@kevin5251984/guild 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/bin/guildd.mjs +20 -0
  3. package/cordis.yml +24 -0
  4. package/package.json +52 -0
  5. package/src/agent-file.ts +125 -0
  6. package/src/browser.ts +668 -0
  7. package/src/catalog/default-bots.ts +263 -0
  8. package/src/catalog/skills.ts +128 -0
  9. package/src/catalog/subagents.ts +70 -0
  10. package/src/chat-parts.ts +71 -0
  11. package/src/cli-args.ts +75 -0
  12. package/src/cli.ts +60 -0
  13. package/src/compact.ts +355 -0
  14. package/src/cordis.d.ts +40 -0
  15. package/src/db.ts +653 -0
  16. package/src/generate.ts +673 -0
  17. package/src/handlers.ts +1623 -0
  18. package/src/harness.ts +326 -0
  19. package/src/host-agents.ts +137 -0
  20. package/src/host-browse.ts +199 -0
  21. package/src/host-skills.ts +150 -0
  22. package/src/image-gen.ts +270 -0
  23. package/src/index.ts +12 -0
  24. package/src/llm.ts +993 -0
  25. package/src/mcp.ts +563 -0
  26. package/src/memory.ts +159 -0
  27. package/src/mention.ts +176 -0
  28. package/src/oauth.ts +1474 -0
  29. package/src/plugins/api.ts +8 -0
  30. package/src/plugins/chat.ts +31 -0
  31. package/src/plugins/harness.ts +77 -0
  32. package/src/plugins/llm.ts +50 -0
  33. package/src/plugins/mcp.ts +58 -0
  34. package/src/plugins/memory.ts +42 -0
  35. package/src/plugins/oauth.ts +47 -0
  36. package/src/plugins/server.ts +126 -0
  37. package/src/plugins/store.ts +29 -0
  38. package/src/plugins/tools.ts +79 -0
  39. package/src/public/buddy.js +432 -0
  40. package/src/public/chat.css +3045 -0
  41. package/src/public/chat.html +5834 -0
  42. package/src/public/favicon-16.png +0 -0
  43. package/src/public/favicon-16.svg +10 -0
  44. package/src/public/favicon-32.png +0 -0
  45. package/src/public/favicon.ico +0 -0
  46. package/src/public/favicon.svg +13 -0
  47. package/src/public/i18n.js +663 -0
  48. package/src/public/index.html +143 -0
  49. package/src/public/library.html +678 -0
  50. package/src/public/mcp-add.html +126 -0
  51. package/src/public/md.js +332 -0
  52. package/src/public/rpg/inn-street.jpg +0 -0
  53. package/src/public/settings.html +795 -0
  54. package/src/public/skills-add.html +212 -0
  55. package/src/public/studio.html +1181 -0
  56. package/src/public/style.css +1678 -0
  57. package/src/public/subagents-add.html +152 -0
  58. package/src/router.ts +978 -0
  59. package/src/send-budget.ts +52 -0
  60. package/src/server.ts +1 -0
  61. package/src/skill-import.ts +250 -0
  62. package/src/slash.ts +15 -0
  63. package/src/start.ts +103 -0
  64. package/src/store.ts +1208 -0
  65. package/src/subagent.ts +355 -0
  66. package/src/tools.ts +818 -0
  67. package/src/trajectory.ts +339 -0
  68. package/src/usage.ts +111 -0
  69. package/vendor/protocol/package.json +19 -0
  70. package/vendor/protocol/src/index.ts +159 -0
