promethios-bridge 2.1.8 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promethios-bridge",
3
- "version": "2.1.8",
3
+ "version": "2.2.1",
4
4
  "description": "Run Promethios agent frameworks locally on your computer with full file, terminal, browser access, ambient context capture, and the always-on-top floating chat overlay. Native Framework Mode supports OpenClaw and other frameworks via the bridge.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/bridge.js CHANGED
@@ -39,7 +39,12 @@ const { initAndroidTools } = require('./tools/android');
39
39
 
40
40
  // Optional: Electron overlay window (bundled in src/overlay — gracefully skipped if Electron not available)
41
41
  let launchOverlay = null;
42
- try { launchOverlay = require('./overlay/launcher').launchOverlay; } catch { /* overlay launcher not found */ }
42
+ let isDesktopInstalled = null;
43
+ try {
44
+ const overlayLauncher = require('./overlay/launcher');
45
+ launchOverlay = overlayLauncher.launchOverlay;
46
+ isDesktopInstalled = overlayLauncher.isDesktopInstalled;
47
+ } catch { /* overlay launcher not found */ }
43
48
 
44
49
  const HEARTBEAT_INTERVAL = 30_000; // 30s
45
50
  const POLL_INTERVAL = 1_000; // 1s — poll for pending tool calls
@@ -485,9 +490,188 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
485
490
  }
486
491
  });
487
492
 
