From fe79d289e92a4927e9c4ddb771017b6d0421e994 Mon Sep 17 00:00:00 2001 From: waml <14224835+waml@users.noreply.github.com> Date: Sun, 17 May 2026 18:02:10 -0700 Subject: [PATCH 1/3] Recover prepared Control Center improvements --- CHANGELOG.md | 13 ++++ main.mjs | 31 +++++++++ package-lock.json | 4 +- package.json | 2 +- ui/control-center.css | 102 +++++++++++++++++++++++++++- ui/control-center.html | 51 ++++++++++---- ui/control-center.js | 151 ++++++++++++++++++++++++++++++++--------- ui/preload.cjs | 3 + ui/preload.mjs | 3 + 9 files changed, 311 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8543970..6080f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.2.2 - 2026-05-17 + +### Fixed +- Restored the full governor UI from the prepared release work: digit-only inputs, narrow two-column layout, safe default values, and protection against live refresh wiping unsaved edits. +- Added the missing risk-disclaimer modal linked from the governor acknowledgement text. +- Restored the compact two-line footer with activity summary and color-coded status messages. +- Added bulk tab visibility bridge methods so the header show/hide-all control can use one desktop IPC call when available. +- Made Chrome CDP the first browser backend option in the Control Center. + +### Verified +- Control Center script syntax check passed. +- Public package dry-run excludes private workflows. + ## 0.2.1 - 2026-05-17 ### Fixed diff --git a/main.mjs b/main.mjs index 4ff3e84..0b53838 100644 --- a/main.mjs +++ b/main.mjs @@ -376,6 +376,37 @@ async function main() { return { ok: true }; }); + const setTabsVisible = async (visible) => { + let changed = 0; + for (const tab of tabs.listTabs()) { + try { + const win = tabs.getWindowById(tab.id); + if (visible) { + if (win.isMinimized?.()) win.restore?.(); + win.show?.(); + win.focus?.(); + } else { + win.minimize?.(); + } + changed += 1; + } catch {} + } + emitTabsChanged(); + return { ok: true, changed }; + }; + + ipcMain.handle('agentify:setTabsVisible', async (_evt, args) => { + return await setTabsVisible(!!args?.visible); + }); + + ipcMain.handle('agentify:showAllTabs', async () => { + return await setTabsVisible(true); + }); + + ipcMain.handle('agentify:hideAllTabs', async () => { + return await setTabsVisible(false); + }); + ipcMain.handle('agentify:closeTab', async (_evt, args) => { const tabId = String(args?.tabId || '').trim(); if (!tabId) throw new Error('missing_tabId'); diff --git a/package-lock.json b/package-lock.json index c4ad405..f74af77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentify/desktop", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentify/desktop", - "version": "0.2.1", + "version": "0.2.2", "license": "MPL-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index a9f33c2..c8cef9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentify/desktop", - "version": "0.2.1", + "version": "0.2.2", "description": "Agentify Desktop control center and MCP server for local AI web sessions", "license": "MPL-2.0", "type": "module", diff --git a/ui/control-center.css b/ui/control-center.css index 7b053e5..5f532c2 100644 --- a/ui/control-center.css +++ b/ui/control-center.css @@ -138,6 +138,17 @@ body { grid-template-columns: 1fr 1fr; gap: 8px 12px; } +.governorIntro { + margin-bottom: 14px; +} +.governorGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; +} +.governorInput { + max-width: 112px; + text-align: right; +} .compactNum { max-width: 160px; } @@ -261,16 +272,95 @@ input::placeholder { color: rgba(233,236,242,0.45); } .hint { font-size: 12px; color: rgba(233,236,242,0.66); line-height: 1.35; margin-top: 8px; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } +.linkButton { + border: 0; + padding: 0; + background: transparent; + color: #93c5fd; + cursor: pointer; + font: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} +.linkButton:hover { color: #bfdbfe; } +.modal { + max-width: min(560px, calc(100vw - 32px)); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; + padding: 0; + background: #10131a; + color: #e9ecf2; + box-shadow: 0 22px 70px rgba(0,0,0,0.52); +} +.modal::backdrop { background: rgba(0,0,0,0.58); } +.modalPanel { padding: 14px; } +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.modalTitle { font-weight: 700; } +.modalBody { + margin-top: 12px; + color: rgba(233,236,242,0.78); + font-size: 13px; + line-height: 1.45; +} +.modalBody p { margin: 0 0 10px; } +.modalBody p:last-child { margin-bottom: 0; } +.compactBtn { + flex: 0 0 auto; + padding: 7px 10px; + border-radius: 10px; + line-height: 1; + white-space: nowrap; +} .footer { border-top: 1px solid rgba(255,255,255,0.08); - padding: 10px 14px; + padding: 6px 10px; display: flex; - justify-content: flex-start; + justify-content: space-between; align-items: center; + gap: 12px; background: rgba(0,0,0,0.15); + min-height: 44px; +} +.footerStatus { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.activityLabel { + font-size: 12px; + font-weight: 700; + color: rgba(233,236,242,0.86); +} +.status { + font-size: 12px; + color: rgba(233,236,242,0.7); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.messageLine { + font-size: 11px; + color: #93c5fd; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.messageLine.isWarn { + color: #fbbf24; +} +.messageLine.isError { + color: #fca5a5; +} +.messageLine.isMuted { + color: rgba(233,236,242,0.52); } -.status { font-size: 12px; color: rgba(233,236,242,0.65); } @media (max-width: 760px) { .header { @@ -284,3 +374,9 @@ input::placeholder { color: rgba(233,236,242,0.45); } font-size: 12px; } } + +@media (max-width: 420px) { + .governorGrid { + grid-template-columns: 1fr; + } +} diff --git a/ui/control-center.html b/ui/control-center.html index 31096c7..18ec606 100644 --- a/ui/control-center.html +++ b/ui/control-center.html @@ -141,26 +141,26 @@
-
Automation limits
-
+
Settings (governor)
+
These defaults exist to reduce accidental high-rate automation. Automating web UIs may violate website terms; use responsibly.
-
+
@@ -176,10 +176,10 @@
-
+ +
+
+
Automation risk controls
+ +
+
+

