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 +269 -70
- package/package.json +1 -1
- package/src/tools/browser.ts +282 -69
- package/src/tools/index.ts +55 -16
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
|
-
|
|
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
|
|
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(
|
|
585
|
-
if (!el) return 'Element not found: ${
|
|
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
|
|
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(
|
|
622
|
-
if (!el) return 'Element not found: ${
|
|
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
|
|
640
|
-
const
|
|
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(
|
|
645
|
-
if (!el) return 'Element not found: ${
|
|
662
|
+
const el = document.querySelector(${selectorJS});
|
|
663
|
+
if (!el) return 'Element not found: ' + ${selectorJS};
|
|
646
664
|
|
|
647
665
|
el.focus();
|
|
648
|
-
el.value =
|
|
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)
|
|
788
|
-
|
|
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)
|
|
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
|
-
|
|
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:
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
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
|
|
847
|
-
|
|
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(
|
|
857
|
-
|
|
858
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
859
|
-
|
|
860
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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(`
|
|
1114
|
+
const running = isChromeRunning(chromePath);
|
|
1115
|
+
log.debug(`Browser currently running: ${running}`);
|
|
978
1116
|
if (running) {
|
|
979
|
-
log.debug("Killing
|
|
980
|
-
await killChromeGracefully();
|
|
981
|
-
|
|
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: "
|
|
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
|
|
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
|
-
|
|
1754
|
+
unlinkSync2(zipPath);
|
|
1585
1755
|
} catch {
|
|
1586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/tools/browser.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
269
|
-
if (!el) return 'Element not found: ${
|
|
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
|
|
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(
|
|
314
|
-
if (!el) return 'Element not found: ${
|
|
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
|
-
|
|
335
|
-
|
|
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(
|
|
341
|
-
if (!el) return 'Element not found: ${
|
|
361
|
+
const el = document.querySelector(${selectorJS});
|
|
362
|
+
if (!el) return 'Element not found: ' + ${selectorJS};
|
|
342
363
|
|
|
343
364
|
el.focus();
|
|
344
|
-
el.value =
|
|
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)
|
|
517
|
-
|
|
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)
|
|
520
|
-
|
|
521
|
-
|
|
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:
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
|
589
|
-
|
|
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
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
637
|
-
execSync(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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())
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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(`
|
|
961
|
+
const running = isChromeRunning(chromePath);
|
|
962
|
+
log.debug(`Browser currently running: ${running}`);
|
|
796
963
|
|
|
797
|
-
// Case 3:
|
|
964
|
+
// Case 3: Browser running without CDP → restart
|
|
798
965
|
if (running) {
|
|
799
|
-
log.debug("Killing
|
|
800
|
-
await killChromeGracefully();
|
|
801
|
-
|
|
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
|
-
//
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
354
|
-
|
|
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
|
-
//
|
|
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)}`;
|