diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 862886b757..c93408453a 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -2499,8 +2499,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // Per-turn token usage: each SDKAssistantMessage carries the // wrapped Anthropic API message whose `.usage` reflects what // that single turn consumed. Useful for diagnosing runaway - // loops; logged but not metric'd individually (the result - // message rolls up the session totals). + // loops. The panel keeps these in RAM until the result replaces + // them; message ids let it dedupe repeated content blocks. if (message.type === "assistant" && message.message && message.message.usage) { const u = message.message.usage; @@ -2510,6 +2510,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "cacheRead=" + (u.cache_read_input_tokens || 0), "cacheCreate=" + (u.cache_creation_input_tokens || 0), message.parent_tool_use_id ? "(subagent)" : ""); + nodeConnector.triggerPeer("aiUsageProgress", { + requestId: requestId, + messageId: message.message.id, + parentToolUseId: message.parent_tool_use_id || null, + model: message.message.model, + usage: u + }); } // Aggregate session usage on the terminal `result` message. diff --git a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js index eaf420dc83..5dcf88a111 100644 --- a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js @@ -35,6 +35,43 @@ // send(msgStr) - sends the given message string over the transport. var transport = global._Brackets_LiveDev_Transport; + // The page's own scripts run after this file and may patch built-ins to read or rewrite what goes + // to and from the editor, so everything on that path uses the originals, captured here. The + // editor's Runtime.evaluate scripts run through the real eval, called indirectly so they see only + // the global scope, as they would from the page: never this closure's transport, comm or the CSS + // domain object that shadows window.CSS here. + const _globalEval = global.eval; + const _parse = JSON.parse; + const _stringify = JSON.stringify; + const _Promise = global.Promise; + const _setTimeout = global.setTimeout; + const _clearTimeout = global.clearTimeout; + + /** + * Append without Array.prototype.push, which a page could patch to collect the item. + * @param {Array} arr Destination array. + * @param {*} item Item to append. + */ + function _arrayPush(arr, item) { + arr[arr.length] = item; + } + + /** + * Copy nonmatching items without exposing the array to a page-patched filter. + * @param {Array} arr Source array. + * @param {function(*): boolean} predicate Returns true for items to remove. + * @return {Array} The remaining items, in order. + */ + function _arrayWithout(arr, predicate) { + const kept = []; + for (let i = 0; i < arr.length; i++) { + if (!predicate(arr[i])) { + _arrayPush(kept, arr[i]); + } + } + return kept; + } + /** * Manage messaging between Editor and Browser at the protocol layer. * Handle messages that arrives through the current transport and dispatch them @@ -68,16 +105,15 @@ if (msgHandlers && msgHandlers.length > 0) { // invoke handlers with the received message - msgHandlers.forEach(function (handler) { + // A plain loop: a patched Array.prototype.forEach would be handed the message. + for (let i = 0; i < msgHandlers.length; i++) { try { // TODO: check which context should be used to call handlers here. - handler(msg); - return; + msgHandlers[i](msg); } catch (e) { console.error("[Brackets LiveDev] Error executing a handler for " + msg.method, e.stack); - return; } - }); + } } else { // no subscribers, ignore it. // TODO: any other default handling? (eg. specific respond, trigger as a generic event, etc.); @@ -115,8 +151,7 @@ //initialize array this.handlers[method] = []; } - // add handler to the stack - this.handlers[method].push(handler); + _arrayPush(this.handlers[method], handler); }, /** @@ -124,7 +159,7 @@ * @param {string} msgStr Message to be sent. */ send: function (msgStr) { - transport.send(JSON.stringify(msgStr)); + transport.send(_stringify(msgStr)); } }; @@ -140,9 +175,9 @@ evaluate: function (msg) { // an unanswered request leaves the editor side waiting forever try { - var result = eval(msg.params.expression); + var result = _globalEval(msg.params.expression); MessageBroker.respond(msg, { - result: JSON.stringify(result) // TODO: in original protocol this is an object handle + result: _stringify(result) // TODO: in original protocol this is an object handle }); } catch (e) { console.error("[Brackets LiveDev] Runtime.evaluate failed", e); @@ -349,7 +384,7 @@ * @param {string} msgStr The protocol message as stringified JSON. */ message: function (msgStr) { - const msg = JSON.parse(msgStr); + const msg = _parse(msgStr); _setPCommReady(); if(msg && typeof msg === "object" && msg.method === "PhoenixComm.execLPFn") { _onLPFnTrigger(msg.fnName, msg.params); @@ -397,7 +432,8 @@ let currentFnExecID = 1; let lpCommReady = false; - const pendingExecPromises = new Map(); + // A page-patched Map.prototype.set must not receive the promise's resolve/reject callbacks. + const pendingExecPromises = Object.create(null); let queuedExecRequests = []; // array of { fnName, paramObj, fnExecID } // A pending execPhoenixFn is only ever settled by a response message, so if // the editor disconnects mid-call the promise would hang forever (and any @@ -414,7 +450,7 @@ if (lpCommReady) { MessageBroker.send(payload); } else { - queuedExecRequests.push(payload); + _arrayPush(queuedExecRequests, payload); } } @@ -437,21 +473,25 @@ } registeredPhoenixCommFns[fnName] = fn; }, + /** + * Request an editor function, rejecting if no response arrives before the timeout. + * @param {string} fnName Registered editor function name. + * @param {*} paramObj Function parameters. + * @return {Promise<*>} The editor's response. + */ execPhoenixFn: function (fnName, paramObj) { - return new Promise((resolve, reject) => { + return new _Promise((resolve, reject) => { const fnExecID = currentFnExecID++; - const timer = setTimeout(function () { - if (pendingExecPromises.has(fnExecID)) { - pendingExecPromises.delete(fnExecID); + const timer = _setTimeout(function () { + if (pendingExecPromises[fnExecID]) { + delete pendingExecPromises[fnExecID]; // if still queued (comm never became ready), drop it so a // late flush doesn't execute a call the caller saw fail - queuedExecRequests = queuedExecRequests.filter(function (req) { - return req.fnExecID !== fnExecID; - }); + queuedExecRequests = _arrayWithout(queuedExecRequests, req => req.fnExecID === fnExecID); reject(new Error(`execPhoenixFn timed out: ${fnName}`)); } }, PHOENIX_FN_TIMEOUT_MS); - pendingExecPromises.set(fnExecID, { resolve, reject, timer }); + pendingExecPromises[fnExecID] = { resolve, reject, timer }; _sendOrQueueExec({ execFnName: fnName, paramObj, @@ -468,17 +508,24 @@ PhoenixComm.registerLpFn("PH_LP_COMM_READY", _setPCommReady); + /** + * Settle a pending editor request and clear its timeout; ignore late or unknown replies. + * @param {string} fnName Editor function name, used for diagnostics. + * @param {number} fnExecID Request identifier. + * @param {*} resolveWith Successful result. + * @param {*} rejectWith Error returned by the editor, if any. + */ function _onPhoenixExecResponse(fnName, fnExecID, resolveWith, rejectWith) { - const pendingPromise = pendingExecPromises.get(fnExecID); + const pendingPromise = pendingExecPromises[fnExecID]; if(!pendingPromise) { // already settled (e.g. by the timeout) or an unknown id — bail // rather than dereference undefined and throw in the message handler console.error(`execPhoenixFn: No response promise found! for ${fnName}: ${fnExecID}`); return; } - pendingExecPromises.delete(fnExecID); + delete pendingExecPromises[fnExecID]; if (pendingPromise.timer) { - clearTimeout(pendingPromise.timer); + _clearTimeout(pendingPromise.timer); } if(rejectWith) { pendingPromise.reject(rejectWith); diff --git a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js index 6bb986b0b4..fa94cc917e 100644 --- a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js @@ -19,6 +19,8 @@ * */ +/*global __PHOENIX_LIVE_PREVIEW_TRANSPORT_CONFIG__ */ + // This is a transport injected into the browser via a script that handles the low // level communication between the live development protocol handlers on both sides. // The actual communication to phoenix is done via the loaded web worker below. We just post/receive all @@ -89,20 +91,46 @@ (function (global) { - // The below line will be replaced with the transport scripts provided by the static server at - // LivePreviewTransport.js:getRemoteScript() This is so that the actual live preview page doesnt get hold of - // any phoenix web socket or broadcast channel ids from this closure programatically for security. - - //Replace dynamic section start - const TRANSPORT_CONFIG={}; - //Replace dynamic section end - + // getRemoteScript() replaces this call with a private config initializer before serving the script. + // A call survives minification without depending on declaration spacing or merged const statements. + // Its unknown return value also prevents the minifier from folding config property reads. + const TRANSPORT_CONFIG = __PHOENIX_LIVE_PREVIEW_TRANSPORT_CONFIG__(); + + // The page's own scripts run after this one and may patch built-ins to read or rewrite what goes + // to and from the editor. The channel uses the originals, captured here before any of them runs. + const _apply = Reflect.apply; + const _workerPostMessage = Worker.prototype.postMessage; + const _messageEventData = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data").get; + const _stringify = JSON.stringify; + const _console = global.console; + const _consoleLog = _console.log; + + /** + * Log only when enabled by the editor, using the original console method. + * @param {...*} args Values to log. + */ function _debugLog(...args) { - if(window.LIVE_PREVIEW_DEBUG_ENABLED) { - console.log(...args); + if (TRANSPORT_CONFIG.LIVE_PREVIEW_DEBUG_ENABLED) { + _apply(_consoleLog, _console, args); } } + /** + * Send data through the captured Worker method. + * @param {Object} data Worker message. + */ + function _postToWorker(data) { + _apply(_workerPostMessage, worker, [data]); + } + /** + * Read worker data without invoking a page-patched getter. + * @param {MessageEvent} event Worker event. + * @return {*} The event payload. + */ + function _eventData(event) { + return _apply(_messageEventData, event, []); + } + function createLRU(max = 100) { const map = new Map(); @@ -139,9 +167,10 @@ const worker = new Worker(TRANSPORT_CONFIG.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME); let _workerMessageProcessor; worker.onmessage = (event) => { - const type = event.data.type; + const data = _eventData(event); + const type = data.type; switch (type) { - case 'REDIRECT_PAGE': location.href = event.data.URL; break; + case 'REDIRECT_PAGE': location.href = data.URL; break; default: if(_workerMessageProcessor){ return _workerMessageProcessor(event); @@ -151,7 +180,7 @@ }; // message channel to phoenix connect on load itself. The channel id is injected from phoenix // via LivePreviewTransport.js while serving the instrumented html file - worker.postMessage({ + _postToWorker({ type: "setupPhoenixComm", livePreviewDebugModeEnabled: TRANSPORT_CONFIG.LIVE_PREVIEW_DEBUG_ENABLED, broadcastChannel: TRANSPORT_CONFIG.LIVE_PREVIEW_BROADCAST_CHANNEL_ID, // in browser this will be present, but not in tauri @@ -159,7 +188,7 @@ clientID }); function _postLivePreviewMessage(message) { - worker.postMessage({type: "livePreview", message}); + _postToWorker({type: "livePreview", message}); } let sentTitle, sentFavIconURL; @@ -192,7 +221,7 @@ if(!base64){ base64 = "favicon.ico"; } - worker.postMessage({ + _postToWorker({ type: "updateTitleIcon", faviconBase64: base64 }); @@ -201,7 +230,7 @@ if(sentTitle!== document.title) { sentTitle = document.title; - worker.postMessage({ + _postToWorker({ type: "updateTitleIcon", title: document.title }); @@ -239,18 +268,19 @@ // Listen to the response _workerMessageProcessor = (event) => { + const data = _eventData(event); // Print the result - _debugLog("Live Preview: Browser received event from Phoenix: ", JSON.stringify(event.data)); - const type = event.data.type; + _debugLog("Live Preview: Browser received event from Phoenix: ", _stringify(data)); + const type = data.type; switch (type) { case 'BROWSER_CONNECT': break; // do nothing. This is a loopback message from another live preview tab case 'BROWSER_MESSAGE': break; // do nothing. This is a loopback message from another live preview tab case 'BROWSER_CLOSE': break; // do nothing. This is a loopback message from another live preview tab case 'MESSAGE_FROM_PHOENIX': if (self._callbacks && self._callbacks.message) { - const clientIDs = event.data.clientIDs, - message = event.data.message, - messageID = event.data.messageID; + const clientIDs = data.clientIDs, + message = data.message, + messageID = data.messageID; if(messageID && processedMessageIDs.has(messageID)){ return; // we have already processed this message. } diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 9c62170e54..df45119ced 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -22,7 +22,8 @@ function RemoteFunctions(config = {}) { // this is just a test function here to check if live preview. fn call is working correctly. console.log("Hello World", param); }); - const MessageBroker = window._Brackets_MessageBroker; // to be used by plugins. + // Injected plugins share this closure; the window property is removed after injection. + const MessageBroker = window._Brackets_MessageBroker; const SHARED_STATE = { __description: "Use this to keep shared state for Live Preview Edit instead of window.*", diff --git a/src/LiveDevelopment/LivePreviewConstants.js b/src/LiveDevelopment/LivePreviewConstants.js index 69a46993c7..d1b890a44c 100644 --- a/src/LiveDevelopment/LivePreviewConstants.js +++ b/src/LiveDevelopment/LivePreviewConstants.js @@ -41,6 +41,7 @@ define(function main(require, exports, module) { exports.HIGHLIGHT_CLICK = "click"; exports.PREFERENCE_SHOW_RULER_LINES = "livePreviewShowMeasurements"; + exports.PREFERENCE_SHOW_ASK_AI = "livePreviewShowAskAI"; exports.PREFERENCE_SHOW_STYLES_BAR = "livePreviewShowStylesBar"; diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js index 195e11c444..7c5243ba14 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js +++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js @@ -121,7 +121,22 @@ define(function (require, exports, module) { }; /** - * Returns the instrumented version of the file. + * Insert the runtime before unfinished markup, preserving a leading doctype's parsing mode. + * Only complete comments and a doctype may precede the script; partial markup must + * follow it so an unfinished attribute, comment or raw-text element cannot swallow the runtime. + * @param {string} html Uninstrumentable source text. + * @param {string} remoteScript Blocking runtime script markup. + * @return {string} Source text with the runtime inserted before page content. + */ + function _injectIntoUnfinishedHTML(html, remoteScript) { + // HTML ends a doctype at the first > even inside an unfinished quoted identifier. + const preamble = /^(?:\uFEFF|[\t\n\f\r ]|b">