package/src/browser.ts ADDED
@@ -0,0 +1,668 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ cpSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { createServer } from "node:net";
15
+ import { homedir } from "node:os";
16
+ import { basename, dirname, join } from "node:path";
17
+ import { randomUUID } from "node:crypto";
18
+ import { DatabaseSync } from "node:sqlite";
19
+ import { generatedDir, generatedPublicPath } from "./image-gen.ts";
20
+ import { defaultDataDir } from "./store.ts";
21
+ import type { ToolOutcome } from "./tools.ts";
22
+
23
+ /**
24
+ * Real-profile browsing follows Hermes (nousresearch/hermes-agent
25
+ * hermes_cli/browser_connect.py): never CDP the live profile (Chrome 136+).
26
+ * Snapshot last_used auth into ~/.guild/browser-profile/chrome, drive the copy.
27
+ * On by default (GUILD_BROWSER_REAL_PROFILE=1). Set 0 for throwaway; turning off deletes the snapshot.
28
+ */
29
+ export const SNAPSHOT_DONE_MARKER = ".guild-snapshot-complete";
30
+
31
+ const AUTH_REFRESH = [
32
+ "Cookies",
33
+ "Network/Cookies",
34
+ "Login Data",
35
+ "Login Data For Account",
36
+ "Web Data",
37
+ "Preferences",
38
+ ];
39
+
40
+ const SQLITE_AUTH_DBS = new Set([
41
+ "Cookies",
42
+ "Login Data",
43
+ "Login Data For Account",
44
+ "Web Data",
45
+ ]);
46
+
47
+ const EXACT_TREE_IGNORE = new Set([
48
+ "Extensions",
49
+ "Local Extension Settings",
50
+ "Service Worker",
51
+ "IndexedDB",
52
+ "Crash Reports",
53
+ "Crashpad",
54
+ "Snapshots",
55
+ "optimization_guide_model_store",
56
+ "Safe Browsing",
57
+ "SafetyTips",
58
+ "OnDeviceHeadSuggestModel",
59
+ "segmentation_platform",
60
+ "Sync Data",
61
+ "Shared Dictionary",
62
+ "RunningChromeVersion",
63
+ "SingletonSocket",
64
+ "BrowserMetrics-spare.pma",
65
+ ...SQLITE_AUTH_DBS,
66
+ ]);
67
+
68
+ export type BrowserAction =
69
+ | "open"
70
+ | "snapshot"
71
+ | "click"
72
+ | "type"
73
+ | "press"
74
+ | "screenshot"
75
+ | "close";
76
+
77
+ type Session = {
78
+ proc: ChildProcess;
79
+ port: number;
80
+ userDataDir: string;
81
+ real: boolean;
82
+ ws: WebSocket;
83
+ seq: number;
84
+ pending: Map<number, { resolve: (value: unknown) => void; reject: (err: Error) => void }>;
85
+ };
86
+
87
+ let session: Session | null = null;
88
+
89
+ export function realProfileEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
90
+ const raw = (env.GUILD_BROWSER_REAL_PROFILE ?? "1").trim().toLowerCase();
91
+ if (!raw) return true;
92
+ return raw !== "0" && raw !== "false" && raw !== "no" && raw !== "off";
93
+ }
94
+
95
+ export function chromeUserDataDir(home = homedir(), platform = process.platform): string {
96
+ if (platform === "darwin") {
97
+ return join(home, "Library", "Application Support", "Google", "Chrome");
98
+ }
99
+ if (platform === "win32") {
100
+ const local = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
101
+ return join(local, "Google", "Chrome", "User Data");
102
+ }
103
+ return join(home, ".config", "google-chrome");
104
+ }
105
+
106
+ export function lastUsedProfile(userDataDir: string): string {
107
+ let name = "Default";
108
+ const path = join(userDataDir, "Local State");
109
+ if (existsSync(path)) {
110
+ try {
111
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as {
112
+ profile?: { last_used?: string };
113
+ };
114
+ const raw = parsed.profile?.last_used;
115
+ if (typeof raw === "string" && raw.trim()) name = raw.trim();
116
+ } catch {
117
+ name = "Default";
118
+ }
119
+ }
120
+ return isDir(join(userDataDir, name)) ? name : "Default";
121
+ }
122
+
123
+ export function snapshotDir(dataDir: string): string {
124
+ return join(dataDir, "browser-profile", "chrome");
125
+ }
126
+
127
+ export function ephemeralDir(dataDir: string): string {
128
+ return join(dataDir, "browser-ephemeral");
129
+ }
130
+
131
+ export function cleanupRealProfileSnapshots(dataDir: string): void {
132
+ const root = join(dataDir, "browser-profile");
133
+ if (existsSync(root)) rmSync(root, { recursive: true, force: true });
134
+ }
135
+
136
+ function isDir(path: string): boolean {
137
+ try {
138
+ return statSync(path).isDirectory();
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ function isFile(path: string): boolean {
145
+ try {
146
+ return statSync(path).isFile();
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ function snapshotIgnore(name: string): boolean {
153
+ if (EXACT_TREE_IGNORE.has(name)) return true;
154
+ if (name.includes("Cache")) return true;
155
+ if (name.startsWith("Extension")) return true;
156
+ if (name.startsWith("BrowserMetrics")) return true;
157
+ if (name.startsWith("OptimizationGuide")) return true;
158
+ if (name.startsWith("History")) return true;
159
+ if (name.startsWith("Favicons")) return true;
160
+ if (name.startsWith("Singleton")) return true;
161
+ return (
162
+ name.endsWith(".tmp") ||
163
+ name.endsWith("-journal") ||
164
+ name.endsWith("-wal") ||
165
+ name.endsWith("-shm")
166
+ );
167
+ }
168
+
169
+ function stripSqliteSidecars(file: string): void {
170
+ for (const suffix of ["-journal", "-wal", "-shm"]) {
171
+ rmSync(`${file}${suffix}`, { force: true });
172
+ }
173
+ }
174
+
175
+ function secureSnapshotRoot(path: string): void {
176
+ try {
177
+ mkdirSync(path, { recursive: true });
178
+ chmodSync(path, 0o700);
179
+ } catch {
180
+ /* Windows / best-effort */
181
+ }
182
+ }
183
+
184
+ function sqlPath(path: string): string {
185
+ return `'${path.replaceAll("'", "''")}'`;
186
+ }
187
+
188
+ export function copyAuthFile(srcFile: string, destFile: string): boolean {
189
+ mkdirSync(dirname(destFile), { recursive: true });
190
+ if (SQLITE_AUTH_DBS.has(basename(srcFile))) {
191
+ try {
192
+ if (existsSync(destFile)) rmSync(destFile, { force: true });
193
+ const source = new DatabaseSync(srcFile, { readOnly: true, timeout: 5000 });
194
+ try {
195
+ source.exec(`VACUUM INTO ${sqlPath(destFile)}`);
196
+ } finally {
197
+ source.close();
198
+ }
199
+ stripSqliteSidecars(destFile);
200
+ return true;
201
+ } catch {
202
+ /* raw copy — text fixtures and non-DB files */
203
+ }
204
+ }
205
+ try {
206
+ cpSync(srcFile, destFile, { force: true });
207
+ return true;
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+
213
+ /** Overlay last_used auth into the copy's Default. Returns how many SQLite DBs failed. */
214
+ export function copyAuthProfile(srcProfile: string, destProfile: string): number {
215
+ mkdirSync(destProfile, { recursive: true });
216
+ let failedDbs = 0;
217
+ for (const rel of AUTH_REFRESH) {
218
+ const from = join(srcProfile, ...rel.split("/"));
219
+ if (!isFile(from)) continue;
220
+ const ok = copyAuthFile(from, join(destProfile, ...rel.split("/")));
221
+ if (!ok && SQLITE_AUTH_DBS.has(basename(from))) failedDbs += 1;
222
+ }
223
+ return failedDbs;
224
+ }
225
+
226
+ function copyProfileTree(srcProfile: string, destProfile: string): void {
227
+ mkdirSync(destProfile, { recursive: true });
228
+ cpSync(srcProfile, destProfile, {
229
+ recursive: true,
230
+ force: true,
231
+ filter: (from) => from === srcProfile || !snapshotIgnore(basename(from)),
232
+ });
233
+ }
234
+
235
+ function cookieDb(src: string, sourceProfile: string): string | null {
236
+ for (const rel of ["Network/Cookies", "Cookies"]) {
237
+ const candidate = join(src, sourceProfile, ...rel.split("/"));
238
+ if (isFile(candidate)) return candidate;
239
+ }
240
+ return null;
241
+ }
242
+
243
+ export function profileIsLocked(src: string, sourceProfile: string): boolean {
244
+ const db = cookieDb(src, sourceProfile);
245
+ if (!db) return false;
246
+ try {
247
+ const fd = openSync(db, "r");
248
+ closeSync(fd);
249
+ return false;
250
+ } catch (error) {
251
+ const code =
252
+ error && typeof error === "object" && "code" in error
253
+ ? String((error as { code: unknown }).code)
254
+ : "";
255
+ return code === "EPERM" || code === "EACCES";
256
+ }
257
+ }
258
+
259
+ function pinLocalStateDefault(root: string, srcLocalState: string): void {
260
+ let parsed: Record<string, unknown> = {};
261
+ if (isFile(srcLocalState)) {
262
+ try {
263
+ parsed = JSON.parse(readFileSync(srcLocalState, "utf8")) as Record<
264
+ string,
265
+ unknown
266
+ >;
267
+ } catch {
268
+ parsed = {};
269
+ }
270
+ }
271
+ const profile =
272
+ parsed.profile && typeof parsed.profile === "object"
273
+ ? (parsed.profile as Record<string, unknown>)
274
+ : {};
275
+ profile.last_used = "Default";
276
+ parsed.profile = profile;
277
+ writeFileSync(join(root, "Local State"), `${JSON.stringify(parsed)}\n`);
278
+ }
279
+
280
+ export function syncRealProfile(
281
+ dataDir: string,
282
+ home = homedir(),
283
+ platform = process.platform,
284
+ ): string {
285
+ const userData = chromeUserDataDir(home, platform);
286
+ if (!isDir(userData)) {
287
+ throw new Error(`no Chrome user-data dir at ${userData}`);
288
+ }
289
+ const leaf = lastUsedProfile(userData);
290
+ const srcProfile = join(userData, leaf);
291
+ if (!isDir(srcProfile)) {
292
+ throw new Error(`Chrome profile "${leaf}" not found under ${userData}`);
293
+ }
294
+ if (profileIsLocked(userData, leaf)) {
295
+ throw new Error(
296
+ "Chrome is running and has its profile locked, so login data can't be copied. Fully quit the browser (including any background/tray instance) and retry, or set GUILD_BROWSER_REAL_PROFILE=0.",
297
+ );
298
+ }
299
+ const root = snapshotDir(dataDir);
300
+ const dest = join(root, "Default");
301
+ const parent = dirname(root);
302
+ mkdirSync(root, { recursive: true });
303
+ secureSnapshotRoot(parent);
304
+ secureSnapshotRoot(root);
305
+ pinLocalStateDefault(root, join(userData, "Local State"));
306
+ const marker = join(root, SNAPSHOT_DONE_MARKER);
307
+ const populated = isFile(marker);
308
+ if (!populated) {
309
+ rmSync(dest, { recursive: true, force: true });
310
+ try {
311
+ copyProfileTree(srcProfile, dest);
312
+ } catch {
313
+ /* per-file skip is non-fatal; auth overlay is the source of truth */
314
+ }
315
+ }
316
+ const failedDbs = copyAuthProfile(srcProfile, dest);
317
+ if (failedDbs) {
318
+ throw new Error(
319
+ `could not read the Chrome profile's login data (${failedDbs} database(s) locked). Close Chrome and retry, or set GUILD_BROWSER_REAL_PROFILE=0.`,
320
+ );
321
+ }
322
+ for (const leftover of ["SingletonLock", "SingletonSocket", "SingletonCookie"]) {
323
+ rmSync(join(root, leftover), { force: true });
324
+ }
325
+ writeFileSync(marker, `${leaf}\n`);
326
+ return root;
327
+ }
328
+
329
+ export function chromeBinary(platform = process.platform): string | null {
330
+ if (platform === "darwin") {
331
+ const candidates = [
332
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
333
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
334
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
335
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
336
+ ];
337
+ return candidates.find((path) => existsSync(path)) ?? null;
338
+ }
339
+ if (platform === "win32") {
340
+ const local = process.env.LOCALAPPDATA || "";
341
+ const candidates = [
342
+ join(local, "Google", "Chrome", "Application", "chrome.exe"),
343
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
344
+ ];
345
+ return candidates.find((path) => existsSync(path)) ?? null;
346
+ }
347
+ const candidates = ["google-chrome", "chromium", "chromium-browser", "brave-browser"];
348
+ for (const name of candidates) {
349
+ const path = `/usr/bin/${name}`;
350
+ if (existsSync(path)) return path;
351
+ }
352
+ return null;
353
+ }
354
+
355
+ async function freePort(): Promise<number> {
356
+ const probe = createServer();
357
+ await new Promise<void>((resolve, reject) => {
358
+ probe.once("error", reject);
359
+ probe.listen(0, "127.0.0.1", () => resolve());
360
+ });
361
+ const address = probe.address();
362
+ const port =
363
+ address && typeof address === "object" ? address.port : 18742;
364
+ await new Promise<void>((resolve) => probe.close(() => resolve()));
365
+ return port;
366
+ }
367
+
368
+ async function waitJson(url: string, ms = 20_000): Promise<unknown> {
369
+ const deadline = Date.now() + ms;
370
+ let last = "";
371
+ while (Date.now() < deadline) {
372
+ try {
373
+ const res = await fetch(url, { signal: AbortSignal.timeout(800) });
374
+ if (res.ok) return await res.json();
375
+ last = `HTTP ${res.status}`;
376
+ } catch (error) {
377
+ last = error instanceof Error ? error.message : String(error);
378
+ }
379
+ await new Promise((resolve) => setTimeout(resolve, 200));
380
+ }
381
+ throw new Error(`Chrome DevTools did not come up: ${last}`);
382
+ }
383
+
384
+ function sendCdp(sess: Session, method: string, params?: Record<string, unknown>): Promise<unknown> {
385
+ const id = ++sess.seq;
386
+ return new Promise((resolve, reject) => {
387
+ const timer = setTimeout(() => {
388
+ sess.pending.delete(id);
389
+ reject(new Error(`CDP timeout: ${method}`));
390
+ }, 25_000);
391
+ sess.pending.set(id, {
392
+ resolve: (value) => {
393
+ clearTimeout(timer);
394
+ resolve(value);
395
+ },
396
+ reject: (err) => {
397
+ clearTimeout(timer);
398
+ reject(err);
399
+ },
400
+ });
401
+ sess.ws.send(JSON.stringify({ id, method, params }));
402
+ });
403
+ }
404
+
405
+ async function attachPage(sess: Session): Promise<void> {
406
+ const list = (await waitJson(`http://127.0.0.1:${sess.port}/json/list`, 8_000)) as {
407
+ type?: string;
408
+ webSocketDebuggerUrl?: string;
409
+ }[];
410
+ const page = (Array.isArray(list) ? list : []).find(
411
+ (item) => item.type === "page" && item.webSocketDebuggerUrl,
412
+ );
413
+ const url = page?.webSocketDebuggerUrl;
414
+ if (!url) throw new Error("Chrome has no page target for CDP");
415
+ if (sess.ws && sess.ws.readyState === WebSocket.OPEN) {
416
+ try {
417
+ sess.ws.close();
418
+ } catch {
419
+ /* ignore */
420
+ }
421
+ }
422
+ await openWs(sess, url);
423
+ await sendCdp(sess, "Page.enable");
424
+ await sendCdp(sess, "Runtime.enable");
425
+ }
426
+
427
+ function openWs(sess: Session, url: string): Promise<void> {
428
+ return new Promise((resolve, reject) => {
429
+ const ws = new WebSocket(url);
430
+ sess.ws = ws;
431
+ ws.addEventListener("message", (event) => {
432
+ try {
433
+ const msg = JSON.parse(String(event.data)) as {
434
+ id?: number;
435
+ error?: { message?: string };
436
+ result?: unknown;
437
+ };
438
+ if (typeof msg.id !== "number") return;
439
+ const wait = sess.pending.get(msg.id);
440
+ if (!wait) return;
441
+ sess.pending.delete(msg.id);
442
+ if (msg.error) wait.reject(new Error(msg.error.message || "CDP error"));
443
+ else wait.resolve(msg.result);
444
+ } catch {
445
+ /* ignore */
446
+ }
447
+ });
448
+ ws.addEventListener("open", () => resolve());
449
+ ws.addEventListener("error", () => reject(new Error("CDP websocket failed")));
450
+ });
451
+ }
452
+
453
+ async function launchChrome(dataDir: string, env: NodeJS.ProcessEnv): Promise<Session> {
454
+ const bin = chromeBinary();
455
+ if (!bin) throw new Error("Chrome / Chromium / Edge / Brave not found");
456
+ const real = realProfileEnabled(env);
457
+ if (!real) cleanupRealProfileSnapshots(dataDir);
458
+ const userDataDir = real ? syncRealProfile(dataDir) : ephemeralDir(dataDir);
459
+ mkdirSync(userDataDir, { recursive: true });
460
+ const port = await freePort();
461
+ const args = [
462
+ `--remote-debugging-port=${port}`,
463
+ `--remote-debugging-address=127.0.0.1`,
464
+ `--user-data-dir=${userDataDir}`,
465
+ "--no-first-run",
466
+ "--no-default-browser-check",
467
+ "--disable-sync",
468
+ ];
469
+ if (real) args.push("--profile-directory=Default");
470
+ const proc = spawn(bin, args, {
471
+ stdio: "ignore",
472
+ detached: false,
473
+ });
474
+ const sess: Session = {
475
+ proc,
476
+ port,
477
+ userDataDir,
478
+ real,
479
+ ws: null as unknown as WebSocket,
480
+ seq: 0,
481
+ pending: new Map(),
482
+ };
483
+ try {
484
+ await waitJson(`http://127.0.0.1:${port}/json/version`, 20_000);
485
+ await attachPage(sess);
486
+ } catch (error) {
487
+ proc.kill("SIGTERM");
488
+ throw error;
489
+ }
490
+ session = sess;
491
+ return sess;
492
+ }
493
+
494
+ export async function closeBrowser(): Promise<void> {
495
+ const sess = session;
496
+ session = null;
497
+ if (!sess) return;
498
+ try {
499
+ sess.ws.close();
500
+ } catch {
501
+ /* ignore */
502
+ }
503
+ try {
504
+ sess.proc.kill("SIGTERM");
505
+ } catch {
506
+ /* ignore */
507
+ }
508
+ }
509
+
510
+ async function ensureSession(dataDir: string, env: NodeJS.ProcessEnv): Promise<Session> {
511
+ const wantReal = realProfileEnabled(env);
512
+ if (session && session.real === wantReal && session.proc.exitCode === null) {
513
+ return session;
514
+ }
515
+ await closeBrowser();
516
+ return launchChrome(dataDir, env);
517
+ }
518
+
519
+ type AxNode = {
520
+ ref: string;
521
+ tag: string;
522
+ role?: string;
523
+ name: string;
524
+ href?: string;
525
+ };
526
+
527
+ const SNAPSHOT_JS = `(() => {
528
+ const els = [...document.querySelectorAll("a, button, input, textarea, select, [role='button'], [role='link'], [contenteditable='true']")];
529
+ return els.slice(0, 80).map((el, i) => {
530
+ const ref = "e" + (i + 1);
531
+ el.setAttribute("data-guild-ref", ref);
532
+ const name = (el.getAttribute("aria-label") || el.innerText || el.value || el.getAttribute("placeholder") || "").replace(/\\s+/g, " ").trim().slice(0, 80);
533
+ return {
534
+ ref: "@" + ref,
535
+ tag: el.tagName.toLowerCase(),
536
+ role: el.getAttribute("role") || undefined,
537
+ name,
538
+ href: el.href || undefined,
539
+ };
540
+ });
541
+ })()`;
542
+
543
+ async function evaluate<T>(sess: Session, expression: string): Promise<T> {
544
+ const result = (await sendCdp(sess, "Runtime.evaluate", {
545
+ expression,
546
+ returnByValue: true,
547
+ awaitPromise: true,
548
+ })) as { result?: { value?: T; description?: string }; exceptionDetails?: { text?: string } };
549
+ if (result.exceptionDetails) {
550
+ throw new Error(result.exceptionDetails.text || "page JS error");
551
+ }
552
+ return result.result?.value as T;
553
+ }
554
+
555
+ async function snapshotText(sess: Session): Promise<string> {
556
+ const url = await evaluate<string>(sess, "location.href");
557
+ const title = await evaluate<string>(sess, "document.title");
558
+ const nodes = (await evaluate<AxNode[]>(sess, SNAPSHOT_JS)) || [];
559
+ const lines = nodes.map((node) => {
560
+ const extra = node.href ? ` ${node.href}` : "";
561
+ return `${node.ref} <${node.tag}> ${node.name}${extra}`.trim();
562
+ });
563
+ return [`${title} — ${url}`, ...lines].join("\n") || "(empty page)";
564
+ }
565
+
566
+ function parseRef(raw: string): string {
567
+ return raw.trim().replace(/^@/, "");
568
+ }
569
+
570
+ export async function runBrowser(
571
+ args: Record<string, unknown>,
572
+ input: {
573
+ dataDir?: string;
574
+ env?: NodeJS.ProcessEnv;
575
+ signal?: AbortSignal;
576
+ } = {},
577
+ ): Promise<ToolOutcome> {
578
+ if (input.signal?.aborted) {
579
+ const err = new Error("aborted");
580
+ err.name = "AbortError";
581
+ throw err;
582
+ }
583
+ const env = input.env ?? process.env;
584
+ const dataDir = input.dataDir ?? defaultDataDir(env);
585
+ const action = String(args.action || args.command || "snapshot").trim().toLowerCase() as BrowserAction;
586
+ const onAbort = () => {
587
+ closeBrowser().catch(() => {});
588
+ };
589
+ input.signal?.addEventListener("abort", onAbort, { once: true });
590
+ try {
591
+ if (action === "close") {
592
+ await closeBrowser();
593
+ return { text: "browser closed" };
594
+ }
595
+ const sess = await ensureSession(dataDir, env);
596
+ const mode = sess.real ? "real-profile" : "ephemeral";
597
+ if (action === "open" || action === "navigate") {
598
+ const url = String(args.url || "").trim();
599
+ if (!url) return { text: "browser open needs a url", isError: true };
600
+ await sendCdp(sess, "Page.navigate", { url });
601
+ await new Promise((resolve) => setTimeout(resolve, 1200));
602
+ try {
603
+ await attachPage(sess);
604
+ } catch {
605
+ /* keep existing ws */
606
+ }
607
+ const snap = await snapshotText(sess);
608
+ return { text: `[${mode}]\n${snap}` };
609
+ }
610
+ if (action === "snapshot") {
611
+ const snap = await snapshotText(sess);
612
+ return { text: `[${mode}]\n${snap}` };
613
+ }
614
+ if (action === "click") {
615
+ const ref = parseRef(String(args.ref || ""));
616
+ if (!ref) return { text: "browser click needs ref like @e1", isError: true };
617
+ await evaluate(sess, `document.querySelector('[data-guild-ref="${ref}"]')?.click()`);
618
+ await new Promise((resolve) => setTimeout(resolve, 400));
619
+ const snap = await snapshotText(sess);
620
+ return { text: `[${mode}] clicked @${ref}\n${snap}` };
621
+ }
622
+ if (action === "type") {
623
+ const ref = parseRef(String(args.ref || ""));
624
+ const text = String(args.text ?? "");
625
+ if (!ref) return { text: "browser type needs ref like @e1", isError: true };
626
+ const js = `(() => {
627
+ const el = document.querySelector('[data-guild-ref="${ref}"]');
628
+ if (!el) return "missing";
629
+ el.focus();
630
+ if ("value" in el) el.value = ${JSON.stringify(text)};
631
+ el.dispatchEvent(new Event("input", { bubbles: true }));
632
+ return "ok";
633
+ })()`;
634
+ const status = await evaluate<string>(sess, js);
635
+ if (status === "missing") return { text: `no element @${ref}`, isError: true };
636
+ return { text: `[${mode}] typed into @${ref}` };
637
+ }
638
+ if (action === "press") {
639
+ const key = String(args.text || args.key || "Enter");
640
+ await sendCdp(sess, "Input.dispatchKeyEvent", { type: "keyDown", key });
641
+ await sendCdp(sess, "Input.dispatchKeyEvent", { type: "keyUp", key });
642
+ return { text: `[${mode}] pressed ${key}` };
643
+ }
644
+ if (action === "screenshot") {
645
+ const result = (await sendCdp(sess, "Page.captureScreenshot", {
646
+ format: "png",
647
+ })) as { data?: string };
648
+ if (!result.data) return { text: "screenshot failed", isError: true };
649
+ const name = `${randomUUID()}.png`;
650
+ const dir = generatedDir(dataDir);
651
+ mkdirSync(dir, { recursive: true });
652
+ writeFileSync(join(dir, name), Buffer.from(result.data, "base64"));
653
+ const publicPath = generatedPublicPath(name);
654
+ return { text: `[${mode}] screenshot\n![page](${publicPath})` };
655
+ }
656
+ return { text: `unknown browser action: ${action}`, isError: true };
657
+ } catch (error) {
658
+ if (error instanceof Error && error.name === "AbortError") throw error;
659
+ const text = error instanceof Error ? error.message : String(error);
660
+ return { text, isError: true };
661
+ } finally {
662
+ input.signal?.removeEventListener("abort", onAbort);
663
+ }
664
+ }
665
+
666
+ export function resetBrowserForTests(): void {
667
+ session = null;
668
+ }