493
+
494
+ // ── MCP OAuth routes (for the browser-based overlay Providers tab) ──────────
495
+ // These routes implement the same OAuth flow as the Electron mcp-oauth.js,
496
+ // but running entirely inside the Express server so the npx bridge can use it.
497
+ //
498
+ // Token storage: in-memory map (resets on bridge restart).
499
+ // For persistence across restarts, tokens are also saved to ~/.promethios/mcp-tokens.json
500
+ const path = require('path');
501
+ const fs = require('fs');
502
+ const http = require('http');
503
+ const { URL: NodeURL } = require('url');
504
+ const crypto = require('crypto');
505
+
506
+ const MCP_TOKENS_PATH = path.join(
507
+ process.env.HOME || process.env.USERPROFILE || require('os').homedir(),
508
+ '.promethios', 'mcp-tokens.json'
509
+ );
510
+ function loadMcpTokens() {
511
+ try {
512
+ fs.mkdirSync(path.dirname(MCP_TOKENS_PATH), { recursive: true });
513
+ return JSON.parse(fs.readFileSync(MCP_TOKENS_PATH, 'utf8'));
514
+ } catch { return {}; }
515
+ }
516
+ function saveMcpTokens(tokens) {
517
+ try {
518
+ fs.mkdirSync(path.dirname(MCP_TOKENS_PATH), { recursive: true });
519
+ fs.writeFileSync(MCP_TOKENS_PATH, JSON.stringify(tokens, null, 2), 'utf8');
520
+ } catch (err) { log('[mcp-tokens] save failed:', err.message); }
521
+ }
522
+ const mcpTokens = loadMcpTokens(); // { manus: 'tok_...', claude: 'tok_...', ... }
523
+
524
+ const PROVIDER_NAMES = {
525
+ manus: 'Manus', claude: 'Claude', chatgpt: 'ChatGPT',
526
+ gemini: 'Gemini', perplexity: 'Perplexity',
527
+ };
528
+
529
+ // Pending OAuth servers: clientId → { server, resolve, reject }
530
+ const _pendingOAuth = new Map();
531
+ let _oauthCallbackPort = 7826;
532
+
533
+ // GET /mcp-oauth-status — return connected providers
534
+ app.get('/mcp-oauth-status', (req, res) => {
535
+ const tokens = {};
536
+ for (const [id, tok] of Object.entries(mcpTokens)) {
537
+ tokens[id] = { connected: true, tokenPrefix: tok.slice(0, 16) + '...' };
538
+ }
539
+ res.json({ ok: true, tokens });
540
+ });
541
+
542
+ // POST /mcp-oauth-start — start OAuth flow for a provider
543
+ // Body: { clientId: 'manus' }
544
+ // Opens the consent page in the system browser and waits for the callback.
545
+ app.post('/mcp-oauth-start', async (req, res) => {
546
+ const { clientId } = req.body || {};
547
+ if (!clientId) return res.status(400).json({ ok: false, error: 'Missing clientId' });
548
+ // Close any existing pending server for this client
549
+ if (_pendingOAuth.has(clientId)) {
550
+ try { _pendingOAuth.get(clientId).server.close(); } catch {}
551
+ _pendingOAuth.delete(clientId);
552
+ }
553
+ const cbPort = _oauthCallbackPort++;
554
+ const redirectUri = `http://localhost:${cbPort}/callback`;
555
+ const state = crypto.randomBytes(16).toString('hex');
556
+ const authorizeUrl = new NodeURL(`${apiBase}/api/mcp/oauth/authorize`);
557
+ authorizeUrl.searchParams.set('client_id', clientId);
558
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri);
559
+ authorizeUrl.searchParams.set('state', state);
560
+ authorizeUrl.searchParams.set('response_type', 'code');
561
+ if (authToken) authorizeUrl.searchParams.set('bridge_token', authToken);
562
+ const successHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Connected</title>
563
+ <style>body{font-family:system-ui,sans-serif;background:#0f0f14;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:16px;}
564
+ .check{width:56px;height:56px;background:rgba(139,92,246,.15);border:2px solid rgba(139,92,246,.4);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:24px;}
565
+ h1{font-size:20px;font-weight:700;margin:0;}p{font-size:13px;color:rgba(255,255,255,.45);margin:0;text-align:center;}</style></head>
566
+ <body><div class="check">&#10003;</div><h1>Connected to Promethios!</h1><p>Authorization complete.<br>You can close this tab and return to the overlay.</p></body></html>`;
567
+ const errorHtml = (msg) => `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Failed</title>
568
+ <style>body{font-family:system-ui,sans-serif;background:#0f0f14;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:16px;}
569
+ .x{width:56px;height:56px;background:rgba(239,68,68,.1);border:2px solid rgba(239,68,68,.3);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:24px;color:#ef4444;}
570
+ h1{font-size:20px;font-weight:700;margin:0;}p{font-size:13px;color:rgba(255,255,255,.45);margin:0;text-align:center;}</style></head>
571
+ <body><div class="x">&#10007;</div><h1>Connection Failed</h1><p>${msg}<br>Please close this tab and try again.</p></body></html>`;
572
+ const server = http.createServer(async (cbReq, cbRes) => {
573
+ const reqUrl = new NodeURL(cbReq.url, `http://localhost:${cbPort}`);
574
+ if (reqUrl.pathname !== '/callback') { cbRes.writeHead(404); cbRes.end('Not found'); return; }
575
+ const code = reqUrl.searchParams.get('code');
576
+ const retState = reqUrl.searchParams.get('state');
577
+ const token = reqUrl.searchParams.get('token') || reqUrl.searchParams.get('access_token');
578
+ const error = reqUrl.searchParams.get('error');
579
+ try { server.close(); } catch {}
580
+ _pendingOAuth.delete(clientId);
581
+ if (error) {
582
+ cbRes.writeHead(200, { 'Content-Type': 'text/html' }); cbRes.end(errorHtml(error));
583
+ return res.json({ ok: false, error: `Authorization denied: ${error}` });
584
+ }
585
+ if (token) {
586
+ cbRes.writeHead(200, { 'Content-Type': 'text/html' }); cbRes.end(successHtml);
587
+ mcpTokens[clientId] = token;
588
+ saveMcpTokens(mcpTokens);
589
+ return res.json({ ok: true, providerName: PROVIDER_NAMES[clientId] || clientId });
590
+ }
591
+ if (!code) {
592
+ cbRes.writeHead(400, { 'Content-Type': 'text/html' }); cbRes.end(errorHtml('No authorization code received'));
593
+ return res.json({ ok: false, error: 'No authorization code received' });
594
+ }
595
+ if (retState !== state) {
596
+ cbRes.writeHead(400, { 'Content-Type': 'text/html' }); cbRes.end(errorHtml('State mismatch'));
597
+ return res.json({ ok: false, error: 'State mismatch — possible CSRF' });
598
+ }
599
+ cbRes.writeHead(200, { 'Content-Type': 'text/html' }); cbRes.end(successHtml);
600
+ try {
601
+ const tokenRes = await fetch(`${apiBase}/api/mcp/oauth/token`, {
602
+ method: 'POST',
603
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
604
+ body: new URLSearchParams({
605
+ grant_type: 'authorization_code',
606
+ code,
607
+ client_id: clientId,
608
+ redirect_uri: redirectUri,
609
+ }).toString(),
610
+ });
611
+ const tokenData = await tokenRes.json();
612
+ if (!tokenRes.ok || !tokenData.access_token) {
613
+ const msg = tokenData.error_description || tokenData.error || 'Token exchange failed';
614
+ return res.json({ ok: false, error: msg });
615
+ }
616
+ const tok = tokenData.access_token;
617
+ mcpTokens[clientId] = tok;
618
+ saveMcpTokens(mcpTokens);
619
+ res.json({ ok: true, providerName: PROVIDER_NAMES[clientId] || clientId });
620
+ } catch (err) {
621
+ res.json({ ok: false, error: err.message });
622
+ }
623
+ });
624
+ server.on('error', (err) => {
625
+ _pendingOAuth.delete(clientId);
626
+ res.json({ ok: false, error: 'OAuth callback server error: ' + err.message });
627
+ });
628
+ _pendingOAuth.set(clientId, { server });
629
+ server.listen(cbPort, '127.0.0.1', () => {
630
+ // Open system browser
631
+ const { exec } = require('child_process');
632
+ const platform = process.platform;
633
+ const url = authorizeUrl.toString();
634
+ let cmd;
635
+ if (platform === 'win32') cmd = `start "" "${url}"`;
636
+ else if (platform === 'darwin') cmd = `open "${url}"`;
637
+ else cmd = `xdg-open "${url}"`;
638
+ exec(cmd, (err) => { if (err) log('[mcp-oauth] open browser failed:', err.message); });
639
+ log('[mcp-oauth] Opened browser for', clientId, '→', url);
640
+ // Auto-timeout after 5 minutes
641
+ setTimeout(() => {
642
+ if (_pendingOAuth.has(clientId)) {
643
+ try { server.close(); } catch {}
644
+ _pendingOAuth.delete(clientId);
645
+ if (!res.headersSent) res.json({ ok: false, error: 'Authorization timed out — please try again' });
646
+ }
647
+ }, 5 * 60 * 1000);
648
+ });
649
+ });
650
+
651
+ // POST /mcp-oauth-disconnect — remove stored token for a provider
652
+ app.post('/mcp-oauth-disconnect', async (req, res) => {
653
+ const { clientId } = req.body || {};
654
+ if (!clientId) return res.status(400).json({ ok: false, error: 'Missing clientId' });
655
+ const tok = mcpTokens[clientId];
656
+ if (tok) {
657
+ // Best-effort server-side revocation
658
+ try {
659
+ await fetch(`${apiBase}/api/mcp/oauth/revoke`, {
660
+ method: 'POST',
661
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
662
+ body: new URLSearchParams({ token: tok, client_id: clientId }).toString(),
663
+ });
664
+ } catch {}
665
+ delete mcpTokens[clientId];
666
+ saveMcpTokens(mcpTokens);
667
+ }
668
+ res.json({ ok: true });
669
+ });
670
+
488
671
  app.get('/overlay', (req, res) => {
489
672
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
490
- // NOTE: The overlay HTML talks to /chat-proxy, /models-proxy, /set-model-proxy
673
+ // NOTE: The overlay HTML talks to /chat-proxy, /models-proxy, /set-model-proxy,
674
+ // /mcp-oauth-start, /mcp-oauth-status, /mcp-oauth-disconnect
491
675
  // (all same origin, no CORS). It never calls the remote API directly.
492
676
  res.send(`<!DOCTYPE html>
493
677
  <html lang="en">
@@ -533,7 +717,29 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
533
717
  background: #1e1030; border: 1px solid #3b1f6b;
534
718
  border-radius: 4px; padding: 1px 6px;
535
719
  }
536
- /* ── Model selector ── */
720
+ /* ── Tab bar ── */
721
+ #tab-bar {
722
+ display: flex;
723
+ background: #111113;
724
+ border-bottom: 1px solid #1f1f23;
725
+ flex-shrink: 0;
726
+ }
727
+ .tab-btn {
728
+ flex: 1;
729
+ padding: 9px 0;
730
+ font-size: 12px;
731
+ font-weight: 500;
732
+ color: #52525b;
733
+ background: none;
734
+ border: none;
735
+ border-bottom: 2px solid transparent;
736
+ cursor: pointer;
737
+ transition: color 0.15s, border-color 0.15s;
738
+ letter-spacing: 0.01em;
739
+ }
740
+ .tab-btn:hover { color: #a1a1aa; }
741
+ .tab-btn.active { color: #a855f7; border-bottom-color: #7c3aed; }
742
+ /* ── Model selector (in header) ── */
537
743
  #model-wrap {
538
744
  margin-left: auto;
539
745
  position: relative;
@@ -602,7 +808,10 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
602
808
  background: #22c55e; flex-shrink: 0;
603
809
  box-shadow: 0 0 6px #22c55e88;
604
810
  }
605
- /* ── Messages ── */
811
+ /* ── Tab panels ── */
812
+ .tab-panel { display: none; flex: 1; flex-direction: column; overflow: hidden; }
813
+ .tab-panel.active { display: flex; }
814
+ /* ── Chat panel ── */
606
815
  #messages {
607
816
  flex: 1;
608
817
  overflow-y: auto;
@@ -701,6 +910,143 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
701
910
  }
702
911
  #send:hover { background: #6d28d9; }
703
912
  #send:disabled { background: #27272a; cursor: not-allowed; }
913
+ /* ── Providers tab ── */
914
+ #providers-panel {
915
+ flex: 1;
916
+ overflow-y: auto;
917
+ padding: 14px 12px;
918
+ display: flex;
919
+ flex-direction: column;
920
+ gap: 8px;
921
+ }
922
+ #providers-panel::-webkit-scrollbar { width: 4px; }
923
+ #providers-panel::-webkit-scrollbar-track { background: transparent; }
924
+ #providers-panel::-webkit-scrollbar-thumb { background: #27272a; border-radius: 2px; }
925
+ .providers-heading {
926
+ font-size: 11px;
927
+ font-weight: 600;
928
+ color: #52525b;
929
+ text-transform: uppercase;
930
+ letter-spacing: 0.07em;
931
+ padding: 4px 2px 8px;
932
+ }
933
+ .provider-row {
934
+ background: #111113;
935
+ border: 1px solid #1f1f23;
936
+ border-radius: 12px;
937
+ padding: 12px 14px;
938
+ display: flex;
939
+ align-items: center;
940
+ gap: 12px;
941
+ transition: border-color 0.15s;
942
+ }
943
+ .provider-row:hover { border-color: #27272a; }
944
+ .provider-row.connected { border-color: #1a3a2a; background: #0d1f17; }
945
+ .provider-icon {
946
+ font-size: 22px;
947
+ width: 36px; height: 36px;
948
+ display: flex; align-items: center; justify-content: center;
949
+ flex-shrink: 0;
950
+ background: #18181b;
951
+ border-radius: 8px;
952
+ border: 1px solid #27272a;
953
+ }
954
+ .provider-info { flex: 1; min-width: 0; }
955
+ .provider-name {
956
+ font-size: 13px;
957
+ font-weight: 600;
958
+ color: #e4e4e7;
959
+ margin-bottom: 2px;
960
+ }
961
+ .provider-desc {
962
+ font-size: 11px;
963
+ color: #52525b;
964
+ white-space: nowrap;
965
+ overflow: hidden;
966
+ text-overflow: ellipsis;
967
+ }
968
+ .provider-desc.connected-text { color: #22c55e; }
969
+ .provider-actions {
970
+ display: flex;
971
+ gap: 6px;
972
+ flex-shrink: 0;
973
+ }
974
+ .btn-open {
975
+ background: #18181b;
976
+ border: 1px solid #27272a;
977
+ border-radius: 7px;
978
+ color: #a1a1aa;
979
+ font-size: 11px;
980
+ font-weight: 500;
981
+ padding: 5px 10px;
982
+ cursor: pointer;
983
+ white-space: nowrap;
984
+ transition: border-color 0.15s, color 0.15s;
985
+ text-decoration: none;
986
+ display: inline-flex; align-items: center; gap: 4px;
987
+ }
988
+ .btn-open:hover { border-color: #7c3aed; color: #e4e4e7; }
989
+ .btn-connect {
990
+ background: #1e1030;
991
+ border: 1px solid #3b1f6b;
992
+ border-radius: 7px;
993
+ color: #a855f7;
994
+ font-size: 11px;
995
+ font-weight: 500;
996
+ padding: 5px 10px;
997
+ cursor: pointer;
998
+ white-space: nowrap;
999
+ transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
1000
+ display: inline-flex; align-items: center; gap: 4px;
1001
+ }
1002
+ .btn-connect:hover:not(:disabled) { background: #2d1a4a; border-color: #7c3aed; }
1003
+ .btn-connect:disabled { opacity: 0.4; cursor: not-allowed; }
1004
+ .btn-connect.connected-state {
1005
+ background: #0d1f17;
1006
+ border-color: #1a3a2a;
1007
+ color: #22c55e;
1008
+ }
1009
+ .btn-connect.connecting { opacity: 0.7; cursor: wait; }
1010
+ .connect-dot {
1011
+ width: 6px; height: 6px; border-radius: 50%;
1012
+ background: currentColor;
1013
+ display: inline-block;
1014
+ }
1015
+ .providers-footer {
1016
+ margin-top: 8px;
1017
+ padding: 10px 12px;
1018
+ background: #0d0d10;
1019
+ border: 1px solid #1f1f23;
1020
+ border-radius: 10px;
1021
+ font-size: 11px;
1022
+ color: #3f3f46;
1023
+ line-height: 1.6;
1024
+ }
1025
+ .providers-footer a { color: #7c3aed; text-decoration: none; }
1026
+ .providers-footer a:hover { text-decoration: underline; }
1027
+ /* ── Toast notification ── */
1028
+ #toast {
1029
+ position: fixed;
1030
+ bottom: 16px;
1031
+ left: 50%;
1032
+ transform: translateX(-50%) translateY(20px);
1033
+ background: #1a1a24;
1034
+ border: 1px solid #2a2a3a;
1035
+ border-radius: 10px;
1036
+ padding: 9px 16px;
1037
+ font-size: 12px;
1038
+ color: #e4e4e7;
1039
+ z-index: 9999;
1040
+ opacity: 0;
1041
+ transition: opacity 0.2s, transform 0.2s;
1042
+ pointer-events: none;
1043
+ white-space: nowrap;
1044
+ max-width: 90vw;
1045
+ text-align: center;
1046
+ }
1047
+ #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
1048
+ #toast.error { border-color: #7f1d1d; background: #1a0d0d; color: #fca5a5; }
1049
+ #toast.success { border-color: #14532d; background: #0d1f17; color: #86efac; }
704
1050
  </style>
705
1051
  </head>
706
1052
  <body>
@@ -714,19 +1060,38 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
714
1060
  </div>
715
1061
  <div id="status-dot"></div>
716
1062
  </div>
717
- <div id="messages">
718
- <div class="msg system">Your computer is connected. Ask me anything.</div>
1063
+ <div id="tab-bar">
1064
+ <button class="tab-btn active" data-tab="chat">Chat</button>
1065
+ <button class="tab-btn" data-tab="providers">Providers</button>
719
1066
  </div>
720
- <div id="input-row">
721
- <textarea id="input" placeholder="Ask Promethios..." rows="1"></textarea>
722
- <button id="send" title="Send (Enter)">&#9650;</button>
1067
+ <!-- Chat tab -->
1068
+ <div id="tab-chat" class="tab-panel active">
1069
+ <div id="messages">
1070
+ <div class="msg system">Your computer is connected. Ask me anything.</div>
1071
+ </div>
1072
+ <div id="input-row">
1073
+ <textarea id="input" placeholder="Ask Promethios..." rows="1"></textarea>
1074
+ <button id="send" title="Send (Enter)">&#9650;</button>
1075
+ </div>
1076
+ </div>
1077
+ <!-- Providers tab -->
1078
+ <div id="tab-providers" class="tab-panel">
1079
+ <div id="providers-panel">
1080
+ <div class="providers-heading">AI Platforms</div>
1081
+ <!-- Rows injected by JS -->
1082
+ <div id="provider-list"></div>
1083
+ <div class="providers-footer">
1084
+ Connect an AI platform to give it access to your local tools via MCP.<br>
1085
+ Click <strong>Open ↗</strong> to open the platform, then <strong>Connect MCP</strong> to authorize.
1086
+ </div>
1087
+ </div>
723
1088
  </div>
1089
+ <div id="toast"></div>
724
1090
  <script>
725
1091
  const BASE = 'http://127.0.0.1:${port}';
726
1092
  const PROXY = BASE + '/chat-proxy';
727
1093
  const MODELS_URL = BASE + '/models-proxy';
728
1094
  const SET_MODEL = BASE + '/set-model-proxy';
729
-
730
1095
  const messagesEl = document.getElementById('messages');
731
1096
  const inputEl = document.getElementById('input');
732
1097
  const sendBtn = document.getElementById('send');
@@ -734,19 +1099,15 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
734
1099
  const modelBtn = document.getElementById('model-btn');
735
1100
  const modelLabel = document.getElementById('model-label');
736
1101
  const modelDropdown = document.getElementById('model-dropdown');
737
-
738
1102
  // ── Conversation history (multi-turn context) ──────────────────────────────
739
1103
  const conversationHistory = [];
740
-
741
1104
  // ── Current model state ────────────────────────────────────────────────────
742
1105
  let currentModel = { provider: null, modelId: null, modelName: null };
743
-
744
1106
  // ── Status helpers ─────────────────────────────────────────────────────────
745
1107
  function setStatus(ok) {
746
1108
  statusDot.style.background = ok ? '#22c55e' : '#f59e0b';
747
1109
  statusDot.style.boxShadow = ok ? '0 0 6px #22c55e88' : '0 0 6px #f59e0b88';
748
1110
  }
749
-
750
1111
  function addMsg(role, text) {
751
1112
  const d = document.createElement('div');
752
1113
  d.className = 'msg ' + role;
@@ -755,20 +1116,34 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
755
1116
  messagesEl.scrollTop = messagesEl.scrollHeight;
756
1117
  return d;
757
1118
  }
758
-
1119
+ // ── Toast ──────────────────────────────────────────────────────────────────
1120
+ let _toastTimer = null;
1121
+ function showToast(msg, type = '') {
1122
+ const el = document.getElementById('toast');
1123
+ el.textContent = msg;
1124
+ el.className = 'show' + (type ? ' ' + type : '');
1125
+ clearTimeout(_toastTimer);
1126
+ _toastTimer = setTimeout(() => { el.className = ''; }, 3200);
1127
+ }
1128
+ // ── Tab switching ──────────────────────────────────────────────────────────
1129
+ document.querySelectorAll('.tab-btn').forEach(btn => {
1130
+ btn.addEventListener('click', () => {
1131
+ const tab = btn.dataset.tab;
1132
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b === btn));
1133
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + tab));
1134
+ if (tab === 'providers') loadProviders();
1135
+ });
1136
+ });
759
1137
  // ── Model switcher ─────────────────────────────────────────────────────────
760
1138
  async function loadModels() {
761
1139
  try {
762
1140
  const res = await fetch(MODELS_URL);
763
1141
  if (!res.ok) { modelLabel.textContent = 'No model'; return; }
764
1142
  const data = await res.json();
765
- // data = { current: { provider, modelId, modelName } | null, groups: [{ provider, label, models }] }
766
-
767
1143
  if (data.current) {
768
1144
  currentModel = { provider: data.current.provider, modelId: data.current.modelId, modelName: data.current.modelName };
769
1145
  modelLabel.textContent = data.current.modelName || data.current.modelId;
770
1146
  } else if (data.groups?.length) {
771
- // Pick first available model as default display
772
1147
  const first = data.groups[0];
773
1148
  const firstModel = first.models?.[0];
774
1149
  if (firstModel) {
@@ -778,8 +1153,6 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
778
1153
  } else {
779
1154
  modelLabel.textContent = 'No API key';
780
1155
  }
781
-
782
- // Build dropdown
783
1156
  modelDropdown.innerHTML = '';
784
1157
  if (!data.groups?.length) {
785
1158
  modelDropdown.innerHTML = '<div class="model-option" style="color:#52525b;cursor:default">No API keys configured</div>';
@@ -799,67 +1172,183 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
799
1172
  modelDropdown.appendChild(opt);
800
1173
  }
801
1174
  }
802
- } catch (e) {
803
- modelLabel.textContent = 'Error';
804
- }
1175
+ } catch { modelLabel.textContent = 'Error'; }
805
1176
  }
806
-
807
1177
  async function selectModel(provider, modelId, modelName) {
808
- currentModel = { provider, modelId, modelName };
809
- modelLabel.textContent = modelName;
810
- modelDropdown.classList.remove('open');
811
- // Persist to Promethios (syncs with main UI)
812
1178
  try {
813
- await fetch(SET_MODEL, {
1179
+ const res = await fetch(SET_MODEL, {
814
1180
  method: 'POST',
815
1181
  headers: { 'Content-Type': 'application/json' },
816
- body: JSON.stringify({ provider, modelId, modelName }),
1182
+ body: JSON.stringify({ provider, modelId }),
817
1183
  });
818
- } catch (_) {}
819
- // Rebuild dropdown to show new active state
820
- await loadModels();
1184
+ if (res.ok) {
1185
+ currentModel = { provider, modelId, modelName };
1186
+ modelLabel.textContent = modelName;
1187
+ modelDropdown.classList.remove('open');
1188
+ await loadModels();
1189
+ }
1190
+ } catch {}
821
1191
  }
822
-
823
1192
  modelBtn.addEventListener('click', (e) => {
824
1193
  e.stopPropagation();
825
1194
  modelDropdown.classList.toggle('open');
826
1195
  });
827
1196
  document.addEventListener('click', () => modelDropdown.classList.remove('open'));
828
- modelDropdown.addEventListener('click', e => e.stopPropagation());
829
-
830
- // ── Chat ───────────────────────────────────────────────────────────────────
1197
+ // ── Providers tab ──────────────────────────────────────────────────────────
1198
+ const PROVIDERS = [
1199
+ { id: 'manus', name: 'Manus', icon: '🤖', desc: 'AI agent platform', url: 'https://manus.im' },
1200
+ { id: 'claude', name: 'Claude', icon: '🧠', desc: 'Anthropic AI assistant', url: 'https://claude.ai' },
1201
+ { id: 'chatgpt', name: 'ChatGPT', icon: '💬', desc: 'OpenAI ChatGPT', url: 'https://chatgpt.com' },
1202
+ { id: 'gemini', name: 'Gemini', icon: '✨', desc: 'Google Gemini', url: 'https://gemini.google.com' },
1203
+ { id: 'perplexity', name: 'Perplexity', icon: '🔍', desc: 'Perplexity AI', url: 'https://perplexity.ai' },
1204
+ ];
1205
+ // Track which providers the user has opened (enables Connect MCP button)
1206
+ const openedProviders = new Set(JSON.parse(localStorage.getItem('prom_opened') || '[]'));
1207
+ // Track connected providers (from server)
1208
+ let connectedProviders = {};
1209
+ // Track in-progress connections
1210
+ const connectingProviders = new Set();
1211
+ async function loadProviders() {
1212
+ try {
1213
+ const res = await fetch(BASE + '/mcp-oauth-status');
1214
+ if (res.ok) {
1215
+ const data = await res.json();
1216
+ connectedProviders = data.tokens || {};
1217
+ }
1218
+ } catch {}
1219
+ renderProviders();
1220
+ }
1221
+ function renderProviders() {
1222
+ const list = document.getElementById('provider-list');
1223
+ list.innerHTML = '';
1224
+ for (const p of PROVIDERS) {
1225
+ const isConnected = !!connectedProviders[p.id];
1226
+ const isOpened = openedProviders.has(p.id);
1227
+ const isConnecting = connectingProviders.has(p.id);
1228
+ const row = document.createElement('div');
1229
+ row.className = 'provider-row' + (isConnected ? ' connected' : '');
1230
+ row.dataset.id = p.id;
1231
+ const connectLabel = isConnected ? '&#10003; Connected' : (isConnecting ? 'Connecting...' : 'Connect MCP');
1232
+ const connectClass = 'btn-connect' +
1233
+ (isConnected ? ' connected-state' : '') +
1234
+ (isConnecting ? ' connecting' : '');
1235
+ row.innerHTML =
1236
+ '<div class="provider-icon">' + p.icon + '</div>' +
1237
+ '<div class="provider-info">' +
1238
+ '<div class="provider-name">' + p.name + '</div>' +
1239
+ '<div class="provider-desc' + (isConnected ? ' connected-text' : '') + '">' +
1240
+ (isConnected ? 'MCP connected · tools available' : p.desc) +
1241
+ '</div>' +
1242
+ '</div>' +
1243
+ '<div class="provider-actions">' +
1244
+ '<button class="btn-open" data-url="' + p.url + '" data-id="' + p.id + '">Open &#8599;</button>' +
1245
+ '<button class="' + connectClass + '"' +
1246
+ (!isOpened && !isConnected ? ' disabled' : '') +
1247
+ (isConnecting ? ' disabled' : '') +
1248
+ ' data-id="' + p.id + '">' +
1249
+ connectLabel +
1250
+ '</button>' +
1251
+ '</div>';
1252
+ list.appendChild(row);
1253
+ }
1254
+ // Attach event listeners
1255
+ list.querySelectorAll('.btn-open').forEach(btn => {
1256
+ btn.addEventListener('click', () => {
1257
+ const id = btn.dataset.id;
1258
+ const url = btn.dataset.url;
1259
+ // Mark as opened so Connect MCP becomes enabled
1260
+ openedProviders.add(id);
1261
+ localStorage.setItem('prom_opened', JSON.stringify([...openedProviders]));
1262
+ // Open via bridge's open-external endpoint (uses system default browser)
1263
+ fetch(BASE + '/open-external', {
1264
+ method: 'POST',
1265
+ headers: { 'Content-Type': 'application/json' },
1266
+ body: JSON.stringify({ url }),
1267
+ }).catch(() => {
1268
+ // Fallback: open directly
1269
+ window.open(url, '_blank');
1270
+ });
1271
+ renderProviders();
1272
+ });
1273
+ });
1274
+ list.querySelectorAll('.btn-connect').forEach(btn => {
1275
+ if (btn.disabled) return;
1276
+ const id = btn.dataset.id;
1277
+ if (btn.classList.contains('connected-state')) {
1278
+ // Disconnect
1279
+ btn.addEventListener('click', () => disconnectProvider(id));
1280
+ } else {
1281
+ btn.addEventListener('click', () => connectProvider(id));
1282
+ }
1283
+ });
1284
+ }
1285
+ async function connectProvider(id) {
1286
+ if (connectingProviders.has(id)) return;
1287
+ connectingProviders.add(id);
1288
+ renderProviders();
1289
+ showToast('Opening browser for authorization...', '');
1290
+ try {
1291
+ const res = await fetch(BASE + '/mcp-oauth-start', {
1292
+ method: 'POST',
1293
+ headers: { 'Content-Type': 'application/json' },
1294
+ body: JSON.stringify({ clientId: id }),
1295
+ });
1296
+ const data = await res.json();
1297
+ if (data.ok) {
1298
+ connectedProviders[id] = { connected: true };
1299
+ showToast(data.providerName + ' connected!', 'success');
1300
+ } else {
1301
+ showToast('Connection failed: ' + (data.error || 'Unknown error'), 'error');
1302
+ }
1303
+ } catch (e) {
1304
+ showToast('Connection error: ' + e.message, 'error');
1305
+ }
1306
+ connectingProviders.delete(id);
1307
+ renderProviders();
1308
+ }
1309
+ async function disconnectProvider(id) {
1310
+ try {
1311
+ const res = await fetch(BASE + '/mcp-oauth-disconnect', {
1312
+ method: 'POST',
1313
+ headers: { 'Content-Type': 'application/json' },
1314
+ body: JSON.stringify({ clientId: id }),
1315
+ });
1316
+ const data = await res.json();
1317
+ if (data.ok) {
1318
+ delete connectedProviders[id];
1319
+ showToast('Disconnected', '');
1320
+ renderProviders();
1321
+ }
1322
+ } catch (e) {
1323
+ showToast('Disconnect error: ' + e.message, 'error');
1324
+ }
1325
+ }
1326
+ // ── Chat send ──────────────────────────────────────────────────────────────
831
1327
  async function sendMessage() {
832
1328
  const text = inputEl.value.trim();
833
1329
  if (!text || sendBtn.disabled) return;
834
1330
  inputEl.value = '';
835
1331
  inputEl.style.height = 'auto';
836
- addMsg('user', text);
837
1332
  sendBtn.disabled = true;
838
- setStatus(true);
839
- const thinking = addMsg('thinking', 'Promethios is thinking\u2026');
1333
+ addMsg('user', text);
1334
+ const thinking = addMsg('thinking', 'Thinking…');
1335
+ setStatus(false);
840
1336
  try {
841
1337
  const res = await fetch(PROXY, {
842
1338
  method: 'POST',
843
1339
  headers: { 'Content-Type': 'application/json' },
844
1340
  body: JSON.stringify({
845
1341
  message: text,
846
- conversationHistory: conversationHistory.slice(-20),
847
- source: 'overlay',
1342
+ history: conversationHistory,
1343
+ model: currentModel.modelId ? { provider: currentModel.provider, modelId: currentModel.modelId } : undefined,
848
1344
  }),
849
1345
  });
850
1346
  thinking.remove();
851
1347
  if (res.ok) {
852
1348
  const data = await res.json();
853
- const reply = data.reply || data.message || JSON.stringify(data);
1349
+ const reply = data.reply || data.content || data.message || JSON.stringify(data);
854
1350
  addMsg('ai', reply);
855
- // Show which model replied (subtle footer on message)
856
- if (data.model) {
857
- const meta = document.createElement('div');
858
- meta.className = 'msg system';
859
- meta.textContent = data.provider + ' / ' + data.model;
860
- messagesEl.appendChild(meta);
861
- messagesEl.scrollTop = messagesEl.scrollHeight;
862
- }
1351
+ messagesEl.scrollTop = messagesEl.scrollHeight;
863
1352
  conversationHistory.push({ role: 'user', content: text });
864
1353
  conversationHistory.push({ role: 'assistant', content: reply });
865
1354
  } else {
@@ -875,7 +1364,6 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
875
1364
  sendBtn.disabled = false;
876
1365
  setStatus(true);
877
1366
  }
878
-
879
1367
  sendBtn.addEventListener('click', sendMessage);
880
1368
  inputEl.addEventListener('keydown', e => {
881
1369
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
@@ -884,7 +1372,6 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
884
1372
  inputEl.style.height = 'auto';
885
1373
  inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
886
1374
  });
887
-
888
1375
  // Load models on startup
889
1376
  loadModels();
890
1377
  <\/script>
@@ -948,17 +1435,27 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
948
1435
  overlayAuthToken = authToken;
949
1436
  bridgeUsername = require('os').userInfo().username;
950
1437
 
951
- // Try Electron first (available when installed globally or via postinstall).
952
- // Fall back to opening the browser-based overlay at http://localhost:<port>/overlay.
1438
+ // ── Option C: Try Electron first; only fall back to browser overlay if not installed ──
1439
+ // isDesktopInstalled() scans standard install paths (postinstall dir + OS program dirs).
1440
+ // If the EXE is found, we launch it and skip the browser overlay entirely.
1441
+ // If not found, we open the browser overlay as a fallback.
953
1442
  let overlayLaunched = false;
1443
+
1444
+ const desktopInstalled = isDesktopInstalled && isDesktopInstalled();
1445
+
954
1446
  if (launchOverlay) {
955
1447
  try {
956
1448
  const overlayChild = launchOverlay({ authToken, apiBase, dev });
957
1449
  if (overlayChild) {
958
1450
  overlayLaunched = true;
959
- console.log(chalk.cyan(' ⬡ Promethios overlay launched — floating chat is ready'));
1451
+ console.log(chalk.cyan(' ⬡ Promethios Desktop launched — floating overlay is ready'));
960
1452
  console.log(chalk.gray(' Hotkey: Ctrl+Shift+P (Win/Linux) or Cmd+Shift+P (Mac)'));
961
1453
  console.log('');
1454
+ } else if (desktopInstalled) {
1455
+ // findDesktopBinary found a path but spawn returned null — EXE may already be running
1456
+ overlayLaunched = true;
1457
+ console.log(chalk.cyan(' ⬡ Promethios Desktop is running'));
1458
+ console.log('');
962
1459
  }
963
1460
  } catch (err) {
964
1461
  log('Electron overlay launch failed (non-critical):', err.message);
@@ -966,8 +1463,8 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
966
1463
  }
967
1464
 
968
1465
  if (!overlayLaunched) {
969
- // Electron not available — open the lightweight browser overlay instead.
970
- // This is a small chat UI served by the bridge's own Express server.
1466
+ // Electron Desktop not installed — open the lightweight browser overlay instead.
1467
+ // This is the full-featured chat + Providers UI served by the bridge's Express server.
971
1468
  const overlayUrl = `http://127.0.0.1:${port}/overlay`;
972
1469
  const openedInBrowser = await openInBrowser(overlayUrl, log);
973
1470
  if (openedInBrowser) {
@@ -2,13 +2,15 @@
2
2
  * launcher.js — Promethios Bridge
3
3
  *
4
4
  * Called by bridge.js after successful authentication.
5
- * Finds the pre-built Promethios Desktop binary (downloaded by postinstall.js
6
- * into ~/.promethios/desktop/) and spawns it with auth credentials via env vars.
5
+ * Finds the pre-built Promethios Desktop binary and spawns it with auth
6
+ * credentials via env vars.
7
7
  *
8
8
  * Binary search order:
9
9
  * 1. PROMETHIOS_DESKTOP_BIN environment variable (dev override)
10
10
  * 2. ~/.promethios/desktop/ — where postinstall.js places the binary
11
- * 3. Electron source-mode fallback (dev only, requires electron in node_modules)
11
+ * 3. Standard OS install paths (Windows: %LOCALAPPDATA%\Programs\Promethios\,
12
+ * macOS: /Applications/Promethios.app, Linux: /opt/Promethios/)
13
+ * 4. Electron source-mode fallback (dev only, requires electron in node_modules)
12
14
  *
13
15
  * The pre-built binary reads its config from env vars:
14
16
  * PROMETHIOS_TOKEN, PROMETHIOS_API_BASE, PROMETHIOS_THREAD_ID, PROMETHIOS_DEV
@@ -30,18 +32,17 @@ function findDesktopBinary() {
30
32
  return process.env.PROMETHIOS_DESKTOP_BIN;
31
33
  }
32
34
 
33
- // 2. Scan ~/.promethios/desktop/ for a matching binary
35
+ // 2. Scan ~/.promethios/desktop/ for a matching binary (postinstall download location)
34
36
  if (fs.existsSync(INSTALL_DIR)) {
35
37
  const files = fs.readdirSync(INSTALL_DIR);
36
38
  const exts = process.platform === 'win32' ? ['.exe']
37
- : process.platform === 'darwin' ? ['.dmg', '.app']
39
+ : process.platform === 'darwin' ? ['.app']
38
40
  : ['.AppImage'];
39
41
 
40
42
  for (const ext of exts) {
41
- const match = files.find(f => f.endsWith(ext));
43
+ const match = files.find(f => f.endsWith(ext) && !f.includes('Setup'));
42
44
  if (match) {
43
45
  const full = path.join(INSTALL_DIR, match);
44
- // Ensure executable bit is set on Unix
45
46
  try {
46
47
  if (process.platform !== 'win32') fs.chmodSync(full, 0o755);
47
48
  } catch {}
@@ -50,6 +51,67 @@ function findDesktopBinary() {
50
51
  }
51
52
  }
52
53
 
54
+ // 3. Standard OS install paths (when user installed via the installer EXE/DMG)
55
+ if (process.platform === 'win32') {
56
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
57
+ const programFiles = process.env.PROGRAMFILES || 'C:\\Program Files';
58
+ const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
59
+
60
+ const candidates = [
61
+ // Electron-builder default: %LOCALAPPDATA%\Programs\Promethios\Promethios.exe
62
+ path.join(localAppData, 'Programs', 'Promethios', 'Promethios.exe'),
63
+ path.join(localAppData, 'Programs', 'promethios', 'Promethios.exe'),
64
+ // Per-machine install
65
+ path.join(programFiles, 'Promethios', 'Promethios.exe'),
66
+ path.join(programFilesX86, 'Promethios', 'Promethios.exe'),
67
+ // Alternate casing
68
+ path.join(localAppData, 'Programs', 'Promethios Desktop', 'Promethios Desktop.exe'),
69
+ path.join(localAppData, 'Programs', 'Promethios', 'Promethios Desktop.exe'),
70
+ ];
71
+
72
+ for (const c of candidates) {
73
+ if (fs.existsSync(c)) return c;
74
+ }
75
+
76
+ // Scan %LOCALAPPDATA%\Programs\ for any Promethios*.exe
77
+ const programsDir = path.join(localAppData, 'Programs');
78
+ if (fs.existsSync(programsDir)) {
79
+ try {
80
+ const dirs = fs.readdirSync(programsDir);
81
+ for (const dir of dirs) {
82
+ if (dir.toLowerCase().includes('promethios')) {
83
+ const dirPath = path.join(programsDir, dir);
84
+ const files = fs.readdirSync(dirPath);
85
+ const exe = files.find(f => f.endsWith('.exe') && !f.includes('Uninstall') && !f.includes('Update'));
86
+ if (exe) return path.join(dirPath, exe);
87
+ }
88
+ }
89
+ } catch {}
90
+ }
91
+ } else if (process.platform === 'darwin') {
92
+ const candidates = [
93
+ '/Applications/Promethios.app/Contents/MacOS/Promethios',
94
+ '/Applications/Promethios Desktop.app/Contents/MacOS/Promethios Desktop',
95
+ path.join(os.homedir(), 'Applications', 'Promethios.app', 'Contents', 'MacOS', 'Promethios'),
96
+ ];
97
+ for (const c of candidates) {
98
+ if (fs.existsSync(c)) return c;
99
+ }
100
+ } else {
101
+ // Linux
102
+ const candidates = [
103
+ '/opt/Promethios/promethios',
104
+ '/usr/local/bin/promethios',
105
+ path.join(os.homedir(), '.local', 'share', 'promethios', 'promethios'),
106
+ ];
107
+ for (const c of candidates) {
108
+ if (fs.existsSync(c)) {
109
+ try { fs.chmodSync(c, 0o755); } catch {}
110
+ return c;
111
+ }
112
+ }
113
+ }
114
+
53
115
  return null;
54
116
  }
55
117
 
@@ -131,4 +193,9 @@ function launchOverlay({ authToken, apiBase = 'https://api.promethios.ai', threa
131
193
  return child;
132
194
  }
133
195
 
134
- module.exports = { launchOverlay };
196
+ // ── Check if Electron is installed (without launching) ───────────────────────
197
+ function isDesktopInstalled() {
198
+ return !!findDesktopBinary();
199
+ }
200
+
201
+ module.exports = { launchOverlay, isDesktopInstalled, findDesktopBinary };