assistme 0.1.9 → 0.1.11

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
@@ -394,7 +394,7 @@ import {
394
394
  import { WebSocket } from "ws";
395
395
  import { execSync, spawn } from "child_process";
396
396
  import { platform } from "os";
397
- import { existsSync } from "fs";
397
+ import { existsSync, unlinkSync } from "fs";
398
398
  var BrowserController = class {
399
399
  ws = null;
400
400
  debugPort;
@@ -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", {
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,33 +975,94 @@ async function killChromeGracefully() {
898
975
  }
899
976
  const start = Date.now();
900
977
  while (Date.now() - start < 8e3) {
901
- if (!isChromeRunning()) return;
978
+ if (!isChromeRunning(chromePath)) {
979
+ log.debug(`Browser exited after ${Date.now() - start}ms`);
980
+ return;
981
+ }
902
982
  await new Promise((r) => setTimeout(r, 500));
903
983
  }
984
+ log.debug("Browser still running after graceful quit, force-killing...");
904
985
  try {
905
986
  if (os === "win32") {
906
- execSync("taskkill /F /IM chrome.exe", {
987
+ const exe = chromePath.split("\\").pop() || "chrome.exe";
988
+ execSync(`taskkill /F /IM "${exe}"`, {
907
989
  stdio: ["pipe", "pipe", "pipe"]
908
990
  });
909
991
  } else {
910
- execSync("pkill -9 -f chrome", {
992
+ execSync(`pkill -9 -f ${JSON.stringify(chromePath)}`, {
911
993
  stdio: ["pipe", "pipe", "pipe"]
912
994
  });
913
995
  }
914
996
  } catch {
915
997
  }
916
998
  await new Promise((r) => setTimeout(r, 1e3));
999
+ if (os !== "win32") {
1000
+ const home = process.env.HOME;
1001
+ if (home) {
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`
1007
+ ] : [
1008
+ `${home}/.config/google-chrome`,
1009
+ `${home}/.config/chromium`,
1010
+ `${home}/.config/microsoft-edge`,
1011
+ `${home}/.config/BraveSoftware/Brave-Browser`
1012
+ ];
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 {
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+ }
917
1027
  }
918
1028
  function spawnChrome(chromePath, port) {
919
1029
  const cdpFlag = `--remote-debugging-port=${port}`;
920
- log.debug(`Spawning Chrome: ${chromePath} ${cdpFlag} --restore-last-session`);
921
- const child = spawn(chromePath, [cdpFlag, "--restore-last-session"], {
922
- detached: true,
923
- stdio: "ignore"
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
+ }
1048
+ let stderr = "";
1049
+ child.stderr?.on("data", (chunk) => {
1050
+ stderr += chunk.toString();
924
1051
  });
925
1052
  child.on("error", (err) => {
926
1053
  log.error(`Chrome spawn error: ${err.message}`);
927
1054
  });
1055
+ child.on("exit", (code, signal) => {
1056
+ if (code !== null && code !== 0) {
1057
+ log.debug(`Chrome exited with code ${code}${signal ? ` (signal: ${signal})` : ""}`);
1058
+ if (stderr) {
1059
+ const lines = stderr.split("\n").filter(Boolean).slice(0, 5);
1060
+ for (const line of lines) {
1061
+ log.debug(` chrome stderr: ${line}`);
1062
+ }
1063
+ }
1064
+ }
1065
+ });
928
1066
  child.unref();
929
1067
  return child;
930
1068
  }
@@ -973,39 +1111,71 @@ async function ensureBrowserAvailable(port = 9222) {
973
1111
  return { success: false, action: "chrome_not_found" };
974
1112
  }
975
1113
  log.debug(`Found Chrome at: ${chromePath}`);
976
- const running = isChromeRunning();
977
- log.debug(`Chrome currently running: ${running}`);
1114
+ const running = isChromeRunning(chromePath);
1115
+ log.debug(`Browser currently running: ${running}`);
978
1116
  if (running) {
979
- log.debug("Killing Chrome gracefully for restart with CDP...");
980
- await killChromeGracefully();
981
- spawnChrome(chromePath, port);
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
+ }
1128
+ await new Promise((r) => setTimeout(r, 2e3));
1129
+ const child2 = spawnChrome(chromePath, port);
982
1130
  if (await waitForCDP(browser)) {
983
1131
  return { success: true, action: "restarted", chromePath };
984
1132
  }
1133
+ if (child2.exitCode !== null) {
1134
+ log.debug(
1135
+ `Browser process already exited (code ${child2.exitCode}) \u2014 may have crashed or profile is locked`
1136
+ );
1137
+ return {
1138
+ success: false,
1139
+ action: "launch_failed",
1140
+ chromePath,
1141
+ detail: `Browser exited immediately (code ${child2.exitCode}). The profile may be locked. Try closing all browser windows first, then run assistme again.`
1142
+ };
1143
+ }
985
1144
  log.debug("First CDP wait timed out after restart, retrying...");
986
1145
  if (await waitForCDP(browser, 15e3)) {
987
1146
  return { success: true, action: "restarted", chromePath };
988
1147
  }
1148
+ const stillRunning2 = isChromeRunning(chromePath);
989
1149
  return {
990
1150
  success: false,
991
1151
  action: "launch_failed",
992
1152
  chromePath,
993
- detail: "Chrome was restarted but CDP did not become reachable within timeout."
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."
994
1154
  };
995
1155
  }
996
- spawnChrome(chromePath, port);
1156
+ const child = spawnChrome(chromePath, port);
997
1157
  if (await waitForCDP(browser)) {
998
1158
  return { success: true, action: "launched", chromePath };
999
1159
  }
1160
+ if (child.exitCode !== null) {
1161
+ log.debug(`Browser process already exited (code ${child.exitCode})`);
1162
+ return {
1163
+ success: false,
1164
+ action: "launch_failed",
1165
+ chromePath,
1166
+ detail: `Browser exited immediately (code ${child.exitCode}). Try launching it manually to see any error dialogs.`
1167
+ };
1168
+ }
1000
1169
  log.debug("First CDP wait timed out after launch, retrying...");
1001
1170
  if (await waitForCDP(browser, 15e3)) {
1002
1171
  return { success: true, action: "launched", chromePath };
1003
1172
  }
1173
+ const stillRunning = isChromeRunning(chromePath);
1004
1174
  return {
1005
1175
  success: false,
1006
1176
  action: "launch_failed",
1007
1177
  chromePath,
1008
- detail: "Chrome was launched but CDP did not become reachable within timeout."
1178
+ detail: stillRunning ? "Chrome is running but CDP port is not responding. Try quitting Chrome completely and running assistme again." : "Chrome exited unexpectedly after launch."
1009
1179
  };
1010
1180
  }
1011
1181
  var browserInstance = null;
@@ -1161,7 +1331,7 @@ import {
1161
1331
  readFileSync,
1162
1332
  writeFileSync,
1163
1333
  statSync,
1164
- unlinkSync,
1334
+ unlinkSync as unlinkSync2,
1165
1335
  rmSync
1166
1336
  } from "fs";
1167
1337
  import { join, basename, dirname } from "path";
@@ -1581,9 +1751,9 @@ ${content}
1581
1751
  await execUnzip(`unzip -o "${zipPath}" -d "${skillDir}"`, {
1582
1752
  timeout: 15e3
1583
1753
  });
1584
- unlinkSync(zipPath);
1754
+ unlinkSync2(zipPath);
1585
1755
  } catch {
1586
- unlinkSync(zipPath);
1756
+ unlinkSync2(zipPath);
1587
1757
  const fileResp = await fetch(
1588
1758
  `${CLAWHUB_API}/skills/${encodeURIComponent(name)}/file?path=SKILL.md&tag=latest`
1589
1759
  );
@@ -1654,7 +1824,7 @@ ${content}
1654
1824
  if (dir !== SKILLS_DIR) {
1655
1825
  rmSync(dir, { recursive: true, force: true });
1656
1826
  } else {
1657
- unlinkSync(skill.filePath);
1827
+ unlinkSync2(skill.filePath);
1658
1828
  }
1659
1829
  this.skills.delete(name);
1660
1830
  return true;
@@ -2192,6 +2362,18 @@ ${stderr}` : "";
2192
2362
  }
2193
2363
 
2194
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
+ }
2195
2377
  async function executeTool(name, input) {
2196
2378
  const browser = getBrowser();
2197
2379
  switch (name) {
@@ -2218,34 +2400,35 @@ async function executeTool(name, input) {
2218
2400
  return executeShell(input.command, input.cwd);
2219
2401
  // ── Browser (CDP) ───────────────────────────────────────
2220
2402
  case "browser_connect": {
2221
- if (!await browser.isAvailable()) {
2222
- const result = await ensureBrowserAvailable();
2223
- if (!result.success) {
2224
- throw new Error(
2225
- `Failed to auto-launch Chrome (${result.action}). Please ensure Google Chrome is installed.`
2226
- );
2227
- }
2228
- }
2229
- return browser.connect(input.tab_index);
2403
+ await ensureConnected(browser, input.tab_index);
2404
+ return browser.isConnected() ? "Connected to browser." : "Failed to connect.";
2230
2405
  }
2231
2406
  case "browser_navigate":
2232
- if (!browser.isConnected()) await browser.connect();
2407
+ await ensureConnected(browser);
2233
2408
  return browser.navigate(input.url);
2234
2409
  case "browser_read_page":
2410
+ await ensureConnected(browser);
2235
2411
  return browser.readPage();
2236
2412
  case "browser_screenshot":
2413
+ await ensureConnected(browser);
2237
2414
  return browser.screenshot();
2238
2415
  case "browser_click":
2416
+ await ensureConnected(browser);
2239
2417
  return browser.click(input.selector);
2240
2418
  case "browser_type":
2419
+ await ensureConnected(browser);
2241
2420
  return browser.typeText(input.selector, input.text);
2242
2421
  case "browser_press_key":
2422
+ await ensureConnected(browser);
2243
2423
  return browser.pressKey(input.key);
2244
2424
  case "browser_scroll":
2425
+ await ensureConnected(browser);
2245
2426
  return input.direction === "up" ? browser.scrollUp() : browser.scrollDown();
2246
2427
  case "browser_get_elements":
2428
+ await ensureConnected(browser);
2247
2429
  return browser.getInteractiveElements();
2248
2430
  case "browser_evaluate":
2431
+ await ensureConnected(browser);
2249
2432
  return browser.evaluate(input.expression);
2250
2433
  case "browser_list_tabs":
2251
2434
  return browser.listTabs();
@@ -2264,7 +2447,23 @@ async function executeTool(name, input) {
2264
2447
  console.log(` (Waiting up to ${waitSeconds}s for you to complete this)`);
2265
2448
  console.log("\u2501".repeat(60));
2266
2449
  console.log("\n");
2267
- 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
+ }
2268
2467
  try {
2269
2468
  const pageInfo = await browser.readPage();
2270
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.9",
3
+ "version": "0.1.11",
4
4
  "description": "AssistMe CLI Agent - AI-powered assistant that controls your real browser",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,7 @@
17
17
  import { WebSocket } from "ws";
18
18
  import { execSync, spawn, type ChildProcess } from "node:child_process";
19
19
  import { platform } from "node:os";
20
- import { existsSync } from "node:fs";
20
+ import { existsSync, unlinkSync } from "node:fs";
21
21
  import { log } from "../utils/logger.js";
22
22
 
23
23
  interface CDPTab {
@@ -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.
705
+ */
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.
631
716
  */
632
- async function killChromeGracefully(): Promise<void> {
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", {
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,50 +740,130 @@ 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()) return;
746
+ if (!isChromeRunning(chromePath)) {
747
+ log.debug(`Browser exited after ${Date.now() - start}ms`);
748
+ return;
749
+ }
660
750
  await new Promise((r) => setTimeout(r, 500));
661
751
  }
662
752
 
753
+ log.debug("Browser still running after graceful quit, force-killing...");
754
+
663
755
  // Force kill if still alive
664
756
  try {
665
757
  if (os === "win32") {
666
- execSync("taskkill /F /IM chrome.exe", {
758
+ const exe = chromePath.split("\\").pop() || "chrome.exe";
759
+ execSync(`taskkill /F /IM "${exe}"`, {
667
760
  stdio: ["pipe", "pipe", "pipe"],
668
761
  });
669
762
  } else {
670
- execSync("pkill -9 -f chrome", {
763
+ execSync(`pkill -9 -f ${JSON.stringify(chromePath)}`, {
671
764
  stdio: ["pipe", "pipe", "pipe"],
672
765
  });
673
766
  }
674
767
  } catch {
675
768
  /* already dead */
676
769
  }
770
+
771
+ // Wait for processes to fully terminate after SIGKILL
677
772
  await new Promise((r) => setTimeout(r, 1000));
773
+
774
+ // Remove SingletonLock files that may linger after a force-kill
775
+ if (os !== "win32") {
776
+ const home = process.env.HOME;
777
+ if (home) {
778
+ const lockSuffixes = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
779
+ const profileDirs =
780
+ os === "darwin"
781
+ ? [
782
+ `${home}/Library/Application Support/Google/Chrome`,
783
+ `${home}/Library/Application Support/Microsoft Edge`,
784
+ `${home}/Library/Application Support/BraveSoftware/Brave-Browser`,
785
+ ]
786
+ : [
787
+ `${home}/.config/google-chrome`,
788
+ `${home}/.config/chromium`,
789
+ `${home}/.config/microsoft-edge`,
790
+ `${home}/.config/BraveSoftware/Brave-Browser`,
791
+ ];
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 */
802
+ }
803
+ }
804
+ }
805
+ }
806
+ }
678
807
  }
679
808
 
680
809
  /**
681
810
  * Spawn Chrome with the remote-debugging-port flag.
682
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.
683
820
  */
684
821
  function spawnChrome(chromePath: string, port: number): ChildProcess {
685
822
  const cdpFlag = `--remote-debugging-port=${port}`;
823
+ const os = platform();
824
+
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
+ }
686
843
 
687
- // Always invoke the Chrome binary directly rather than `open -a`.
688
- // On macOS, `open -a` silently ignores --args when Chrome is already
689
- // running, which would cause CDP to never be enabled.
690
- log.debug(`Spawning Chrome: ${chromePath} ${cdpFlag} --restore-last-session`);
691
- const child = spawn(chromePath, [cdpFlag, "--restore-last-session"], {
692
- detached: true,
693
- stdio: "ignore",
844
+ // Capture stderr for diagnostics Chrome prints errors here
845
+ let stderr = "";
846
+ child.stderr?.on("data", (chunk: Buffer) => {
847
+ stderr += chunk.toString();
694
848
  });
695
849
 
696
850
  child.on("error", (err) => {
697
851
  log.error(`Chrome spawn error: ${err.message}`);
698
852
  });
699
853
 
854
+ child.on("exit", (code, signal) => {
855
+ if (code !== null && code !== 0) {
856
+ log.debug(`Chrome exited with code ${code}${signal ? ` (signal: ${signal})` : ""}`);
857
+ if (stderr) {
858
+ // Log first few lines of stderr for diagnostics
859
+ const lines = stderr.split("\n").filter(Boolean).slice(0, 5);
860
+ for (const line of lines) {
861
+ log.debug(` chrome stderr: ${line}`);
862
+ }
863
+ }
864
+ }
865
+ });
866
+
700
867
  child.unref();
701
868
  return child;
702
869
  }
@@ -791,51 +958,97 @@ export async function ensureBrowserAvailable(port = 9222): Promise<AutoLaunchRes
791
958
 
792
959
  log.debug(`Found Chrome at: ${chromePath}`);
793
960
 
794
- const running = isChromeRunning();
795
- log.debug(`Chrome currently running: ${running}`);
961
+ const running = isChromeRunning(chromePath);
962
+ log.debug(`Browser currently running: ${running}`);
796
963
 
797
- // Case 3: Chrome running without CDP → restart
964
+ // Case 3: Browser running without CDP → restart
798
965
  if (running) {
799
- log.debug("Killing Chrome gracefully for restart with CDP...");
800
- await killChromeGracefully();
801
- spawnChrome(chromePath, port);
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
+ }
981
+
982
+ // Extra wait for profile lock release after kill
983
+ await new Promise((r) => setTimeout(r, 2000));
984
+
985
+ const child = spawnChrome(chromePath, port);
802
986
 
803
987
  if (await waitForCDP(browser)) {
804
988
  return { success: true, action: "restarted", chromePath };
805
989
  }
806
990
 
807
- // Retry once Chrome can be slow to start (extensions, session restore)
991
+ // Check if browser process exited immediately
992
+ if (child.exitCode !== null) {
993
+ log.debug(
994
+ `Browser process already exited (code ${child.exitCode}) — may have crashed or profile is locked`
995
+ );
996
+ return {
997
+ success: false,
998
+ action: "launch_failed",
999
+ chromePath,
1000
+ detail: `Browser exited immediately (code ${child.exitCode}). The profile may be locked. Try closing all browser windows first, then run assistme again.`,
1001
+ };
1002
+ }
1003
+
1004
+ // Retry once — browser can be slow to start (extensions, session restore)
808
1005
  log.debug("First CDP wait timed out after restart, retrying...");
809
1006
  if (await waitForCDP(browser, 15000)) {
810
1007
  return { success: true, action: "restarted", chromePath };
811
1008
  }
812
1009
 
1010
+ const stillRunning = isChromeRunning(chromePath);
813
1011
  return {
814
1012
  success: false,
815
1013
  action: "launch_failed",
816
1014
  chromePath,
817
- detail: "Chrome was restarted but CDP did not become reachable within timeout.",
1015
+ detail: stillRunning
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.",
818
1018
  };
819
1019
  }
820
1020
 
821
- // Case 4: Chrome not running → launch
822
- spawnChrome(chromePath, port);
1021
+ // Case 4: Browser not running → launch
1022
+ const child = spawnChrome(chromePath, port);
823
1023
 
824
1024
  if (await waitForCDP(browser)) {
825
1025
  return { success: true, action: "launched", chromePath };
826
1026
  }
827
1027
 
1028
+ if (child.exitCode !== null) {
1029
+ log.debug(`Browser process already exited (code ${child.exitCode})`);
1030
+ return {
1031
+ success: false,
1032
+ action: "launch_failed",
1033
+ chromePath,
1034
+ detail: `Browser exited immediately (code ${child.exitCode}). Try launching it manually to see any error dialogs.`,
1035
+ };
1036
+ }
1037
+
828
1038
  // Retry once
829
1039
  log.debug("First CDP wait timed out after launch, retrying...");
830
1040
  if (await waitForCDP(browser, 15000)) {
831
1041
  return { success: true, action: "launched", chromePath };
832
1042
  }
833
1043
 
1044
+ const stillRunning = isChromeRunning(chromePath);
834
1045
  return {
835
1046
  success: false,
836
1047
  action: "launch_failed",
837
1048
  chromePath,
838
- detail: "Chrome was launched but CDP did not become reachable within timeout.",
1049
+ detail: stillRunning
1050
+ ? "Chrome is running but CDP port is not responding. Try quitting Chrome completely and running assistme again."
1051
+ : "Chrome exited unexpectedly after launch.",
839
1052
  };
840
1053
  }
841
1054
 
@@ -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)}`;