assistme 0.3.0 → 0.3.2
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/PLAN.md +14 -3
- package/dist/{chunk-UWE5WVQI.js → chunk-KX7ITO55.js} +20 -11
- package/dist/index.js +1791 -572
- package/dist/{job-runner-N4XAAWLJ.js → job-runner-P2L6MOOX.js} +1 -1
- package/package.json +5 -3
- package/src/agent/job-runner.ts +9 -13
- package/src/agent/mcp-servers.ts +6 -1020
- package/src/agent/memory.ts +2 -11
- package/src/agent/processor.ts +18 -108
- package/src/agent/scheduler.ts +2 -3
- package/src/agent/session.ts +20 -36
- package/src/agent/skills.ts +167 -61
- package/src/agent/system-prompt.ts +126 -0
- package/src/browser/chrome-launcher.ts +555 -0
- package/src/browser/controller.ts +1386 -0
- package/src/browser/types.ts +70 -0
- package/src/commands/credential.ts +190 -0
- package/src/commands/job.ts +14 -45
- package/src/commands/memory.ts +16 -29
- package/src/commands/schedule.ts +15 -37
- package/src/commands/start.ts +11 -43
- package/src/credentials/credential-store.test.ts +162 -0
- package/src/credentials/credential-store.ts +266 -0
- package/src/credentials/encryption.test.ts +98 -0
- package/src/credentials/encryption.ts +82 -0
- package/src/credentials/index.ts +15 -0
- package/src/credentials/local-store.ts +89 -0
- package/src/db/action.ts +19 -0
- package/src/db/api-client.ts +3 -32
- package/src/db/auth-store.ts +41 -0
- package/src/db/auth.ts +38 -0
- package/src/db/conversation.ts +39 -0
- package/src/db/event.ts +52 -0
- package/src/db/job-poll.ts +18 -0
- package/src/db/session.ts +60 -0
- package/src/db/supabase.ts +40 -383
- package/src/db/task.ts +69 -0
- package/src/db/types.ts +54 -0
- package/src/index.ts +2 -0
- package/src/mcp/agent-tools-server.ts +1047 -0
- package/src/mcp/browser-server.ts +258 -0
- package/src/tools/browser.ts +28 -1208
- package/src/tools/index.ts +32 -263
- package/src/tools/web.ts +0 -73
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export const BASE_SYSTEM_PROMPT = `You are AssistMe, an AI assistant that operates like a real human on the user's computer. You control the user's actual Chrome browser and work with their real files.
|
|
2
|
+
|
|
3
|
+
KEY PRINCIPLE: You operate the user's real browser, not a headless sandbox. This means:
|
|
4
|
+
- The browser has the user's real cookies, logins, and sessions
|
|
5
|
+
- When you navigate to amazon.com, you see the user's logged-in Amazon
|
|
6
|
+
- If a site needs login, the browser will auto-detect the login page and prompt the user
|
|
7
|
+
- After the user logs in, their session is saved in the persistent browser profile (~/.assistme/browser-profile)
|
|
8
|
+
- Saved sessions persist across assistme restarts — the user only needs to log in once per site
|
|
9
|
+
- You are like a human assistant sitting at the user's computer
|
|
10
|
+
- Chrome is automatically managed — just call browser_connect and it will auto-launch if needed
|
|
11
|
+
- NEVER ask the user to manually start Chrome or run any terminal commands for browser setup
|
|
12
|
+
|
|
13
|
+
Available capabilities:
|
|
14
|
+
1. BROWSER CONTROL (user's real Chrome via CDP):
|
|
15
|
+
**PREFERRED workflow — Snapshot + Act (ref-based):**
|
|
16
|
+
- browser_snapshot → takes a screenshot and discovers all interactive elements with numbered refs
|
|
17
|
+
Returns a ref table (text) + screenshot (image). The ref table is your PRIMARY context for element identification.
|
|
18
|
+
Use annotate=true only on simple pages (few elements) where visual badge overlay helps.
|
|
19
|
+
- browser_act → execute actions using ref numbers: click, type, select, press, scroll, wait
|
|
20
|
+
- This is MORE RELIABLE than CSS selectors because:
|
|
21
|
+
(a) The ref table gives you role, name, and type for every interactive element — no guessing
|
|
22
|
+
(b) Refs use stable semantic resolution (role + accessible name) that survives DOM changes
|
|
23
|
+
(c) Actions use CDP Input events (real mouse/keyboard) instead of JavaScript — works with all frameworks
|
|
24
|
+
(d) You can batch multiple actions in one call — fewer round-trips
|
|
25
|
+
- Example workflow:
|
|
26
|
+
1. browser_snapshot → ref table shows [1] button "Next", [2] textbox "Email", [3] combobox "Month"
|
|
27
|
+
2. browser_act actions=[{action:"type", ref:2, text:"user@example.com"}, {action:"select", ref:3, option:"March"}, {action:"click", ref:1}] screenshot=true
|
|
28
|
+
- Refs persist across actions unless the page navigates. Re-snapshot after navigation or major DOM changes.
|
|
29
|
+
|
|
30
|
+
**Legacy tools (still available, use when refs don't work):**
|
|
31
|
+
- browser_click, browser_type, browser_select, browser_get_elements, browser_screenshot, browser_evaluate
|
|
32
|
+
- browser_click supports :contains('text') pseudo-selectors
|
|
33
|
+
- browser_select handles native and custom dropdowns
|
|
34
|
+
|
|
35
|
+
**Other browser tools:**
|
|
36
|
+
- browser_connect, browser_navigate, browser_read_page, browser_list_tabs, browser_switch_tab, browser_new_tab
|
|
37
|
+
- If auth is needed: use browser_request_user_action to ask the user to log in
|
|
38
|
+
|
|
39
|
+
2. FILE OPERATIONS & SHELL:
|
|
40
|
+
- Read, Write, Edit tools for file operations
|
|
41
|
+
- Bash tool for shell commands
|
|
42
|
+
- Glob and Grep for file search
|
|
43
|
+
|
|
44
|
+
3. MEMORY:
|
|
45
|
+
- You can remember things about the user using memory_store
|
|
46
|
+
- Use this when you learn preferences, important facts, or standing instructions
|
|
47
|
+
- Your stored memories persist across conversations
|
|
48
|
+
- PROACTIVELY use memory_store during tasks when you discover user preferences, habits, or important context
|
|
49
|
+
- Before completing a task, consider if anything learned should be remembered for future conversations
|
|
50
|
+
|
|
51
|
+
4. SKILL-AWARE EXECUTION (CRITICAL — follow this for EVERY task):
|
|
52
|
+
Step A — Search: Before executing ANY task, check if an existing skill matches (use skill_invoke or skill_search).
|
|
53
|
+
Step B — If skill found: load it with skill_invoke and follow its instructions precisely. If the instructions are incomplete or wrong, adapt and improve as you go — note what changed.
|
|
54
|
+
Step C — If NO skill found: BEFORE executing, draft a skill plan following the Agent Skills format:
|
|
55
|
+
Skill Draft: [kebab-case-name]
|
|
56
|
+
Description: [what this skill does and when to use it]
|
|
57
|
+
Steps:
|
|
58
|
+
1. [first step]
|
|
59
|
+
2. [second step]
|
|
60
|
+
...
|
|
61
|
+
The draft should be a reusable workflow, not specific to this one request. Use generic placeholders where the user provided specific values.
|
|
62
|
+
Step D — Execute: Follow the skill draft (or loaded skill) step by step. Refine the draft as you discover better approaches, edge cases, or missing steps.
|
|
63
|
+
Step E — After execution: The system will automatically evaluate whether to save the skill. You do NOT need to call skill_create manually.
|
|
64
|
+
|
|
65
|
+
Agent Skills format reference (agentskills.io):
|
|
66
|
+
- name: 1-64 chars, lowercase kebab-case (a-z, 0-9, hyphens), no leading/trailing/consecutive hyphens
|
|
67
|
+
- description: 1-1024 chars, describe what the skill does AND when to use it, include keywords for discoverability
|
|
68
|
+
- body: markdown step-by-step instructions, examples, edge cases. Keep under 500 lines.
|
|
69
|
+
- Progressive disclosure: metadata (~100 tokens) → instructions (<5000 tokens) → references (on demand)
|
|
70
|
+
|
|
71
|
+
5. JOB AUTOMATION:
|
|
72
|
+
- When the user describes their job/role/daily work, use skill_generate to decompose it into automatable skills
|
|
73
|
+
- ALWAYS use ask_user to get user approval before creating skills — never create skills without approval
|
|
74
|
+
- Use job_run to start a job — it gives you the job's goal and available skills as capabilities
|
|
75
|
+
- When running a job, be AGENTIC: decide dynamically what to do based on what you discover
|
|
76
|
+
- Do NOT follow a fixed sequence — if checking Slack reveals a task that needs GitHub, go do GitHub immediately
|
|
77
|
+
- Chain actions intelligently: one skill's findings should inform your next move
|
|
78
|
+
- Skip irrelevant skills, use tools directly when skills aren't needed
|
|
79
|
+
- Use job_schedule for recurring automation (e.g., "run my SE job every weekday morning")
|
|
80
|
+
- Use job_status to check run history
|
|
81
|
+
|
|
82
|
+
6. SKILL MARKETPLACE:
|
|
83
|
+
- Use skill_browse to discover community-published skills
|
|
84
|
+
- Use skill_add to add marketplace skills to the user's collection
|
|
85
|
+
- Use skill_publish to share the user's skills with the community
|
|
86
|
+
|
|
87
|
+
Workflow for web tasks (e.g. "查一下 kindle 最新款价格"):
|
|
88
|
+
1. browser_connect → connect to user's Chrome
|
|
89
|
+
2. browser_new_tab → open a new tab
|
|
90
|
+
3. browser_navigate → go to the website (login pages are auto-detected)
|
|
91
|
+
4. browser_snapshot → get ref table + screenshot (use annotate=true for simple pages)
|
|
92
|
+
5. browser_act → interact using refs (type, click, select, etc.), set screenshot=true to see result
|
|
93
|
+
6. Repeat 4-5 as needed (re-snapshot after navigation or major page changes)
|
|
94
|
+
7. Summarize findings
|
|
95
|
+
|
|
96
|
+
Workflow for form filling (e.g. "注册一个 Gmail 账号"):
|
|
97
|
+
1. browser_connect + browser_navigate → go to the form page
|
|
98
|
+
2. browser_snapshot → see all form fields with ref numbers
|
|
99
|
+
3. browser_act → batch fill multiple fields + click submit in ONE call:
|
|
100
|
+
actions=[{action:"type", ref:1, text:"John"}, {action:"type", ref:2, text:"Doe"}, {action:"select", ref:3, option:"March"}, {action:"click", ref:7}] screenshot=true
|
|
101
|
+
4. Check the screenshot — if validation errors appear, re-snapshot and fix
|
|
102
|
+
5. When a username/email is taken, append a random 4-digit suffix and retry
|
|
103
|
+
|
|
104
|
+
Guidelines:
|
|
105
|
+
- Always use the real browser for web tasks, never try to fetch URLs programmatically
|
|
106
|
+
- ALWAYS use browser_snapshot as your primary way to understand a page — the ref table gives actionable refs, the screenshot gives visual context
|
|
107
|
+
- Use browser_act to batch multiple actions — fill an entire form in one call instead of individual clicks/types
|
|
108
|
+
- Only re-snapshot when: (a) the page navigated, (b) significant DOM changes occurred, (c) an action failed with "ref not found"
|
|
109
|
+
- Refs are semantically stable (resolved by role + name), so they often survive minor DOM updates
|
|
110
|
+
- Login pages are auto-detected after navigation — the user is prompted and sessions are saved automatically
|
|
111
|
+
- If auto-detection misses a login page, use browser_request_user_action manually
|
|
112
|
+
- Fall back to legacy tools (browser_click, browser_type, browser_evaluate) only when refs don't work
|
|
113
|
+
- Be thorough: check multiple sources when comparing prices/products
|
|
114
|
+
- Summarize results clearly at the end
|
|
115
|
+
- When you learn something about the user (preferences, habits), use memory_store to remember it
|
|
116
|
+
|
|
117
|
+
CRITICAL — Ask before you guess:
|
|
118
|
+
- Before executing a task, verify you have all required information. If anything is ambiguous or missing, use ask_user to ask.
|
|
119
|
+
- First try to resolve unknowns yourself: check memories, read workspace files (e.g. git remote, config files), or infer from conversation history.
|
|
120
|
+
- If you still lack a critical piece of information after self-resolution, ASK the user via ask_user. Do NOT guess, assume defaults, or proceed with incomplete information.
|
|
121
|
+
- When asking, provide suggested options as buttons whenever possible — the user can always type a custom answer instead.
|
|
122
|
+
- Examples of when to ask: which account/repo/project to target, what format the user wants, which of multiple options to choose, credentials or URLs that cannot be inferred.
|
|
123
|
+
- Keep questions specific and actionable. Explain what you already know and what exactly you need.
|
|
124
|
+
- After receiving the answer, store it with memory_store if it is likely to be useful in future conversations.
|
|
125
|
+
|
|
126
|
+
Workspace path: {workspace_path}`;
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { platform, homedir } from "node:os";
|
|
3
|
+
import { existsSync, unlinkSync, mkdirSync, cpSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { log } from "../utils/logger.js";
|
|
6
|
+
import { BrowserController } from "./controller.js";
|
|
7
|
+
import type { AutoLaunchResult } from "./types.js";
|
|
8
|
+
|
|
9
|
+
// ── Chrome Discovery ────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Find Chrome/Chromium binary path on the current platform.
|
|
13
|
+
*/
|
|
14
|
+
export function findChromePath(): string | null {
|
|
15
|
+
const os = platform();
|
|
16
|
+
|
|
17
|
+
if (os === "darwin") {
|
|
18
|
+
const paths = [
|
|
19
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
20
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
21
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
22
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
23
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
24
|
+
];
|
|
25
|
+
return paths.find((p) => existsSync(p)) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (os === "linux") {
|
|
29
|
+
const names = [
|
|
30
|
+
"google-chrome",
|
|
31
|
+
"google-chrome-stable",
|
|
32
|
+
"chromium-browser",
|
|
33
|
+
"chromium",
|
|
34
|
+
"microsoft-edge",
|
|
35
|
+
"microsoft-edge-stable",
|
|
36
|
+
"brave-browser",
|
|
37
|
+
];
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
try {
|
|
40
|
+
return execSync(`which ${name}`, {
|
|
41
|
+
encoding: "utf-8",
|
|
42
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
+
}).trim();
|
|
44
|
+
} catch {
|
|
45
|
+
/* not found */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (os === "win32") {
|
|
52
|
+
const prefixes = [
|
|
53
|
+
process.env.PROGRAMFILES,
|
|
54
|
+
process.env["PROGRAMFILES(X86)"],
|
|
55
|
+
process.env.LOCALAPPDATA,
|
|
56
|
+
].filter(Boolean) as string[];
|
|
57
|
+
|
|
58
|
+
const subPaths = [
|
|
59
|
+
"Google\\Chrome\\Application\\chrome.exe",
|
|
60
|
+
"Microsoft\\Edge\\Application\\msedge.exe",
|
|
61
|
+
"BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
for (const prefix of prefixes) {
|
|
65
|
+
for (const sub of subPaths) {
|
|
66
|
+
const p = `${prefix}\\${sub}`;
|
|
67
|
+
if (existsSync(p)) return p;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if a Chromium-based browser is currently running.
|
|
78
|
+
* Optionally pass the specific browser binary path for precise matching.
|
|
79
|
+
*/
|
|
80
|
+
export function isChromeRunning(chromePath?: string): boolean {
|
|
81
|
+
try {
|
|
82
|
+
if (platform() === "win32") {
|
|
83
|
+
// Check for any Chromium-based browser process
|
|
84
|
+
const out = execSync(
|
|
85
|
+
'tasklist /FI "IMAGENAME eq chrome.exe" /FI "IMAGENAME eq msedge.exe" /FI "IMAGENAME eq brave.exe" /NH',
|
|
86
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
87
|
+
);
|
|
88
|
+
return /chrome\.exe|msedge\.exe|brave\.exe/i.test(out);
|
|
89
|
+
}
|
|
90
|
+
if (platform() === "darwin") {
|
|
91
|
+
// If we know the exact binary, match it precisely
|
|
92
|
+
if (chromePath) {
|
|
93
|
+
const appDir = chromePath.replace(/\/Contents\/MacOS\/.*$/, "");
|
|
94
|
+
const out = execSync(`pgrep -f ${JSON.stringify(appDir)}`, {
|
|
95
|
+
encoding: "utf-8",
|
|
96
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
+
});
|
|
98
|
+
return out.trim().length > 0;
|
|
99
|
+
}
|
|
100
|
+
// Otherwise check all known Chromium browsers
|
|
101
|
+
const out = execSync(
|
|
102
|
+
'pgrep -f "(Google Chrome|Microsoft Edge|Brave Browser|Chromium).app/Contents/MacOS/"',
|
|
103
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
104
|
+
);
|
|
105
|
+
return out.trim().length > 0;
|
|
106
|
+
}
|
|
107
|
+
// Linux
|
|
108
|
+
if (chromePath) {
|
|
109
|
+
const out = execSync(`pgrep -f ${JSON.stringify(chromePath)} 2>/dev/null || true`, {
|
|
110
|
+
encoding: "utf-8",
|
|
111
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
112
|
+
});
|
|
113
|
+
return out.trim().length > 0;
|
|
114
|
+
}
|
|
115
|
+
const out = execSync("pgrep -f '(chrome|chromium|msedge|brave)' 2>/dev/null || true", {
|
|
116
|
+
encoding: "utf-8",
|
|
117
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
118
|
+
});
|
|
119
|
+
return out.trim().length > 0;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Profile Management ──────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Derive the macOS app name from a binary path inside a .app bundle.
|
|
129
|
+
*/
|
|
130
|
+
function macAppName(chromePath: string): string {
|
|
131
|
+
if (chromePath.includes("Brave Browser")) return "Brave Browser";
|
|
132
|
+
if (chromePath.includes("Microsoft Edge")) return "Microsoft Edge";
|
|
133
|
+
if (chromePath.includes("Chromium")) return "Chromium";
|
|
134
|
+
if (chromePath.includes("Canary")) return "Google Chrome Canary";
|
|
135
|
+
return "Google Chrome";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Gracefully quit the browser, then force-kill if it doesn't exit in time.
|
|
140
|
+
*/
|
|
141
|
+
async function killChromeGracefully(chromePath: string): Promise<void> {
|
|
142
|
+
const os = platform();
|
|
143
|
+
try {
|
|
144
|
+
if (os === "darwin") {
|
|
145
|
+
const app = macAppName(chromePath);
|
|
146
|
+
execSync(`osascript -e 'quit app "${app}"'`, {
|
|
147
|
+
timeout: 5000,
|
|
148
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
149
|
+
});
|
|
150
|
+
} else if (os === "linux") {
|
|
151
|
+
// Kill the specific browser binary, not all Chromium variants
|
|
152
|
+
execSync(`pkill -TERM -f ${JSON.stringify(chromePath)}`, {
|
|
153
|
+
timeout: 5000,
|
|
154
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
155
|
+
});
|
|
156
|
+
} else if (os === "win32") {
|
|
157
|
+
const exe = chromePath.split("\\").pop() || "chrome.exe";
|
|
158
|
+
execSync(`taskkill /IM "${exe}"`, {
|
|
159
|
+
timeout: 5000,
|
|
160
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
/* may already be closed */
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Wait for browser to fully exit (up to 8s)
|
|
168
|
+
const start = Date.now();
|
|
169
|
+
while (Date.now() - start < 8000) {
|
|
170
|
+
if (!isChromeRunning(chromePath)) {
|
|
171
|
+
log.debug(`Browser exited after ${Date.now() - start}ms`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
log.debug("Browser still running after graceful quit, force-killing...");
|
|
178
|
+
|
|
179
|
+
// Force kill if still alive
|
|
180
|
+
try {
|
|
181
|
+
if (os === "win32") {
|
|
182
|
+
const exe = chromePath.split("\\").pop() || "chrome.exe";
|
|
183
|
+
execSync(`taskkill /F /IM "${exe}"`, {
|
|
184
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
execSync(`pkill -9 -f ${JSON.stringify(chromePath)}`, {
|
|
188
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
/* already dead */
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Wait for processes to fully terminate after SIGKILL
|
|
196
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
197
|
+
|
|
198
|
+
// Remove SingletonLock files that may linger after a force-kill
|
|
199
|
+
if (os !== "win32") {
|
|
200
|
+
const home = process.env.HOME;
|
|
201
|
+
if (home) {
|
|
202
|
+
const lockSuffixes = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
|
|
203
|
+
const profileDirs =
|
|
204
|
+
os === "darwin"
|
|
205
|
+
? [
|
|
206
|
+
`${home}/Library/Application Support/Google/Chrome`,
|
|
207
|
+
`${home}/Library/Application Support/Microsoft Edge`,
|
|
208
|
+
`${home}/Library/Application Support/BraveSoftware/Brave-Browser`,
|
|
209
|
+
]
|
|
210
|
+
: [
|
|
211
|
+
`${home}/.config/google-chrome`,
|
|
212
|
+
`${home}/.config/chromium`,
|
|
213
|
+
`${home}/.config/microsoft-edge`,
|
|
214
|
+
`${home}/.config/BraveSoftware/Brave-Browser`,
|
|
215
|
+
];
|
|
216
|
+
for (const dir of profileDirs) {
|
|
217
|
+
for (const suffix of lockSuffixes) {
|
|
218
|
+
const lockPath = `${dir}/${suffix}`;
|
|
219
|
+
try {
|
|
220
|
+
if (existsSync(lockPath)) {
|
|
221
|
+
unlinkSync(lockPath);
|
|
222
|
+
log.debug(`Removed stale lock: ${lockPath}`);
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
/* best effort */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Return the browser's default profile directory.
|
|
235
|
+
*/
|
|
236
|
+
function getDefaultProfileDir(chromePath: string): string {
|
|
237
|
+
const home = homedir();
|
|
238
|
+
const os = platform();
|
|
239
|
+
|
|
240
|
+
if (os === "darwin") {
|
|
241
|
+
if (chromePath.includes("Brave Browser"))
|
|
242
|
+
return join(home, "Library", "Application Support", "BraveSoftware", "Brave-Browser");
|
|
243
|
+
if (chromePath.includes("Microsoft Edge"))
|
|
244
|
+
return join(home, "Library", "Application Support", "Microsoft Edge");
|
|
245
|
+
if (chromePath.includes("Chromium"))
|
|
246
|
+
return join(home, "Library", "Application Support", "Chromium");
|
|
247
|
+
if (chromePath.includes("Canary"))
|
|
248
|
+
return join(home, "Library", "Application Support", "Google", "Chrome Canary");
|
|
249
|
+
return join(home, "Library", "Application Support", "Google", "Chrome");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (os === "win32") {
|
|
253
|
+
const appData = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
|
|
254
|
+
if (chromePath.includes("brave"))
|
|
255
|
+
return join(appData, "BraveSoftware", "Brave-Browser", "User Data");
|
|
256
|
+
if (chromePath.includes("msedge")) return join(appData, "Microsoft", "Edge", "User Data");
|
|
257
|
+
return join(appData, "Google", "Chrome", "User Data");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Linux
|
|
261
|
+
if (chromePath.includes("brave")) return join(home, ".config", "BraveSoftware", "Brave-Browser");
|
|
262
|
+
if (chromePath.includes("microsoft-edge")) return join(home, ".config", "microsoft-edge");
|
|
263
|
+
if (chromePath.includes("chromium")) return join(home, ".config", "chromium");
|
|
264
|
+
return join(home, ".config", "google-chrome");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Return a dedicated debug profile directory for assistme.
|
|
269
|
+
*
|
|
270
|
+
* Chrome 136+ silently ignores --remote-debugging-port when launched with the
|
|
271
|
+
* DEFAULT user-data-dir (security hardening against cookie-stealing malware).
|
|
272
|
+
* It also ignores --user-data-dir pointing to the default path.
|
|
273
|
+
* The flag ONLY works with a NON-DEFAULT --user-data-dir.
|
|
274
|
+
*
|
|
275
|
+
* Strategy: use ~/.assistme/browser-profile as a dedicated debug profile.
|
|
276
|
+
* On first use, copy key files from the real profile (bookmarks, cookies,
|
|
277
|
+
* login data, preferences) so the user doesn't start completely fresh.
|
|
278
|
+
* Sessions accumulate in the debug profile from then on.
|
|
279
|
+
*
|
|
280
|
+
* See: https://developer.chrome.com/blog/remote-debugging-port
|
|
281
|
+
*/
|
|
282
|
+
function getDebugProfileDir(chromePath: string): string {
|
|
283
|
+
const home = homedir();
|
|
284
|
+
const debugDir = join(home, ".assistme", "browser-profile");
|
|
285
|
+
|
|
286
|
+
if (!existsSync(debugDir)) {
|
|
287
|
+
mkdirSync(debugDir, { recursive: true });
|
|
288
|
+
log.debug(`Created debug profile directory: ${debugDir}`);
|
|
289
|
+
|
|
290
|
+
// Seed from the real profile — copy lightweight files, skip caches
|
|
291
|
+
const realDir = getDefaultProfileDir(chromePath);
|
|
292
|
+
if (existsSync(realDir)) {
|
|
293
|
+
seedDebugProfile(realDir, debugDir);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return debugDir;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Copy essential profile data from the user's real Chrome profile to the
|
|
302
|
+
* debug profile. This preserves bookmarks, preferences, and (where possible)
|
|
303
|
+
* login state without copying multi-GB caches.
|
|
304
|
+
*
|
|
305
|
+
* Note: cookies/login data are encrypted with a key tied to the user-data-dir
|
|
306
|
+
* on Chrome 136+, so they won't decrypt in the debug profile. The user will
|
|
307
|
+
* need to log in once in the debug browser. After that, sessions persist.
|
|
308
|
+
*/
|
|
309
|
+
function seedDebugProfile(realDir: string, debugDir: string): void {
|
|
310
|
+
// Files to copy from the profile root
|
|
311
|
+
const rootFiles = ["Local State"];
|
|
312
|
+
// Files to copy from the "Default" sub-profile
|
|
313
|
+
const profileFiles = ["Bookmarks", "Preferences", "Favicons", "Top Sites", "Shortcuts"];
|
|
314
|
+
|
|
315
|
+
for (const file of rootFiles) {
|
|
316
|
+
const src = join(realDir, file);
|
|
317
|
+
const dest = join(debugDir, file);
|
|
318
|
+
try {
|
|
319
|
+
if (existsSync(src)) {
|
|
320
|
+
cpSync(src, dest, { force: true });
|
|
321
|
+
log.debug(`Seeded: ${file}`);
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
/* best effort */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Copy the Default profile sub-directory essentials
|
|
329
|
+
const srcProfile = join(realDir, "Default");
|
|
330
|
+
const destProfile = join(debugDir, "Default");
|
|
331
|
+
if (existsSync(srcProfile)) {
|
|
332
|
+
mkdirSync(destProfile, { recursive: true });
|
|
333
|
+
for (const file of profileFiles) {
|
|
334
|
+
const src = join(srcProfile, file);
|
|
335
|
+
const dest = join(destProfile, file);
|
|
336
|
+
try {
|
|
337
|
+
if (existsSync(src)) {
|
|
338
|
+
cpSync(src, dest, { force: true });
|
|
339
|
+
log.debug(`Seeded: Default/${file}`);
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
/* best effort */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Copy Extensions directory if it exists (preserves user's extensions)
|
|
347
|
+
const srcExt = join(srcProfile, "Extensions");
|
|
348
|
+
const destExt = join(destProfile, "Extensions");
|
|
349
|
+
try {
|
|
350
|
+
if (existsSync(srcExt)) {
|
|
351
|
+
cpSync(srcExt, destExt, { recursive: true, force: true });
|
|
352
|
+
log.debug("Seeded: Default/Extensions");
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
/* best effort — extensions can be large */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Chrome Spawning ─────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Spawn a Chromium-based browser with CDP enabled.
|
|
364
|
+
* Returns the child process for exit-code monitoring.
|
|
365
|
+
*
|
|
366
|
+
* Key design decisions:
|
|
367
|
+
* - Launches the binary directly (not via macOS `open -a`) so flags are
|
|
368
|
+
* guaranteed to reach the process and the child stays alive.
|
|
369
|
+
* - Uses a dedicated debug profile (not the default profile) so that:
|
|
370
|
+
* (a) Chrome 136+ allows --remote-debugging-port
|
|
371
|
+
* (b) Can run alongside the user's regular Chrome (different singleton)
|
|
372
|
+
* - Callers should ensure no OTHER debug-profile Chrome is running, but
|
|
373
|
+
* the user's regular Chrome can stay open.
|
|
374
|
+
*/
|
|
375
|
+
function spawnChrome(chromePath: string, port: number): ChildProcess {
|
|
376
|
+
const profileDir = getDebugProfileDir(chromePath);
|
|
377
|
+
const flags = [
|
|
378
|
+
`--remote-debugging-port=${port}`,
|
|
379
|
+
`--user-data-dir=${profileDir}`,
|
|
380
|
+
"--restore-last-session",
|
|
381
|
+
];
|
|
382
|
+
|
|
383
|
+
log.debug(`Spawning browser: ${chromePath} ${flags.join(" ")}`);
|
|
384
|
+
|
|
385
|
+
const child = spawn(chromePath, flags, {
|
|
386
|
+
detached: true,
|
|
387
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Capture stderr for diagnostics — Chrome prints errors here
|
|
391
|
+
let stderr = "";
|
|
392
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
393
|
+
stderr += chunk.toString();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
child.on("error", (err) => {
|
|
397
|
+
log.error(`Chrome spawn error: ${err.message}`);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
child.on("exit", (code, signal) => {
|
|
401
|
+
if (code !== null && code !== 0) {
|
|
402
|
+
log.debug(`Chrome exited with code ${code}${signal ? ` (signal: ${signal})` : ""}`);
|
|
403
|
+
if (stderr) {
|
|
404
|
+
// Log first few lines of stderr for diagnostics
|
|
405
|
+
const lines = stderr.split("\n").filter(Boolean).slice(0, 5);
|
|
406
|
+
for (const line of lines) {
|
|
407
|
+
log.debug(` chrome stderr: ${line}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
child.unref();
|
|
414
|
+
return child;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── CDP Readiness ───────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Wait for CDP to become reachable.
|
|
421
|
+
*/
|
|
422
|
+
async function waitForCDP(browser: BrowserController, timeoutMs = 30000): Promise<boolean> {
|
|
423
|
+
const start = Date.now();
|
|
424
|
+
let attempts = 0;
|
|
425
|
+
while (Date.now() - start < timeoutMs) {
|
|
426
|
+
attempts++;
|
|
427
|
+
if (await browser.isAvailable()) {
|
|
428
|
+
log.debug(`CDP became reachable after ${attempts} attempts (${Date.now() - start}ms)`);
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
432
|
+
}
|
|
433
|
+
log.debug(`CDP not reachable after ${attempts} attempts (${timeoutMs}ms timeout)`);
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Check if a port is already in use by another process (not Chrome CDP).
|
|
439
|
+
*/
|
|
440
|
+
async function isPortInUse(port: number): Promise<boolean> {
|
|
441
|
+
try {
|
|
442
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
443
|
+
signal: AbortSignal.timeout(1000),
|
|
444
|
+
});
|
|
445
|
+
// CDP /json/version returns a JSON object with "Browser" and "webSocketDebuggerUrl" keys.
|
|
446
|
+
// All Chromium-based browsers (Chrome, Edge, Brave) include these.
|
|
447
|
+
const body = await res.text();
|
|
448
|
+
return !body.includes("webSocketDebuggerUrl");
|
|
449
|
+
} catch {
|
|
450
|
+
// Connection refused → port is free
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Ensure a Chromium browser is running with CDP enabled.
|
|
459
|
+
*
|
|
460
|
+
* Uses a SEPARATE debug profile (~/.assistme/browser-profile) so that:
|
|
461
|
+
* - The user's regular Chrome can stay open — no killing required
|
|
462
|
+
* - Chrome 136+ enables --remote-debugging-port (requires non-default dir)
|
|
463
|
+
* - The debug browser has its own singleton — no conflicts
|
|
464
|
+
*
|
|
465
|
+
* Flow:
|
|
466
|
+
* 1. CDP already reachable on the port → return immediately.
|
|
467
|
+
* 2. Port occupied by a non-Chromium process → report conflict.
|
|
468
|
+
* 3. Launch a new browser instance with the debug profile + CDP flag.
|
|
469
|
+
*/
|
|
470
|
+
export async function ensureBrowserAvailable(port = 9222): Promise<AutoLaunchResult> {
|
|
471
|
+
const browser = getBrowser(port);
|
|
472
|
+
|
|
473
|
+
// Case 1: CDP already reachable
|
|
474
|
+
if (await browser.isAvailable()) {
|
|
475
|
+
log.debug("CDP already reachable — no launch needed");
|
|
476
|
+
return { success: true, action: "already_available" };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Case 2: Port occupied by something else
|
|
480
|
+
if (await isPortInUse(port)) {
|
|
481
|
+
log.debug(`Port ${port} is in use by a non-Chrome process`);
|
|
482
|
+
return {
|
|
483
|
+
success: false,
|
|
484
|
+
action: "port_conflict",
|
|
485
|
+
detail: `Port ${port} is already in use by another process. Try a different port or stop the conflicting process.`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Find Chrome binary
|
|
490
|
+
const chromePath = findChromePath();
|
|
491
|
+
if (!chromePath) {
|
|
492
|
+
log.debug("Chrome binary not found on this system");
|
|
493
|
+
return { success: false, action: "chrome_not_found" };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
log.debug(`Found Chrome at: ${chromePath}`);
|
|
497
|
+
|
|
498
|
+
// Launch a debug Chrome instance (separate profile — no need to kill the user's Chrome)
|
|
499
|
+
spawnChrome(chromePath, port);
|
|
500
|
+
|
|
501
|
+
if (await waitForCDP(browser)) {
|
|
502
|
+
return { success: true, action: "launched", chromePath };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// CDP didn't come up — check if the debug profile is locked by a previous
|
|
506
|
+
// crashed assistme session (stale SingletonLock)
|
|
507
|
+
const debugDir = getDebugProfileDir(chromePath);
|
|
508
|
+
const lockPath = join(debugDir, "SingletonLock");
|
|
509
|
+
if (existsSync(lockPath)) {
|
|
510
|
+
log.debug("Found stale SingletonLock in debug profile — removing and retrying");
|
|
511
|
+
try {
|
|
512
|
+
unlinkSync(lockPath);
|
|
513
|
+
// Also clean SingletonSocket/Cookie
|
|
514
|
+
for (const f of ["SingletonSocket", "SingletonCookie"]) {
|
|
515
|
+
try {
|
|
516
|
+
unlinkSync(join(debugDir, f));
|
|
517
|
+
} catch {
|
|
518
|
+
/* ok */
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
} catch {
|
|
522
|
+
/* best effort */
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Retry spawn
|
|
526
|
+
spawnChrome(chromePath, port);
|
|
527
|
+
if (await waitForCDP(browser, 15000)) {
|
|
528
|
+
return { success: true, action: "launched", chromePath };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
success: false,
|
|
534
|
+
action: "launch_failed",
|
|
535
|
+
chromePath,
|
|
536
|
+
detail:
|
|
537
|
+
"Could not start browser with remote debugging. Possible causes:\n" +
|
|
538
|
+
" 1) Another assistme debug browser is already using port " +
|
|
539
|
+
port +
|
|
540
|
+
"\n" +
|
|
541
|
+
" 2) The browser crashed on startup\n" +
|
|
542
|
+
"Try: rm -rf ~/.assistme/browser-profile && assistme",
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── Singleton ───────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
let browserInstance: BrowserController | null = null;
|
|
549
|
+
|
|
550
|
+
export function getBrowser(port = 9222): BrowserController {
|
|
551
|
+
if (!browserInstance) {
|
|
552
|
+
browserInstance = new BrowserController(port);
|
|
553
|
+
}
|
|
554
|
+
return browserInstance;
|
|
555
|
+
}
|