+ Agentify Desktop controls browser tabs on your machine. High-frequency tab automation can accidentally send repeated prompts, + trigger abuse systems, consume account quota, or violate a website's terms of use. +

+

+ The governor reduces those risks by limiting concurrent prompts, capping prompts per minute, spacing requests to the same tab, + and enforcing a short global gap between sends. CAPTCHA, login, and auth challenges pause automation and bring the tab forward + so you can resolve them manually. +

+

+ These controls are guardrails, not permission to ignore service limits. Keep the defaults unless you have a specific reason, + and raise limits only for websites and accounts where that activity is allowed. +

+
+
+
+ diff --git a/ui/control-center.js b/ui/control-center.js index 4054638..9244995 100644 --- a/ui/control-center.js +++ b/ui/control-center.js @@ -53,12 +53,19 @@ function fmtOutcomeStatus(status) { } function num(id, fallback) { - const v = Number(el(id).value); + const raw = String(el(id).value || '').trim(); + if (!raw) return fallback; + const v = Number(raw); return Number.isFinite(v) ? v : fallback; } -function setNum(id, value) { - el(id).value = String(Number(value)); +function setNum(id, value, fallback = 0) { + const next = Number.isFinite(Number(value)) ? Number(value) : fallback; + el(id).value = String(next); +} + +function setValue(id, value) { + el(id).value = String(value ?? ''); } function setChecked(id, value) { @@ -108,7 +115,7 @@ function defaultState() { tabs: [], defaultTabId: null, stateDir: '', - browserBackend: 'electron', + browserBackend: 'chrome-cdp', browser: null, runtime: { inflightQueries: 0, activeQueries: [], lastOutcomes: [] } }; @@ -116,23 +123,31 @@ function defaultState() { function defaultSettings() { return { - browserBackend: 'electron', + browserBackend: 'chrome-cdp', chromeDebugPort: 9222, chromeExecutablePath: null, chromeProfileMode: 'isolated', chromeProfileName: 'Default', maxInflightQueries: 2, maxQueriesPerMinute: 12, - minTabGapMs: 0, - minGlobalGapMs: 0, + minTabGapMs: 1200, + minGlobalGapMs: 200, showTabsByDefault: false, allowAuthPopups: true, acknowledgedAt: null }; } -function statusText(msg) { - el('statusLine').textContent = msg; +function statusText(msg, tone = 'info') { + const line = el('messageLine'); + line.textContent = msg; + line.classList.toggle('isWarn', tone === 'warn'); + line.classList.toggle('isError', tone === 'error'); + line.classList.toggle('isMuted', tone === 'muted'); +} + +function setActivityText(html) { + el('statusLine').innerHTML = html; } function isChromeCdpSelected() { @@ -150,6 +165,55 @@ let refreshInFlight = null; let lastRefreshAt = 0; let hasLiveUpdates = false; let tabsAreHidden = false; +let settingsDirty = false; + +function updateSaveEnabled() { + el('btnSaveSettings').disabled = !settingsDirty || !el('setAcknowledge').checked; +} + +function markSettingsDirty() { + settingsDirty = true; + updateSaveEnabled(); + el('settingsHint').textContent = 'Unsaved changes.'; +} + +function sanitizeIntegerField(input, { clamp = false } = {}) { + const digits = String(input.value || '').replace(/[^\d]/g, ''); + input.value = digits; + if (!clamp || !digits) return; + const min = Number(input.dataset.min || 0); + const max = Number(input.dataset.max || Number.MAX_SAFE_INTEGER); + const next = Math.max(min, Math.min(max, Number(digits))); + input.value = String(next); +} + +function applySettings(settings) { + const s = { ...defaultSettings(), ...(settings || {}) }; + setValue('setBrowserBackend', s.browserBackend || defaultSettings().browserBackend); + setValue('setChromeProfileMode', s.chromeProfileMode || defaultSettings().chromeProfileMode); + setValue('setChromeProfileName', s.chromeProfileName || defaultSettings().chromeProfileName); + setNum('setMaxInflight', s.maxInflightQueries, defaultSettings().maxInflightQueries); + setNum('setQpm', s.maxQueriesPerMinute, defaultSettings().maxQueriesPerMinute); + setNum('setTabGap', s.minTabGapMs, defaultSettings().minTabGapMs); + setNum('setGlobalGap', s.minGlobalGapMs, defaultSettings().minGlobalGapMs); + setChecked('setShowTabsDefault', s.showTabsByDefault); + setChecked('setAllowAuthPopups', s.allowAuthPopups !== false); + setChecked('setAcknowledge', false); + settingsDirty = false; + updateSaveEnabled(); + el('settingsHint').textContent = s.acknowledgedAt ? `Last acknowledged: ${s.acknowledgedAt}` : 'Using safe defaults until you acknowledge changes.'; + syncChromeProfileFields(); +} + +function closeDialog(dialog) { + if (typeof dialog.close === 'function') dialog.close(); + else dialog.removeAttribute('open'); +} + +function openDialog(dialog) { + if (typeof dialog.showModal === 'function') dialog.showModal(); + else dialog.setAttribute('open', 'open'); +} function tabSortWeight(tab, active, outcome) { if (active?.blocked) return 0; @@ -176,7 +240,15 @@ function updateTabsToggle(tabs = []) { async function setAllTabsVisible(visible) { const tabs = Array.isArray(lastState.tabs) ? lastState.tabs : []; if (!tabs.length) { - statusText('No managed tabs are currently open.'); + statusText('No managed tabs are currently open.', 'muted'); + return; + } + const bridge = getBridge(); + if (typeof bridge?.setTabsVisible === 'function') { + const out = await callApi('setTabsVisible', { visible }, { required: true }); + tabsAreHidden = !visible; + updateTabsToggle(tabs); + statusText(`${visible ? 'Showed' : 'Hid'} ${out?.changed ?? tabs.length} managed tab${(out?.changed ?? tabs.length) === 1 ? '' : 's'}.`); return; } let changed = 0; @@ -454,21 +526,12 @@ async function refresh() { const runningSummary = ` • Running: ${activeQueries.length}`; const liveSummary = hasLiveUpdates ? 'Live updates on' : 'Polling every 3s'; const refreshedSummary = lastRefreshAt ? ` • Refreshed ${new Date(lastRefreshAt).toLocaleTimeString()}` : ''; - statusText(`Backend: ${browserSummary} • Tabs: ${tabs.length}${runningSummary} • ${liveSummary}${refreshedSummary} • State: ${lastState.stateDir || ''}`); - - // Settings UI. - el('setBrowserBackend').value = settings.browserBackend || 'electron'; - el('setChromeProfileMode').value = settings.chromeProfileMode || 'isolated'; - el('setChromeProfileName').value = settings.chromeProfileName || 'Default'; - setNum('setMaxInflight', settings.maxInflightQueries); - setNum('setQpm', settings.maxQueriesPerMinute); - setNum('setTabGap', settings.minTabGapMs); - setNum('setGlobalGap', settings.minGlobalGapMs); - setChecked('setShowTabsDefault', settings.showTabsByDefault); - setChecked('setAllowAuthPopups', settings.allowAuthPopups !== false); - setChecked('setAcknowledge', false); - el('btnSaveSettings').disabled = true; - el('settingsHint').textContent = settings.acknowledgedAt ? `Last acknowledged: ${settings.acknowledgedAt}` : 'Not acknowledged yet.'; + const activity = activeQueries.length + ? activeQueries.map((item) => `${item.tabId || 'tab'} ${String(item.phase || 'working').replace(/_/g, ' ')}`).join(' • ') + : 'Idle'; + setActivityText(`Activity: ${activity} • Backend: ${browserSummary} • Tabs: ${tabs.length}${runningSummary} • ${liveSummary}${refreshedSummary}`); + + if (!settingsDirty) applySettings(settings); })().finally(() => { refreshInFlight = null; }); @@ -568,16 +631,40 @@ async function main() { syncChromeProfileFields(); }; - const updateSaveEnabled = () => { - el('btnSaveSettings').disabled = !el('setAcknowledge').checked; - }; el('setAcknowledge').onchange = updateSaveEnabled; + for (const id of ['setMaxInflight', 'setQpm', 'setTabGap', 'setGlobalGap']) { + const input = el(id); + input.addEventListener('input', () => { + sanitizeIntegerField(input); + markSettingsDirty(); + }); + input.addEventListener('blur', () => { + sanitizeIntegerField(input, { clamp: true }); + markSettingsDirty(); + }); + } + for (const id of ['setShowTabsDefault', 'setAllowAuthPopups', 'setBrowserBackend', 'setChromeProfileMode', 'setChromeProfileName']) { + const input = el(id); + input.addEventListener('input', markSettingsDirty); + input.addEventListener('change', markSettingsDirty); + } syncChromeProfileFields(); + const riskModal = el('riskModal'); + el('btnRiskDetails').onclick = (event) => { + event.preventDefault(); + openDialog(riskModal); + }; + el('btnCloseRiskModal').onclick = () => closeDialog(riskModal); + riskModal.addEventListener('click', (event) => { + if (event.target === riskModal) el('btnCloseRiskModal').click(); + }); + el('btnResetSettings').onclick = async () => { el('settingsHint').textContent = ''; try { await callApi('setSettings', { reset: true }, { required: true }); + settingsDirty = false; el('settingsHint').textContent = 'Reset to defaults.'; await refresh(); } catch (e) { @@ -606,9 +693,11 @@ async function main() { { required: true } ); const backendChanged = String(saved?.browserBackend || 'electron') !== String(lastState.browserBackend || 'electron'); + settingsDirty = false; el('settingsHint').textContent = `Saved.${saved?.acknowledgedAt ? ` ${saved.acknowledgedAt}` : ''}${backendChanged ? ' Restart Agentify Desktop to apply backend changes.' : ''}`; setChecked('setAcknowledge', false); - el('btnSaveSettings').disabled = true; + updateSaveEnabled(); + await refresh(); } catch (e) { el('settingsHint').textContent = `Save failed: ${e?.message || String(e)}`; } @@ -621,12 +710,12 @@ async function main() { b?.onTabsChanged?.(() => refresh().catch(() => {})); } catch (e) { hasLiveUpdates = false; - statusText(`Tabs listener unavailable: ${e?.message || String(e)}`); + statusText(`Live updates unavailable: ${e?.message || String(e)}. Refresh still works.`, 'warn'); setInterval(() => refresh().catch(() => {}), 3000); } } else { hasLiveUpdates = false; - statusText('Tabs listener unavailable (compat mode). Auto-refresh every 3s.'); + statusText('Live updates unavailable in this window. Refresh still works.', 'warn'); setInterval(() => refresh().catch(() => {}), 3000); } diff --git a/ui/preload.cjs b/ui/preload.cjs index 5e86d75..472fe47 100644 --- a/ui/preload.cjs +++ b/ui/preload.cjs @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld('agentifyDesktop', { createTab: (args) => ipcRenderer.invoke('agentify:createTab', args || {}), showTab: (args) => ipcRenderer.invoke('agentify:showTab', args || {}), hideTab: (args) => ipcRenderer.invoke('agentify:hideTab', args || {}), + setTabsVisible: (args) => ipcRenderer.invoke('agentify:setTabsVisible', args || {}), + showAllTabs: () => ipcRenderer.invoke('agentify:showAllTabs'), + hideAllTabs: () => ipcRenderer.invoke('agentify:hideAllTabs'), closeTab: (args) => ipcRenderer.invoke('agentify:closeTab', args || {}), stopQuery: (args) => ipcRenderer.invoke('agentify:stopQuery', args || {}), openStateDir: () => ipcRenderer.invoke('agentify:openStateDir'), diff --git a/ui/preload.mjs b/ui/preload.mjs index 754a784..e308ae2 100644 --- a/ui/preload.mjs +++ b/ui/preload.mjs @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld('agentifyDesktop', { createTab: (args) => ipcRenderer.invoke('agentify:createTab', args || {}), showTab: (args) => ipcRenderer.invoke('agentify:showTab', args || {}), hideTab: (args) => ipcRenderer.invoke('agentify:hideTab', args || {}), + setTabsVisible: (args) => ipcRenderer.invoke('agentify:setTabsVisible', args || {}), + showAllTabs: () => ipcRenderer.invoke('agentify:showAllTabs'), + hideAllTabs: () => ipcRenderer.invoke('agentify:hideAllTabs'), closeTab: (args) => ipcRenderer.invoke('agentify:closeTab', args || {}), stopQuery: (args) => ipcRenderer.invoke('agentify:stopQuery', args || {}), openStateDir: () => ipcRenderer.invoke('agentify:openStateDir'), From dd243855f75d1027eacf4a71e8626b0f70894f95 Mon Sep 17 00:00:00 2001 From: waml <14224835+waml@users.noreply.github.com> Date: Sun, 17 May 2026 19:20:40 -0700 Subject: [PATCH 2/3] Fix Windows Electron launcher --- .github/workflows/ci.yml | 7 ++++++- CHANGELOG.md | 10 ++++++++++ README.md | 13 +++++++++++++ bin/agentify-desktop.mjs | 32 +++++++++++++++++++++++++----- mcp-lib.mjs | 42 ++++++++++++++++++++++++---------------- package-lock.json | 4 ++-- package.json | 2 +- tests/mcp-lib.test.mjs | 23 +++++++++++++++------- 8 files changed, 100 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b945c6..de51fd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,11 @@ on: jobs: test: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -14,4 +18,5 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: node bin/agentify-desktop.mjs --help - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 6080f9c..49a65ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.3 - 2026-05-17 + +### Fixed +- Fixed the Windows npm/global GUI launcher path by running Electron through its package CLI with Node instead of the Windows `.cmd` shim. +- Applied the same safer Electron launch resolution to MCP desktop auto-start. + +### Changed +- Added Windows CI coverage for install/test and the npm CLI help path. +- Added README Windows notes for Chrome CDP and explicit browser executable configuration. + ## 0.2.2 - 2026-05-17 ### Fixed diff --git a/README.md b/README.md index f1f1d4c..b6f06ac 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,19 @@ Agentify Desktop does not bypass CAPTCHAs or use third-party solvers. If a verif If your account uses Google, Microsoft, or Apple SSO, keep auth popups enabled in the Control Center. If embedded login remains unreliable, use Chrome CDP. +## Windows Notes + +Use Node.js 20 or 22 on Windows. Agentify Desktop is tested against Windows in CI, including the npm CLI launcher path. + +Chrome CDP is still the recommended backend on Windows because Google and Microsoft SSO can block embedded Electron login. Agentify looks for Chrome, Chromium, Brave, and Microsoft Edge in the usual install locations and on `PATH`. + +If Chrome CDP cannot find your browser, set the executable explicitly: + +```powershell +$env:AGENTIFY_DESKTOP_CHROME_BIN = "C:\Program Files\Google\Chrome\Application\chrome.exe" +npx @agentify/desktop +``` + ## Local Data And Privacy Agentify Desktop is local-first: diff --git a/bin/agentify-desktop.mjs b/bin/agentify-desktop.mjs index fcad626..e1696b0 100755 --- a/bin/agentify-desktop.mjs +++ b/bin/agentify-desktop.mjs @@ -40,15 +40,36 @@ function resolveMode(invokedName, argv) { return { mode: 'unknown', args: argv }; } -function electronBin() { +function electronLaunch() { + const override = String(process.env.AGENTIFY_DESKTOP_ELECTRON_BIN || '').trim(); + if (override) { + return { + command: override, + argsPrefix: [], + shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(override) + }; + } + + const electronCli = path.join(packageRoot, 'node_modules', 'electron', 'cli.js'); + if (fs.existsSync(electronCli)) { + return { command: process.execPath, argsPrefix: [electronCli], shell: false }; + } + const local = path.join( packageRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron' ); - if (fs.existsSync(local)) return local; - return process.env.AGENTIFY_DESKTOP_ELECTRON_BIN || 'electron'; + if (fs.existsSync(local)) { + return { + command: local, + argsPrefix: [], + shell: process.platform === 'win32' + }; + } + + return { command: 'electron', argsPrefix: [], shell: process.platform === 'win32' }; } async function runMcp(args) { @@ -58,10 +79,11 @@ async function runMcp(args) { } function runGui(args) { - const child = spawn(electronBin(), [packageRoot, ...args], { + const launch = electronLaunch(); + const child = spawn(launch.command, [...launch.argsPrefix, packageRoot, ...args], { stdio: 'inherit', env: process.env, - shell: process.platform === 'win32' + shell: launch.shell }); child.on('error', (err) => { console.error(`agentify-desktop failed to start: ${err.message}`); diff --git a/mcp-lib.mjs b/mcp-lib.mjs index 04d18e9..4167430 100644 --- a/mcp-lib.mjs +++ b/mcp-lib.mjs @@ -21,6 +21,28 @@ async function fileExists(p) { } } +async function electronLaunch({ platform, allowFallback = false }) { + const override = String(process.env.AGENTIFY_DESKTOP_ELECTRON_BIN || '').trim(); + if (override) { + return { + command: override, + argsPrefix: [], + shell: platform === 'win32' && /\.(cmd|bat)$/i.test(override) + }; + } + + const electronCli = path.resolve(__dirname, 'node_modules', 'electron', 'cli.js'); + if (await fileExists(electronCli)) { + return { command: process.execPath, argsPrefix: [electronCli], shell: false }; + } + + if (allowFallback) { + return { command: 'electron', argsPrefix: [], shell: platform === 'win32' }; + } + + throw new Error('missing_electron_binary'); +} + export async function loadConnection({ stateDir }) { const state = await readState(stateDir); const token = await readToken(stateDir); @@ -80,25 +102,11 @@ export async function ensureDesktopRunning({ } } - const defaultElectronBin = path.resolve( - __dirname, - 'node_modules', - '.bin', - platform === 'win32' ? 'electron.cmd' : 'electron' - ); const entry = path.join(__dirname, 'main.mjs'); - const usingCustomSpawn = spawnImpl !== spawn; - let electronBin = defaultElectronBin; - if (!(await fileExists(electronBin))) { - if (usingCustomSpawn) { - electronBin = process.env.AGENTIFY_DESKTOP_ELECTRON_BIN || 'electron'; - } else { - throw new Error('missing_electron_binary'); - } - } + const launch = await electronLaunch({ platform, allowFallback: spawnImpl !== spawn }); if (!(await fileExists(entry))) throw new Error('missing_desktop_entry'); - spawnImpl(electronBin, [entry], { + spawnImpl(launch.command, [...launch.argsPrefix, entry], { detached: true, stdio: 'ignore', env: { @@ -106,7 +114,7 @@ export async function ensureDesktopRunning({ AGENTIFY_DESKTOP_STATE_DIR: stateDir, ...(showTabs ? { AGENTIFY_DESKTOP_SHOW_TABS: 'true' } : {}) }, - shell: platform === 'win32' + shell: launch.shell })?.unref?.(); const start = Date.now(); diff --git a/package-lock.json b/package-lock.json index f74af77..766e16f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentify/desktop", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentify/desktop", - "version": "0.2.2", + "version": "0.2.3", "license": "MPL-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index c8cef9b..7020cbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentify/desktop", - "version": "0.2.2", + "version": "0.2.3", "description": "Agentify Desktop control center and MCP server for local AI web sessions", "license": "MPL-2.0", "type": "module", diff --git a/tests/mcp-lib.test.mjs b/tests/mcp-lib.test.mjs index 45ea111..54124c2 100644 --- a/tests/mcp-lib.test.mjs +++ b/tests/mcp-lib.test.mjs @@ -105,7 +105,7 @@ test('mcp-lib: ensureDesktopRunning spawns if serverId mismatches and then recov assert.equal(conn.serverId, 'sid-new'); }); -test('mcp-lib: Windows spawn uses shell for electron.cmd', async () => { +test('mcp-lib: Windows spawn uses Node-hosted Electron CLI without shell', async () => { const dir = await tempDir(); const token = 't'; await ensureToken(dir); @@ -113,13 +113,17 @@ test('mcp-lib: Windows spawn uses shell for electron.cmd', async () => { await writeState({ ok: true, port: 12345, serverId: 'sid-old' }, dir); let fetchServerId = 'sid-wrong'; - let sawShell = false; + let spawnedCmd = null; + let spawnedArgs = null; + let spawnShell = null; const conn = await ensureDesktopRunning({ stateDir: dir, fetchImpl: makeFetch({ getServerId: () => fetchServerId, acceptToken: token }), platform: 'win32', - spawnImpl: (_cmd, _args, opts) => { - sawShell = opts?.shell === true; + spawnImpl: (cmd, args, opts) => { + spawnedCmd = cmd; + spawnedArgs = args; + spawnShell = opts?.shell; fetchServerId = 'sid-new'; void writeState({ ok: true, port: 12345, serverId: 'sid-new' }, dir); return { unref() {} }; @@ -127,7 +131,11 @@ test('mcp-lib: Windows spawn uses shell for electron.cmd', async () => { timeoutMs: 3000 }); assert.equal(conn.serverId, 'sid-new'); - assert.equal(sawShell, true); + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + assert.equal(spawnedCmd, process.execPath); + assert.equal(spawnedArgs?.[0], path.join(packageRoot, 'node_modules', 'electron', 'cli.js')); + assert.equal(spawnedArgs?.[1], path.join(packageRoot, 'main.mjs')); + assert.equal(spawnShell, false); }); test('mcp-lib: ensureDesktopRunning resolves bundled electron relative to desktop package, not cwd', async () => { @@ -157,8 +165,9 @@ test('mcp-lib: ensureDesktopRunning resolves bundled electron relative to deskto assert.equal(conn.serverId, 'sid-new'); assert.equal(path.isAbsolute(spawnedCmd), true); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - assert.equal(spawnedCmd, path.join(packageRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron')); - assert.equal(spawnedArgs?.[0], path.join(packageRoot, 'main.mjs')); + assert.equal(spawnedCmd, process.execPath); + assert.equal(spawnedArgs?.[0], path.join(packageRoot, 'node_modules', 'electron', 'cli.js')); + assert.equal(spawnedArgs?.[1], path.join(packageRoot, 'main.mjs')); } finally { process.chdir(originalCwd); } From 07f7551768cd3b55fc8b2171d56319257919cca4 Mon Sep 17 00:00:00 2001 From: waml <14224835+waml@users.noreply.github.com> Date: Sun, 17 May 2026 19:24:28 -0700 Subject: [PATCH 3/3] Make context tests Windows portable --- context-packer.mjs | 6 +++++- tests/bundle-store.test.mjs | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/context-packer.mjs b/context-packer.mjs index b10badc..6f40c24 100644 --- a/context-packer.mjs +++ b/context-packer.mjs @@ -58,6 +58,10 @@ function normalizeAbsoluteInputPath(value, { cwd = process.cwd() } = {}) { return path.isAbsolute(raw) ? raw : path.resolve(cwd, raw); } +function displayPath(filePath) { + return String(filePath || '').replace(/\\/g, '/'); +} + function looksBinaryByName(filePath) { return BINARY_EXTS.has(extnameLower(filePath)); } @@ -295,7 +299,7 @@ export async function prepareQueryContext({ for (const file of files) { const rel = roots.length ? path.relative(path.dirname(roots[0].path), file.absPath) : path.basename(file.absPath); - const named = rel && !rel.startsWith('..') ? rel : path.basename(file.absPath); + const named = displayPath(rel && !rel.startsWith('..') ? rel : path.basename(file.absPath)); if (looksBinaryByName(file.absPath)) { if (attachedFiles.length < maxAttachmentFiles && file.size <= maxBinaryAttachmentBytes && !attachedSet.has(file.absPath)) { attachedFiles.push({ path: file.absPath, reason: 'context-binary', size: file.size }); diff --git a/tests/bundle-store.test.mjs b/tests/bundle-store.test.mjs index 82dcf90..9d0065b 100644 --- a/tests/bundle-store.test.mjs +++ b/tests/bundle-store.test.mjs @@ -56,6 +56,8 @@ test('bundle-store: ignores blank attachment/context entries', async () => { test('bundle-store: ignores legacy relative paths when reading persisted bundles', async () => { const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentify-bundles-legacy-relative-')); const bundleFile = path.join(stateDir, 'bundles.json'); + const absFile = path.join(stateDir, 'abs.txt'); + const absDir = path.join(stateDir, 'abs-dir'); await fs.writeFile( bundleFile, JSON.stringify( @@ -64,8 +66,8 @@ test('bundle-store: ignores legacy relative paths when reading persisted bundles { name: 'legacy', promptPrefix: 'Review carefully.', - attachments: ['./README.md', '/tmp/abs.txt'], - contextPaths: ['./src', '/tmp/abs-dir'] + attachments: ['./README.md', absFile], + contextPaths: ['./src', absDir] } ] }, @@ -76,8 +78,8 @@ test('bundle-store: ignores legacy relative paths when reading persisted bundles ); const got = await getBundle(stateDir, 'legacy'); - assert.equal(got?.attachments.includes('/tmp/abs.txt'), true); - assert.equal(got?.contextPaths.includes('/tmp/abs-dir'), true); + assert.equal(got?.attachments.includes(absFile), true); + assert.equal(got?.contextPaths.includes(absDir), true); assert.equal(got?.attachments.some((p) => !path.isAbsolute(p)), false); assert.equal(got?.contextPaths.some((p) => !path.isAbsolute(p)), false); assert.equal(got?.attachments.includes(path.resolve('./README.md')), false);