assistme 0.1.10 → 0.1.12

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/dist/index.js CHANGED
@@ -446,8 +446,19 @@ var BrowserController = class {
446
446
  }
447
447
  this.currentTabId = targetTab.id;
448
448
  return new Promise((resolve2, reject) => {
449
+ let settled = false;
449
450
  this.ws = new WebSocket(targetTab.webSocketDebuggerUrl);
451
+ const connectTimeout = setTimeout(() => {
452
+ if (!settled) {
453
+ settled = true;
454
+ this.ws?.close();
455
+ reject(new Error("Connection timeout (5s)"));
456
+ }
457
+ }, 5e3);
450
458
  this.ws.on("open", () => {
459
+ if (settled) return;
460
+ settled = true;
461
+ clearTimeout(connectTimeout);
451
462
  this.connected = true;
452
463
  this.send("Page.enable").catch(() => {
453
464
  });
@@ -469,13 +480,20 @@ var BrowserController = class {
469
480
  });
470
481
  this.ws.on("error", (err) => {
471
482
  this.connected = false;
472
- reject(new Error(`WebSocket error: ${err.message}`));
483
+ if (!settled) {
484
+ settled = true;
485
+ clearTimeout(connectTimeout);
486
+ reject(new Error(`WebSocket error: ${err.message}`));
487
+ }
473
488
  });
474
489
  this.ws.on("close", () => {
475
490
  this.connected = false;
476
491
  this.ws = null;
492
+ for (const [id, cb] of this.callbacks) {
493
+ cb({ id, error: { code: -1, message: "WebSocket closed" } });
494
+ }
495
+ this.callbacks.clear();
477
496
  });
478
- setTimeout(() => reject(new Error("Connection timeout")), 5e3);
479
497
  });
480
498
  }
481
499
  async disconnect() {
@@ -577,12 +595,12 @@ URL: ${info.url}`;
577
595
  }
578
596
  async readElement(selector) {
579
597
  this.ensureConnected();
580
- const escaped = selector.replace(/'/g, "\\'");
598
+ const selectorJS = JSON.stringify(selector);
581
599
  const result = await this.send("Runtime.evaluate", {
582
600
  expression: `
583
601
  (function() {
584
- const el = document.querySelector('${escaped}');
585
- if (!el) return 'Element not found: ${escaped}';
602
+ const el = document.querySelector(${selectorJS});
603
+ if (!el) return 'Element not found: ' + ${selectorJS};
586
604
  return el.innerText || el.textContent || el.value || '(empty)';
587
605
  })()
588
606
  `,
@@ -614,12 +632,12 @@ URL: ${info.url}`;
614
632
  // ── Interactions ────────────────────────────────────────────────
615
633
  async click(selector) {
616
634
  this.ensureConnected();
617
- const escaped = selector.replace(/'/g, "\\'");
635
+ const selectorJS = JSON.stringify(selector);
618
636
  const result = await this.send("Runtime.evaluate", {
619
637
  expression: `
620
638
  (function() {
621
- const el = document.querySelector('${escaped}');
622
- if (!el) return 'Element not found: ${escaped}';
639
+ const el = document.querySelector(${selectorJS});
640
+ if (!el) return 'Element not found: ' + ${selectorJS};
623
641
 
624
642
  // Scroll into view
625
643
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
@@ -636,16 +654,16 @@ URL: ${info.url}`;
636
654
  }
637
655
  async typeText(selector, text) {
638
656
  this.ensureConnected();
639
- const escaped = selector.replace(/'/g, "\\'");
640
- const textEscaped = text.replace(/'/g, "\\'").replace(/\\/g, "\\\\");
657
+ const selectorJS = JSON.stringify(selector);
658
+ const textJS = JSON.stringify(text);
641
659
  const result = await this.send("Runtime.evaluate", {
642
660
  expression: `
643
661
  (function() {
644
- const el = document.querySelector('${escaped}');
645
- if (!el) return 'Element not found: ${escaped}';
662
+ const el = document.querySelector(${selectorJS});
663
+ if (!el) return 'Element not found: ' + ${selectorJS};
646
664
 
647
665
  el.focus();
648
- el.value = '${textEscaped}';
666
+ el.value = ${textJS};
649
667
  el.dispatchEvent(new Event('input', { bubbles: true }));
650
668
  el.dispatchEvent(new Event('change', { bubbles: true }));
651
669
  return 'Typed into: ' + (el.tagName || '') + ' [' + (el.name || el.id || '') + ']';
@@ -784,12 +802,32 @@ URL: ${info.url}`;
784
802
  (function() {
785
803
  const elements = [];
786
804
  const selectors = 'a, button, input, select, textarea, [role="button"], [onclick]';
787
- document.querySelectorAll(selectors).forEach((el, i) => {
788
- if (i > 50) return; // Cap at 50 elements
805
+ const all = document.querySelectorAll(selectors);
806
+ for (let i = 0; i < all.length && elements.length < 50; i++) {
807
+ const el = all[i];
789
808
  const rect = el.getBoundingClientRect();
790
- if (rect.width === 0 || rect.height === 0) return; // Skip hidden
809
+ if (rect.width === 0 || rect.height === 0) continue; // Skip hidden
810
+
811
+ // Build a reliable CSS selector
812
+ let selector;
813
+ if (el.id) {
814
+ selector = '#' + CSS.escape(el.id);
815
+ } else if (el.getAttribute('data-testid')) {
816
+ selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
817
+ } else {
818
+ // Build a path-based selector: find nth-of-type among siblings
819
+ const tag = el.tagName.toLowerCase();
820
+ const parent = el.parentElement;
821
+ if (parent) {
822
+ const siblings = parent.querySelectorAll(':scope > ' + tag);
823
+ const idx = Array.from(siblings).indexOf(el) + 1;
824
+ selector = tag + ':nth-of-type(' + idx + ')';
825
+ } else {
826
+ selector = tag;
827
+ }
828
+ }
791
829
 
792
- const info = {
830
+ elements.push({
793
831
  tag: el.tagName.toLowerCase(),
794
832
  text: (el.textContent || '').trim().slice(0, 80),
795
833
  type: el.getAttribute('type') || '',
@@ -797,12 +835,9 @@ URL: ${info.url}`;
797
835
  id: el.id || '',
798
836
  href: el.getAttribute('href') || '',
799
837
  placeholder: el.getAttribute('placeholder') || '',
800
- selector: el.id ? '#' + el.id
801
- : el.className ? el.tagName.toLowerCase() + '.' + el.className.split(' ')[0]
802
- : el.tagName.toLowerCase() + ':nth-of-type(' + (i+1) + ')',
803
- };
804
- elements.push(info);
805
- });
838
+ selector: selector,
839
+ });
840
+ }
806
841
  return JSON.stringify(elements, null, 2);
807
842
  })()
808
843
  `,
@@ -820,12 +855,23 @@ function findChromePath() {
820
855
  const paths = [
821
856
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
822
857
  "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
823
- "/Applications/Chromium.app/Contents/MacOS/Chromium"
858
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
859
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
860
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
824
861
  ];
825
862
  return paths.find((p) => existsSync(p)) ?? null;
826
863
  }
827
864
  if (os === "linux") {
828
- for (const name of ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium"]) {
865
+ const names = [
866
+ "google-chrome",
867
+ "google-chrome-stable",
868
+ "chromium-browser",
869
+ "chromium",
870
+ "microsoft-edge",
871
+ "microsoft-edge-stable",
872
+ "brave-browser"
873
+ ];
874
+ for (const name of names) {
829
875
  try {
830
876
  return execSync(`which ${name}`, {
831
877
  encoding: "utf-8",
@@ -842,31 +888,53 @@ function findChromePath() {
842
888
  process.env["PROGRAMFILES(X86)"],
843
889
  process.env.LOCALAPPDATA
844
890
  ].filter(Boolean);
891
+ const subPaths = [
892
+ "Google\\Chrome\\Application\\chrome.exe",
893
+ "Microsoft\\Edge\\Application\\msedge.exe",
894
+ "BraveSoftware\\Brave-Browser\\Application\\brave.exe"
895
+ ];
845
896
  for (const prefix of prefixes) {
846
- const p = `${prefix}\\Google\\Chrome\\Application\\chrome.exe`;
847
- if (existsSync(p)) return p;
897
+ for (const sub of subPaths) {
898
+ const p = `${prefix}\\${sub}`;
899
+ if (existsSync(p)) return p;
900
+ }
848
901
  }
849
902
  return null;
850
903
  }
851
904
  return null;
852
905
  }
853
- function isChromeRunning() {
906
+ function isChromeRunning(chromePath) {
854
907
  try {
855
908
  if (platform() === "win32") {
856
- const out2 = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', {
857
- encoding: "utf-8",
858
- stdio: ["pipe", "pipe", "pipe"]
859
- });
860
- return out2.includes("chrome.exe");
909
+ const out2 = execSync(
910
+ 'tasklist /FI "IMAGENAME eq chrome.exe" /FI "IMAGENAME eq msedge.exe" /FI "IMAGENAME eq brave.exe" /NH',
911
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
912
+ );
913
+ return /chrome\.exe|msedge\.exe|brave\.exe/i.test(out2);
861
914
  }
862
915
  if (platform() === "darwin") {
863
- const out2 = execSync('pgrep -f "Google Chrome.app/Contents/MacOS/Google Chrome"', {
916
+ if (chromePath) {
917
+ const appDir = chromePath.replace(/\/Contents\/MacOS\/.*$/, "");
918
+ const out3 = execSync(`pgrep -f ${JSON.stringify(appDir)}`, {
919
+ encoding: "utf-8",
920
+ stdio: ["pipe", "pipe", "pipe"]
921
+ });
922
+ return out3.trim().length > 0;
923
+ }
924
+ const out2 = execSync(
925
+ 'pgrep -f "(Google Chrome|Microsoft Edge|Brave Browser|Chromium).app/Contents/MacOS/"',
926
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
927
+ );
928
+ return out2.trim().length > 0;
929
+ }
930
+ if (chromePath) {
931
+ const out2 = execSync(`pgrep -f ${JSON.stringify(chromePath)} 2>/dev/null || true`, {
864
932
  encoding: "utf-8",
865
933
  stdio: ["pipe", "pipe", "pipe"]
866
934
  });
867
935
  return out2.trim().length > 0;
868
936
  }
869
- const out = execSync("pgrep -f '(chrome|chromium)' 2>/dev/null || true", {
937
+ const out = execSync("pgrep -f '(chrome|chromium|msedge|brave)' 2>/dev/null || true", {
870
938
  encoding: "utf-8",
871
939
  stdio: ["pipe", "pipe", "pipe"]
872
940
  });
@@ -875,21 +943,30 @@ function isChromeRunning() {
875
943
  return false;
876
944
  }
877
945
  }
878
- async function killChromeGracefully() {
946
+ function macAppName(chromePath) {
947
+ if (chromePath.includes("Brave Browser")) return "Brave Browser";
948
+ if (chromePath.includes("Microsoft Edge")) return "Microsoft Edge";
949
+ if (chromePath.includes("Chromium")) return "Chromium";
950
+ if (chromePath.includes("Canary")) return "Google Chrome Canary";
951
+ return "Google Chrome";
952
+ }
953
+ async function killChromeGracefully(chromePath) {
879
954
  const os = platform();
880
955
  try {
881
956
  if (os === "darwin") {
882
- execSync(`osascript -e 'quit app "Google Chrome"'`, {
957
+ const app = macAppName(chromePath);
958
+ execSync(`osascript -e 'quit app "${app}"'`, {
883
959
  timeout: 5e3,
884
960
  stdio: ["pipe", "pipe", "pipe"]
885
961
  });
886
962
  } else if (os === "linux") {
887
- execSync("pkill -TERM -f '(chrome|chromium)'", {
963
+ execSync(`pkill -TERM -f ${JSON.stringify(chromePath)}`, {
888
964
  timeout: 5e3,
889
965
  stdio: ["pipe", "pipe", "pipe"]
890
966
  });
891
967
  } else if (os === "win32") {
892
- execSync("taskkill /IM chrome.exe", {
968
+ const exe = chromePath.split("\\").pop() || "chrome.exe";
969
+ execSync(`taskkill /IM "${exe}"`, {
893
970
  timeout: 5e3,
894
971
  stdio: ["pipe", "pipe", "pipe"]
895
972
  });
@@ -898,20 +975,21 @@ async function killChromeGracefully() {
898
975
  }
899
976
  const start = Date.now();
900
977
  while (Date.now() - start < 8e3) {
901
- if (!isChromeRunning()) {
902
- log.debug(`Chrome exited after ${Date.now() - start}ms`);
978
+ if (!isChromeRunning(chromePath)) {
979
+ log.debug(`Browser exited after ${Date.now() - start}ms`);
903
980
  return;
904
981
  }
905
982
  await new Promise((r) => setTimeout(r, 500));
906
983
  }
907
- log.debug("Chrome still running after graceful quit, force-killing...");
984
+ log.debug("Browser still running after graceful quit, force-killing...");
908
985
  try {
909
986
  if (os === "win32") {
910
- execSync("taskkill /F /IM chrome.exe", {
987
+ const exe = chromePath.split("\\").pop() || "chrome.exe";
988
+ execSync(`taskkill /F /IM "${exe}"`, {
911
989
  stdio: ["pipe", "pipe", "pipe"]
912
990
  });
913
991
  } else {
914
- execSync("pkill -9 -f '(chrome|chromium)'", {
992
+ execSync(`pkill -9 -f ${JSON.stringify(chromePath)}`, {
915
993
  stdio: ["pipe", "pipe", "pipe"]
916
994
  });
917
995
  }
@@ -921,25 +999,27 @@ async function killChromeGracefully() {
921
999
  if (os !== "win32") {
922
1000
  const home = process.env.HOME;
923
1001
  if (home) {
924
- const lockPaths = os === "darwin" ? [
925
- `${home}/Library/Application Support/Google/Chrome/SingletonLock`,
926
- `${home}/Library/Application Support/Google/Chrome/SingletonSocket`,
927
- `${home}/Library/Application Support/Google/Chrome/SingletonCookie`
1002
+ const lockSuffixes = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
1003
+ const profileDirs = os === "darwin" ? [
1004
+ `${home}/Library/Application Support/Google/Chrome`,
1005
+ `${home}/Library/Application Support/Microsoft Edge`,
1006
+ `${home}/Library/Application Support/BraveSoftware/Brave-Browser`
928
1007
  ] : [
929
- `${home}/.config/google-chrome/SingletonLock`,
930
- `${home}/.config/google-chrome/SingletonSocket`,
931
- `${home}/.config/google-chrome/SingletonCookie`,
932
- `${home}/.config/chromium/SingletonLock`,
933
- `${home}/.config/chromium/SingletonSocket`,
934
- `${home}/.config/chromium/SingletonCookie`
1008
+ `${home}/.config/google-chrome`,
1009
+ `${home}/.config/chromium`,
1010
+ `${home}/.config/microsoft-edge`,
1011
+ `${home}/.config/BraveSoftware/Brave-Browser`
935
1012
  ];
936
- for (const lockPath of lockPaths) {
937
- try {
938
- if (existsSync(lockPath)) {
939
- unlinkSync(lockPath);
940
- log.debug(`Removed stale lock: ${lockPath}`);
1013
+ for (const dir of profileDirs) {
1014
+ for (const suffix of lockSuffixes) {
1015
+ const lockPath = `${dir}/${suffix}`;
1016
+ try {
1017
+ if (existsSync(lockPath)) {
1018
+ unlinkSync(lockPath);
1019
+ log.debug(`Removed stale lock: ${lockPath}`);
1020
+ }
1021
+ } catch {
941
1022
  }
942
- } catch {
943
1023
  }
944
1024
  }
945
1025
  }
@@ -947,11 +1027,24 @@ async function killChromeGracefully() {
947
1027
  }
948
1028
  function spawnChrome(chromePath, port) {
949
1029
  const cdpFlag = `--remote-debugging-port=${port}`;
950
- log.debug(`Spawning Chrome: ${chromePath} ${cdpFlag} --restore-last-session`);
951
- const child = spawn(chromePath, [cdpFlag, "--restore-last-session"], {
952
- detached: true,
953
- stdio: ["ignore", "pipe", "pipe"]
954
- });
1030
+ const os = platform();
1031
+ let child;
1032
+ if (os === "darwin") {
1033
+ const appName = macAppName(chromePath);
1034
+ log.debug(
1035
+ `Spawning browser via: open -a "${appName}" --args ${cdpFlag} --restore-last-session`
1036
+ );
1037
+ child = spawn("open", ["-a", appName, "--args", cdpFlag, "--restore-last-session"], {
1038
+ detached: true,
1039
+ stdio: ["ignore", "pipe", "pipe"]
1040
+ });
1041
+ } else {
1042
+ log.debug(`Spawning Chrome: ${chromePath} ${cdpFlag} --restore-last-session`);
1043
+ child = spawn(chromePath, [cdpFlag, "--restore-last-session"], {
1044
+ detached: true,
1045
+ stdio: ["ignore", "pipe", "pipe"]
1046
+ });
1047
+ }
955
1048
  let stderr = "";
956
1049
  child.stderr?.on("data", (chunk) => {
957
1050
  stderr += chunk.toString();
@@ -1018,11 +1111,20 @@ async function ensureBrowserAvailable(port = 9222) {
1018
1111
  return { success: false, action: "chrome_not_found" };
1019
1112
  }
1020
1113
  log.debug(`Found Chrome at: ${chromePath}`);
1021
- const running = isChromeRunning();
1022
- log.debug(`Chrome currently running: ${running}`);
1114
+ const running = isChromeRunning(chromePath);
1115
+ log.debug(`Browser currently running: ${running}`);
1023
1116
  if (running) {
1024
- log.debug("Killing Chrome gracefully for restart with CDP...");
1025
- await killChromeGracefully();
1117
+ log.debug("Killing browser gracefully for restart with CDP...");
1118
+ await killChromeGracefully(chromePath);
1119
+ if (isChromeRunning(chromePath)) {
1120
+ log.debug("Browser still running after kill attempt \u2014 cannot restart with CDP");
1121
+ return {
1122
+ success: false,
1123
+ action: "launch_failed",
1124
+ chromePath,
1125
+ detail: "Could not stop the existing browser process. Please quit the browser manually and run assistme again."
1126
+ };
1127
+ }
1026
1128
  await new Promise((r) => setTimeout(r, 2e3));
1027
1129
  const child2 = spawnChrome(chromePath, port);
1028
1130
  if (await waitForCDP(browser)) {
@@ -1030,25 +1132,25 @@ async function ensureBrowserAvailable(port = 9222) {
1030
1132
  }
1031
1133
  if (child2.exitCode !== null) {
1032
1134
  log.debug(
1033
- `Chrome process already exited (code ${child2.exitCode}) \u2014 may have crashed or profile is locked`
1135
+ `Browser process already exited (code ${child2.exitCode}) \u2014 may have crashed or profile is locked`
1034
1136
  );
1035
1137
  return {
1036
1138
  success: false,
1037
1139
  action: "launch_failed",
1038
1140
  chromePath,
1039
- detail: `Chrome exited immediately (code ${child2.exitCode}). This often means the profile is locked by another Chrome instance. Try closing all Chrome windows first, then run assistme again.`
1141
+ detail: `Browser exited immediately (code ${child2.exitCode}). The profile may be locked. Try closing all browser windows first, then run assistme again.`
1040
1142
  };
1041
1143
  }
1042
1144
  log.debug("First CDP wait timed out after restart, retrying...");
1043
1145
  if (await waitForCDP(browser, 15e3)) {
1044
1146
  return { success: true, action: "restarted", chromePath };
1045
1147
  }
1046
- const stillRunning2 = isChromeRunning();
1148
+ const stillRunning2 = isChromeRunning(chromePath);
1047
1149
  return {
1048
1150
  success: false,
1049
1151
  action: "launch_failed",
1050
1152
  chromePath,
1051
- detail: stillRunning2 ? "Chrome is running but CDP port is not responding. Chrome may have started without the --remote-debugging-port flag. Try: 1) Quit Chrome completely, 2) Run assistme again." : "Chrome was restarted but exited unexpectedly. Check if Chrome can start normally from the command line."
1153
+ detail: stillRunning2 ? "Browser is running but CDP port is not responding. Try: 1) Quit the browser completely, 2) Run assistme again." : "Browser was restarted but exited unexpectedly. Try launching it manually to check for errors."
1052
1154
  };
1053
1155
  }
1054
1156
  const child = spawnChrome(chromePath, port);
@@ -1056,19 +1158,19 @@ async function ensureBrowserAvailable(port = 9222) {
1056
1158
  return { success: true, action: "launched", chromePath };
1057
1159
  }
1058
1160
  if (child.exitCode !== null) {
1059
- log.debug(`Chrome process already exited (code ${child.exitCode})`);
1161
+ log.debug(`Browser process already exited (code ${child.exitCode})`);
1060
1162
  return {
1061
1163
  success: false,
1062
1164
  action: "launch_failed",
1063
1165
  chromePath,
1064
- detail: `Chrome exited immediately (code ${child.exitCode}). Try launching Chrome manually to see any error dialogs.`
1166
+ detail: `Browser exited immediately (code ${child.exitCode}). Try launching it manually to see any error dialogs.`
1065
1167
  };
1066
1168
  }
1067
1169
  log.debug("First CDP wait timed out after launch, retrying...");
1068
1170
  if (await waitForCDP(browser, 15e3)) {
1069
1171
  return { success: true, action: "launched", chromePath };
1070
1172
  }
1071
- const stillRunning = isChromeRunning();
1173
+ const stillRunning = isChromeRunning(chromePath);
1072
1174
  return {
1073
1175
  success: false,
1074
1176
  action: "launch_failed",
@@ -2260,6 +2362,18 @@ ${stderr}` : "";
2260
2362
  }
2261
2363
 
2262
2364
  // src/tools/index.ts
2365
+ async function ensureConnected(browser, tabIndex) {
2366
+ if (browser.isConnected() && tabIndex === void 0) return;
2367
+ if (!await browser.isAvailable()) {
2368
+ const result = await ensureBrowserAvailable();
2369
+ if (!result.success) {
2370
+ throw new Error(
2371
+ `Chrome is not available (${result.action}). ${result.detail || "Please ensure Google Chrome is installed."}`
2372
+ );
2373
+ }
2374
+ }
2375
+ await browser.connect(tabIndex);
2376
+ }
2263
2377
  async function executeTool(name, input) {
2264
2378
  const browser = getBrowser();
2265
2379
  switch (name) {
@@ -2286,34 +2400,35 @@ async function executeTool(name, input) {
2286
2400
  return executeShell(input.command, input.cwd);
2287
2401
  // ── Browser (CDP) ───────────────────────────────────────
2288
2402
  case "browser_connect": {
2289
- if (!await browser.isAvailable()) {
2290
- const result = await ensureBrowserAvailable();
2291
- if (!result.success) {
2292
- throw new Error(
2293
- `Failed to auto-launch Chrome (${result.action}). Please ensure Google Chrome is installed.`
2294
- );
2295
- }
2296
- }
2297
- return browser.connect(input.tab_index);
2403
+ await ensureConnected(browser, input.tab_index);
2404
+ return browser.isConnected() ? "Connected to browser." : "Failed to connect.";
2298
2405
  }
2299
2406
  case "browser_navigate":
2300
- if (!browser.isConnected()) await browser.connect();
2407
+ await ensureConnected(browser);
2301
2408
  return browser.navigate(input.url);
2302
2409
  case "browser_read_page":
2410
+ await ensureConnected(browser);
2303
2411
  return browser.readPage();
2304
2412
  case "browser_screenshot":
2413
+ await ensureConnected(browser);
2305
2414
  return browser.screenshot();
2306
2415
  case "browser_click":
2416
+ await ensureConnected(browser);
2307
2417
  return browser.click(input.selector);
2308
2418
  case "browser_type":
2419
+ await ensureConnected(browser);
2309
2420
  return browser.typeText(input.selector, input.text);
2310
2421
  case "browser_press_key":
2422
+ await ensureConnected(browser);
2311
2423
  return browser.pressKey(input.key);
2312
2424
  case "browser_scroll":
2425
+ await ensureConnected(browser);
2313
2426
  return input.direction === "up" ? browser.scrollUp() : browser.scrollDown();
2314
2427
  case "browser_get_elements":
2428
+ await ensureConnected(browser);
2315
2429
  return browser.getInteractiveElements();
2316
2430
  case "browser_evaluate":
2431
+ await ensureConnected(browser);
2317
2432
  return browser.evaluate(input.expression);
2318
2433
  case "browser_list_tabs":
2319
2434
  return browser.listTabs();
@@ -2332,7 +2447,23 @@ async function executeTool(name, input) {
2332
2447
  console.log(` (Waiting up to ${waitSeconds}s for you to complete this)`);
2333
2448
  console.log("\u2501".repeat(60));
2334
2449
  console.log("\n");
2335
- await new Promise((r) => setTimeout(r, waitSeconds * 1e3));
2450
+ let initialUrl = "";
2451
+ try {
2452
+ const info = await browser.getPageInfo();
2453
+ initialUrl = info.url;
2454
+ } catch {
2455
+ }
2456
+ const deadline = Date.now() + waitSeconds * 1e3;
2457
+ while (Date.now() < deadline) {
2458
+ await new Promise((r) => setTimeout(r, 3e3));
2459
+ try {
2460
+ const info = await browser.getPageInfo();
2461
+ if (initialUrl && info.url !== initialUrl) {
2462
+ break;
2463
+ }
2464
+ } catch {
2465
+ }
2466
+ }
2336
2467
  try {
2337
2468
  const pageInfo = await browser.readPage();
2338
2469
  return `User action wait completed. Current page state:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assistme",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "AssistMe CLI Agent - AI-powered assistant that controls your real browser",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -110,9 +110,21 @@ export class BrowserController {
110
110
  this.currentTabId = targetTab.id;
111
111
 
112
112
  return new Promise((resolve, reject) => {
113
+ let settled = false;
113
114
  this.ws = new WebSocket(targetTab.webSocketDebuggerUrl!);
114
115
 
116
+ const connectTimeout = setTimeout(() => {
117
+ if (!settled) {
118
+ settled = true;
119
+ this.ws?.close();
120
+ reject(new Error("Connection timeout (5s)"));
121
+ }
122
+ }, 5000);
123
+
115
124
  this.ws.on("open", () => {
125
+ if (settled) return;
126
+ settled = true;
127
+ clearTimeout(connectTimeout);
116
128
  this.connected = true;
117
129
  // Enable required domains
118
130
  this.send("Page.enable").catch(() => {});
@@ -135,15 +147,22 @@ export class BrowserController {
135
147
 
136
148
  this.ws.on("error", (err) => {
137
149
  this.connected = false;
138
- reject(new Error(`WebSocket error: ${err.message}`));
150
+ if (!settled) {
151
+ settled = true;
152
+ clearTimeout(connectTimeout);
153
+ reject(new Error(`WebSocket error: ${err.message}`));
154
+ }
139
155
  });
140
156
 
141
157
  this.ws.on("close", () => {
142
158
  this.connected = false;
143
159
  this.ws = null;
160
+ // Reject pending CDP commands so they don't hang forever
161
+ for (const [id, cb] of this.callbacks) {
162
+ cb({ id, error: { code: -1, message: "WebSocket closed" } });
163
+ }
164
+ this.callbacks.clear();
144
165
  });
145
-
146
- setTimeout(() => reject(new Error("Connection timeout")), 5000);
147
166
  });
148
167
  }
149
168
 
@@ -261,12 +280,12 @@ export class BrowserController {
261
280
 
262
281
  async readElement(selector: string): Promise<string> {
263
282
  this.ensureConnected();
264
- const escaped = selector.replace(/'/g, "\\'");
283
+ const selectorJS = JSON.stringify(selector);
265
284
  const result = await this.send("Runtime.evaluate", {
266
285
  expression: `
267
286
  (function() {
268
- const el = document.querySelector('${escaped}');
269
- if (!el) return 'Element not found: ${escaped}';
287
+ const el = document.querySelector(${selectorJS});
288
+ if (!el) return 'Element not found: ' + ${selectorJS};
270
289
  return el.innerText || el.textContent || el.value || '(empty)';
271
290
  })()
272
291
  `,
@@ -305,13 +324,13 @@ export class BrowserController {
305
324
 
306
325
  async click(selector: string): Promise<string> {
307
326
  this.ensureConnected();
308
- const escaped = selector.replace(/'/g, "\\'");
327
+ const selectorJS = JSON.stringify(selector);
309
328
 
310
329
  const result = await this.send("Runtime.evaluate", {
311
330
  expression: `
312
331
  (function() {
313
- const el = document.querySelector('${escaped}');
314
- if (!el) return 'Element not found: ${escaped}';
332
+ const el = document.querySelector(${selectorJS});
333
+ if (!el) return 'Element not found: ' + ${selectorJS};
315
334
 
316
335
  // Scroll into view
317
336
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
@@ -331,17 +350,19 @@ export class BrowserController {
331
350
 
332
351
  async typeText(selector: string, text: string): Promise<string> {
333
352
  this.ensureConnected();
334
- const escaped = selector.replace(/'/g, "\\'");
335
- const textEscaped = text.replace(/'/g, "\\'").replace(/\\/g, "\\\\");
353
+ // Use JSON.stringify for safe string interpolation into JS — handles all
354
+ // special characters (quotes, backslashes, newlines, unicode) correctly.
355
+ const selectorJS = JSON.stringify(selector);
356
+ const textJS = JSON.stringify(text);
336
357
 
337
358
  const result = await this.send("Runtime.evaluate", {
338
359
  expression: `
339
360
  (function() {
340
- const el = document.querySelector('${escaped}');
341
- if (!el) return 'Element not found: ${escaped}';
361
+ const el = document.querySelector(${selectorJS});
362
+ if (!el) return 'Element not found: ' + ${selectorJS};
342
363
 
343
364
  el.focus();
344
- el.value = '${textEscaped}';
365
+ el.value = ${textJS};
345
366
  el.dispatchEvent(new Event('input', { bubbles: true }));
346
367
  el.dispatchEvent(new Event('change', { bubbles: true }));
347
368
  return 'Typed into: ' + (el.tagName || '') + ' [' + (el.name || el.id || '') + ']';
@@ -513,12 +534,32 @@ export class BrowserController {
513
534
  (function() {
514
535
  const elements = [];
515
536
  const selectors = 'a, button, input, select, textarea, [role="button"], [onclick]';
516
- document.querySelectorAll(selectors).forEach((el, i) => {
517
- if (i > 50) return; // Cap at 50 elements
537
+ const all = document.querySelectorAll(selectors);
538
+ for (let i = 0; i < all.length && elements.length < 50; i++) {
539
+ const el = all[i];
518
540
  const rect = el.getBoundingClientRect();
519
- if (rect.width === 0 || rect.height === 0) return; // Skip hidden
520
-
521
- const info = {
541
+ if (rect.width === 0 || rect.height === 0) continue; // Skip hidden
542
+
543
+ // Build a reliable CSS selector
544
+ let selector;
545
+ if (el.id) {
546
+ selector = '#' + CSS.escape(el.id);
547
+ } else if (el.getAttribute('data-testid')) {
548
+ selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
549
+ } else {
550
+ // Build a path-based selector: find nth-of-type among siblings
551
+ const tag = el.tagName.toLowerCase();
552
+ const parent = el.parentElement;
553
+ if (parent) {
554
+ const siblings = parent.querySelectorAll(':scope > ' + tag);
555
+ const idx = Array.from(siblings).indexOf(el) + 1;
556
+ selector = tag + ':nth-of-type(' + idx + ')';
557
+ } else {
558
+ selector = tag;
559
+ }
560
+ }
561
+
562
+ elements.push({
522
563
  tag: el.tagName.toLowerCase(),
523
564
  text: (el.textContent || '').trim().slice(0, 80),
524
565
  type: el.getAttribute('type') || '',
@@ -526,12 +567,9 @@ export class BrowserController {
526
567
  id: el.id || '',
527
568
  href: el.getAttribute('href') || '',
528
569
  placeholder: el.getAttribute('placeholder') || '',
529
- selector: el.id ? '#' + el.id
530
- : el.className ? el.tagName.toLowerCase() + '.' + el.className.split(' ')[0]
531
- : el.tagName.toLowerCase() + ':nth-of-type(' + (i+1) + ')',
532
- };
533
- elements.push(info);
534
- });
570
+ selector: selector,
571
+ });
572
+ }
535
573
  return JSON.stringify(elements, null, 2);
536
574
  })()
537
575
  `,
@@ -559,12 +597,23 @@ export function findChromePath(): string | null {
559
597
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
560
598
  "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
561
599
  "/Applications/Chromium.app/Contents/MacOS/Chromium",
600
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
601
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
562
602
  ];
563
603
  return paths.find((p) => existsSync(p)) ?? null;
564
604
  }
565
605
 
566
606
  if (os === "linux") {
567
- for (const name of ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium"]) {
607
+ const names = [
608
+ "google-chrome",
609
+ "google-chrome-stable",
610
+ "chromium-browser",
611
+ "chromium",
612
+ "microsoft-edge",
613
+ "microsoft-edge-stable",
614
+ "brave-browser",
615
+ ];
616
+ for (const name of names) {
568
617
  try {
569
618
  return execSync(`which ${name}`, {
570
619
  encoding: "utf-8",
@@ -584,9 +633,17 @@ export function findChromePath(): string | null {
584
633
  process.env.LOCALAPPDATA,
585
634
  ].filter(Boolean) as string[];
586
635
 
636
+ const subPaths = [
637
+ "Google\\Chrome\\Application\\chrome.exe",
638
+ "Microsoft\\Edge\\Application\\msedge.exe",
639
+ "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
640
+ ];
641
+
587
642
  for (const prefix of prefixes) {
588
- const p = `${prefix}\\Google\\Chrome\\Application\\chrome.exe`;
589
- if (existsSync(p)) return p;
643
+ for (const sub of subPaths) {
644
+ const p = `${prefix}\\${sub}`;
645
+ if (existsSync(p)) return p;
646
+ }
590
647
  }
591
648
  return null;
592
649
  }
@@ -595,28 +652,45 @@ export function findChromePath(): string | null {
595
652
  }
596
653
 
597
654
  /**
598
- * Check if any Chrome process is currently running.
655
+ * Check if a Chromium-based browser is currently running.
656
+ * Optionally pass the specific browser binary path for precise matching.
599
657
  */
600
- export function isChromeRunning(): boolean {
658
+ export function isChromeRunning(chromePath?: string): boolean {
601
659
  try {
602
660
  if (platform() === "win32") {
603
- const out = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', {
604
- encoding: "utf-8",
605
- stdio: ["pipe", "pipe", "pipe"],
606
- });
607
- return out.includes("chrome.exe");
661
+ // Check for any Chromium-based browser process
662
+ const out = execSync(
663
+ 'tasklist /FI "IMAGENAME eq chrome.exe" /FI "IMAGENAME eq msedge.exe" /FI "IMAGENAME eq brave.exe" /NH',
664
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
665
+ );
666
+ return /chrome\.exe|msedge\.exe|brave\.exe/i.test(out);
608
667
  }
609
668
  if (platform() === "darwin") {
610
- // Match the main Chrome process (not helper/renderer sub-processes).
611
- // No trailing $ — the process command line includes flags after the binary.
612
- const out = execSync('pgrep -f "Google Chrome.app/Contents/MacOS/Google Chrome"', {
669
+ // If we know the exact binary, match it precisely
670
+ if (chromePath) {
671
+ const appDir = chromePath.replace(/\/Contents\/MacOS\/.*$/, "");
672
+ const out = execSync(`pgrep -f ${JSON.stringify(appDir)}`, {
673
+ encoding: "utf-8",
674
+ stdio: ["pipe", "pipe", "pipe"],
675
+ });
676
+ return out.trim().length > 0;
677
+ }
678
+ // Otherwise check all known Chromium browsers
679
+ const out = execSync(
680
+ 'pgrep -f "(Google Chrome|Microsoft Edge|Brave Browser|Chromium).app/Contents/MacOS/"',
681
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
682
+ );
683
+ return out.trim().length > 0;
684
+ }
685
+ // Linux
686
+ if (chromePath) {
687
+ const out = execSync(`pgrep -f ${JSON.stringify(chromePath)} 2>/dev/null || true`, {
613
688
  encoding: "utf-8",
614
689
  stdio: ["pipe", "pipe", "pipe"],
615
690
  });
616
691
  return out.trim().length > 0;
617
692
  }
618
- // Linux match common chrome binary names
619
- const out = execSync("pgrep -f '(chrome|chromium)' 2>/dev/null || true", {
693
+ const out = execSync("pgrep -f '(chrome|chromium|msedge|brave)' 2>/dev/null || true", {
620
694
  encoding: "utf-8",
621
695
  stdio: ["pipe", "pipe", "pipe"],
622
696
  });
@@ -627,24 +701,37 @@ export function isChromeRunning(): boolean {
627
701
  }
628
702
 
629
703
  /**
630
- * Gracefully quit Chrome, then force-kill if it doesn't exit in time.
704
+ * Derive the macOS app name from a binary path inside a .app bundle.
631
705
  */
632
- async function killChromeGracefully(): Promise<void> {
706
+ function macAppName(chromePath: string): string {
707
+ if (chromePath.includes("Brave Browser")) return "Brave Browser";
708
+ if (chromePath.includes("Microsoft Edge")) return "Microsoft Edge";
709
+ if (chromePath.includes("Chromium")) return "Chromium";
710
+ if (chromePath.includes("Canary")) return "Google Chrome Canary";
711
+ return "Google Chrome";
712
+ }
713
+
714
+ /**
715
+ * Gracefully quit the browser, then force-kill if it doesn't exit in time.
716
+ */
717
+ async function killChromeGracefully(chromePath: string): Promise<void> {
633
718
  const os = platform();
634
719
  try {
635
720
  if (os === "darwin") {
636
- // osascript sends a clean "quit" — Chrome saves session state
637
- execSync("osascript -e 'quit app \"Google Chrome\"'", {
721
+ const app = macAppName(chromePath);
722
+ execSync(`osascript -e 'quit app "${app}"'`, {
638
723
  timeout: 5000,
639
724
  stdio: ["pipe", "pipe", "pipe"],
640
725
  });
641
726
  } else if (os === "linux") {
642
- execSync("pkill -TERM -f '(chrome|chromium)'", {
727
+ // Kill the specific browser binary, not all Chromium variants
728
+ execSync(`pkill -TERM -f ${JSON.stringify(chromePath)}`, {
643
729
  timeout: 5000,
644
730
  stdio: ["pipe", "pipe", "pipe"],
645
731
  });
646
732
  } else if (os === "win32") {
647
- execSync("taskkill /IM chrome.exe", {
733
+ const exe = chromePath.split("\\").pop() || "chrome.exe";
734
+ execSync(`taskkill /IM "${exe}"`, {
648
735
  timeout: 5000,
649
736
  stdio: ["pipe", "pipe", "pipe"],
650
737
  });
@@ -653,26 +740,27 @@ async function killChromeGracefully(): Promise<void> {
653
740
  /* may already be closed */
654
741
  }
655
742
 
656
- // Wait for Chrome to fully exit (up to 8s)
743
+ // Wait for browser to fully exit (up to 8s)
657
744
  const start = Date.now();
658
745
  while (Date.now() - start < 8000) {
659
- if (!isChromeRunning()) {
660
- log.debug(`Chrome exited after ${Date.now() - start}ms`);
746
+ if (!isChromeRunning(chromePath)) {
747
+ log.debug(`Browser exited after ${Date.now() - start}ms`);
661
748
  return;
662
749
  }
663
750
  await new Promise((r) => setTimeout(r, 500));
664
751
  }
665
752
 
666
- log.debug("Chrome still running after graceful quit, force-killing...");
753
+ log.debug("Browser still running after graceful quit, force-killing...");
667
754
 
668
755
  // Force kill if still alive
669
756
  try {
670
757
  if (os === "win32") {
671
- execSync("taskkill /F /IM chrome.exe", {
758
+ const exe = chromePath.split("\\").pop() || "chrome.exe";
759
+ execSync(`taskkill /F /IM "${exe}"`, {
672
760
  stdio: ["pipe", "pipe", "pipe"],
673
761
  });
674
762
  } else {
675
- execSync("pkill -9 -f '(chrome|chromium)'", {
763
+ execSync(`pkill -9 -f ${JSON.stringify(chromePath)}`, {
676
764
  stdio: ["pipe", "pipe", "pipe"],
677
765
  });
678
766
  }
@@ -683,34 +771,35 @@ async function killChromeGracefully(): Promise<void> {
683
771
  // Wait for processes to fully terminate after SIGKILL
684
772
  await new Promise((r) => setTimeout(r, 1000));
685
773
 
686
- // On macOS/Linux, remove the Chrome profile SingletonLock that may linger
687
- // after a force-kill, preventing the next Chrome instance from starting.
774
+ // Remove SingletonLock files that may linger after a force-kill
688
775
  if (os !== "win32") {
689
776
  const home = process.env.HOME;
690
777
  if (home) {
691
- const lockPaths =
778
+ const lockSuffixes = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
779
+ const profileDirs =
692
780
  os === "darwin"
693
781
  ? [
694
- `${home}/Library/Application Support/Google/Chrome/SingletonLock`,
695
- `${home}/Library/Application Support/Google/Chrome/SingletonSocket`,
696
- `${home}/Library/Application Support/Google/Chrome/SingletonCookie`,
782
+ `${home}/Library/Application Support/Google/Chrome`,
783
+ `${home}/Library/Application Support/Microsoft Edge`,
784
+ `${home}/Library/Application Support/BraveSoftware/Brave-Browser`,
697
785
  ]
698
786
  : [
699
- `${home}/.config/google-chrome/SingletonLock`,
700
- `${home}/.config/google-chrome/SingletonSocket`,
701
- `${home}/.config/google-chrome/SingletonCookie`,
702
- `${home}/.config/chromium/SingletonLock`,
703
- `${home}/.config/chromium/SingletonSocket`,
704
- `${home}/.config/chromium/SingletonCookie`,
787
+ `${home}/.config/google-chrome`,
788
+ `${home}/.config/chromium`,
789
+ `${home}/.config/microsoft-edge`,
790
+ `${home}/.config/BraveSoftware/Brave-Browser`,
705
791
  ];
706
- for (const lockPath of lockPaths) {
707
- try {
708
- if (existsSync(lockPath)) {
709
- unlinkSync(lockPath);
710
- log.debug(`Removed stale lock: ${lockPath}`);
792
+ for (const dir of profileDirs) {
793
+ for (const suffix of lockSuffixes) {
794
+ const lockPath = `${dir}/${suffix}`;
795
+ try {
796
+ if (existsSync(lockPath)) {
797
+ unlinkSync(lockPath);
798
+ log.debug(`Removed stale lock: ${lockPath}`);
799
+ }
800
+ } catch {
801
+ /* best effort */
711
802
  }
712
- } catch {
713
- /* best effort */
714
803
  }
715
804
  }
716
805
  }
@@ -720,18 +809,37 @@ async function killChromeGracefully(): Promise<void> {
720
809
  /**
721
810
  * Spawn Chrome with the remote-debugging-port flag.
722
811
  * Returns the child process so callers can detect early failures.
812
+ *
813
+ * On macOS, uses `open -a` which goes through Launch Services — the correct
814
+ * way to launch a .app bundle and ensure flags reach the Chrome process.
815
+ * Direct binary invocation on macOS can fail because Chrome's Mach-O binary
816
+ * goes through framework wrappers that may drop command-line flags.
817
+ *
818
+ * IMPORTANT: On macOS, `open -a` ignores --args if Chrome is ALREADY running.
819
+ * Callers must ensure Chrome is fully quit before calling this function.
723
820
  */
724
821
  function spawnChrome(chromePath: string, port: number): ChildProcess {
725
822
  const cdpFlag = `--remote-debugging-port=${port}`;
823
+ const os = platform();
726
824
 
727
- // Always invoke the Chrome binary directly rather than `open -a`.
728
- // On macOS, `open -a` silently ignores --args when Chrome is already
729
- // running, which would cause CDP to never be enabled.
730
- log.debug(`Spawning Chrome: ${chromePath} ${cdpFlag} --restore-last-session`);
731
- const child = spawn(chromePath, [cdpFlag, "--restore-last-session"], {
732
- detached: true,
733
- stdio: ["ignore", "pipe", "pipe"],
734
- });
825
+ let child: ChildProcess;
826
+
827
+ if (os === "darwin") {
828
+ const appName = macAppName(chromePath);
829
+ log.debug(
830
+ `Spawning browser via: open -a "${appName}" --args ${cdpFlag} --restore-last-session`
831
+ );
832
+ child = spawn("open", ["-a", appName, "--args", cdpFlag, "--restore-last-session"], {
833
+ detached: true,
834
+ stdio: ["ignore", "pipe", "pipe"],
835
+ });
836
+ } else {
837
+ log.debug(`Spawning Chrome: ${chromePath} ${cdpFlag} --restore-last-session`);
838
+ child = spawn(chromePath, [cdpFlag, "--restore-last-session"], {
839
+ detached: true,
840
+ stdio: ["ignore", "pipe", "pipe"],
841
+ });
842
+ }
735
843
 
736
844
  // Capture stderr for diagnostics — Chrome prints errors here
737
845
  let stderr = "";
@@ -850,13 +958,26 @@ export async function ensureBrowserAvailable(port = 9222): Promise<AutoLaunchRes
850
958
 
851
959
  log.debug(`Found Chrome at: ${chromePath}`);
852
960
 
853
- const running = isChromeRunning();
854
- log.debug(`Chrome currently running: ${running}`);
961
+ const running = isChromeRunning(chromePath);
962
+ log.debug(`Browser currently running: ${running}`);
855
963
 
856
- // Case 3: Chrome running without CDP → restart
964
+ // Case 3: Browser running without CDP → restart
857
965
  if (running) {
858
- log.debug("Killing Chrome gracefully for restart with CDP...");
859
- await killChromeGracefully();
966
+ log.debug("Killing browser gracefully for restart with CDP...");
967
+ await killChromeGracefully(chromePath);
968
+
969
+ // Verify browser is fully dead — critical on macOS where `open -a`
970
+ // ignores --args if the app is still alive.
971
+ if (isChromeRunning(chromePath)) {
972
+ log.debug("Browser still running after kill attempt — cannot restart with CDP");
973
+ return {
974
+ success: false,
975
+ action: "launch_failed",
976
+ chromePath,
977
+ detail:
978
+ "Could not stop the existing browser process. Please quit the browser manually and run assistme again.",
979
+ };
980
+ }
860
981
 
861
982
  // Extra wait for profile lock release after kill
862
983
  await new Promise((r) => setTimeout(r, 2000));
@@ -867,37 +988,37 @@ export async function ensureBrowserAvailable(port = 9222): Promise<AutoLaunchRes
867
988
  return { success: true, action: "restarted", chromePath };
868
989
  }
869
990
 
870
- // Check if Chrome is still alive before retrying
991
+ // Check if browser process exited immediately
871
992
  if (child.exitCode !== null) {
872
993
  log.debug(
873
- `Chrome process already exited (code ${child.exitCode}) — may have crashed or profile is locked`
994
+ `Browser process already exited (code ${child.exitCode}) — may have crashed or profile is locked`
874
995
  );
875
996
  return {
876
997
  success: false,
877
998
  action: "launch_failed",
878
999
  chromePath,
879
- detail: `Chrome exited immediately (code ${child.exitCode}). This often means the profile is locked by another Chrome instance. Try closing all Chrome windows first, then run assistme again.`,
1000
+ detail: `Browser exited immediately (code ${child.exitCode}). The profile may be locked. Try closing all browser windows first, then run assistme again.`,
880
1001
  };
881
1002
  }
882
1003
 
883
- // Retry once — Chrome can be slow to start (extensions, session restore)
1004
+ // Retry once — browser can be slow to start (extensions, session restore)
884
1005
  log.debug("First CDP wait timed out after restart, retrying...");
885
1006
  if (await waitForCDP(browser, 15000)) {
886
1007
  return { success: true, action: "restarted", chromePath };
887
1008
  }
888
1009
 
889
- const stillRunning = isChromeRunning();
1010
+ const stillRunning = isChromeRunning(chromePath);
890
1011
  return {
891
1012
  success: false,
892
1013
  action: "launch_failed",
893
1014
  chromePath,
894
1015
  detail: stillRunning
895
- ? "Chrome is running but CDP port is not responding. Chrome may have started without the --remote-debugging-port flag. Try: 1) Quit Chrome completely, 2) Run assistme again."
896
- : "Chrome was restarted but exited unexpectedly. Check if Chrome can start normally from the command line.",
1016
+ ? "Browser is running but CDP port is not responding. Try: 1) Quit the browser completely, 2) Run assistme again."
1017
+ : "Browser was restarted but exited unexpectedly. Try launching it manually to check for errors.",
897
1018
  };
898
1019
  }
899
1020
 
900
- // Case 4: Chrome not running → launch
1021
+ // Case 4: Browser not running → launch
901
1022
  const child = spawnChrome(chromePath, port);
902
1023
 
903
1024
  if (await waitForCDP(browser)) {
@@ -905,12 +1026,12 @@ export async function ensureBrowserAvailable(port = 9222): Promise<AutoLaunchRes
905
1026
  }
906
1027
 
907
1028
  if (child.exitCode !== null) {
908
- log.debug(`Chrome process already exited (code ${child.exitCode})`);
1029
+ log.debug(`Browser process already exited (code ${child.exitCode})`);
909
1030
  return {
910
1031
  success: false,
911
1032
  action: "launch_failed",
912
1033
  chromePath,
913
- detail: `Chrome exited immediately (code ${child.exitCode}). Try launching Chrome manually to see any error dialogs.`,
1034
+ detail: `Browser exited immediately (code ${child.exitCode}). Try launching it manually to see any error dialogs.`,
914
1035
  };
915
1036
  }
916
1037
 
@@ -920,7 +1041,7 @@ export async function ensureBrowserAvailable(port = 9222): Promise<AutoLaunchRes
920
1041
  return { success: true, action: "launched", chromePath };
921
1042
  }
922
1043
 
923
- const stillRunning = isChromeRunning();
1044
+ const stillRunning = isChromeRunning(chromePath);
924
1045
  return {
925
1046
  success: false,
926
1047
  action: "launch_failed",
@@ -1,4 +1,4 @@
1
- import { getBrowser, ensureBrowserAvailable } from "./browser.js";
1
+ import { type BrowserController, getBrowser, ensureBrowserAvailable } from "./browser.js";
2
2
  import {
3
3
  readFileContent,
4
4
  writeFileContent,
@@ -270,6 +270,25 @@ export function getToolDefinitions(): ToolDefinition[] {
270
270
  ];
271
271
  }
272
272
 
273
+ /**
274
+ * Ensure Chrome is running with CDP and we have an active WebSocket connection.
275
+ * Called lazily by every browser tool so the user never has to call browser_connect explicitly.
276
+ */
277
+ async function ensureConnected(browser: BrowserController, tabIndex?: number): Promise<void> {
278
+ if (browser.isConnected() && tabIndex === undefined) return;
279
+
280
+ // Auto-launch Chrome if CDP is not reachable
281
+ if (!(await browser.isAvailable())) {
282
+ const result = await ensureBrowserAvailable();
283
+ if (!result.success) {
284
+ throw new Error(
285
+ `Chrome is not available (${result.action}). ${result.detail || "Please ensure Google Chrome is installed."}`
286
+ );
287
+ }
288
+ }
289
+ await browser.connect(tabIndex);
290
+ }
291
+
273
292
  export async function executeTool(name: string, input: Record<string, unknown>): Promise<string> {
274
293
  const browser = getBrowser();
275
294
 
@@ -298,36 +317,35 @@ export async function executeTool(name: string, input: Record<string, unknown>):
298
317
 
299
318
  // ── Browser (CDP) ───────────────────────────────────────
300
319
  case "browser_connect": {
301
- // Auto-launch Chrome if CDP is not reachable
302
- if (!(await browser.isAvailable())) {
303
- const result = await ensureBrowserAvailable();
304
- if (!result.success) {
305
- throw new Error(
306
- `Failed to auto-launch Chrome (${result.action}). ` +
307
- "Please ensure Google Chrome is installed."
308
- );
309
- }
310
- }
311
- return browser.connect(input.tab_index as number | undefined);
320
+ await ensureConnected(browser, input.tab_index as number | undefined);
321
+ return browser.isConnected() ? "Connected to browser." : "Failed to connect.";
312
322
  }
313
323
  case "browser_navigate":
314
- if (!browser.isConnected()) await browser.connect();
324
+ await ensureConnected(browser);
315
325
  return browser.navigate(input.url as string);
316
326
  case "browser_read_page":
327
+ await ensureConnected(browser);
317
328
  return browser.readPage();
318
329
  case "browser_screenshot":
330
+ await ensureConnected(browser);
319
331
  return browser.screenshot();
320
332
  case "browser_click":
333
+ await ensureConnected(browser);
321
334
  return browser.click(input.selector as string);
322
335
  case "browser_type":
336
+ await ensureConnected(browser);
323
337
  return browser.typeText(input.selector as string, input.text as string);
324
338
  case "browser_press_key":
339
+ await ensureConnected(browser);
325
340
  return browser.pressKey(input.key as string);
326
341
  case "browser_scroll":
342
+ await ensureConnected(browser);
327
343
  return (input.direction as string) === "up" ? browser.scrollUp() : browser.scrollDown();
328
344
  case "browser_get_elements":
345
+ await ensureConnected(browser);
329
346
  return browser.getInteractiveElements();
330
347
  case "browser_evaluate":
348
+ await ensureConnected(browser);
331
349
  return browser.evaluate(input.expression as string);
332
350
  case "browser_list_tabs":
333
351
  return browser.listTabs();
@@ -350,10 +368,31 @@ export async function executeTool(name: string, input: Record<string, unknown>):
350
368
  console.log("━".repeat(60));
351
369
  console.log("\n");
352
370
 
353
- // Wait for the user to act (they do it in their browser)
354
- await new Promise((r) => setTimeout(r, waitSeconds * 1000));
371
+ // Capture current page URL to detect navigation (sign that user acted)
372
+ let initialUrl = "";
373
+ try {
374
+ const info = await browser.getPageInfo();
375
+ initialUrl = info.url;
376
+ } catch {
377
+ /* may not be connected */
378
+ }
379
+
380
+ // Poll every 3s instead of blind wait — detect URL changes early
381
+ const deadline = Date.now() + waitSeconds * 1000;
382
+ while (Date.now() < deadline) {
383
+ await new Promise((r) => setTimeout(r, 3000));
384
+ try {
385
+ const info = await browser.getPageInfo();
386
+ if (initialUrl && info.url !== initialUrl) {
387
+ // User navigated — action likely completed
388
+ break;
389
+ }
390
+ } catch {
391
+ // Connection may have dropped during user action
392
+ }
393
+ }
355
394
 
356
- // After waiting, read the page to see current state
395
+ // Read the page to see current state
357
396
  try {
358
397
  const pageInfo = await browser.readPage();
359
398
  return `User action wait completed. Current page state:\n${pageInfo.slice(0, 3000)}`;