@te-river/opencode-team-mode 1.5.15 → 1.6.0

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 (97) hide show
  1. package/README.md +788 -690
  2. package/README.zh-CN.md +712 -631
  3. package/dist/agents.d.ts +5 -3
  4. package/dist/agents.d.ts.map +1 -1
  5. package/dist/agents.js +19 -8
  6. package/dist/agents.js.map +1 -1
  7. package/dist/capabilities.d.ts +1 -1
  8. package/dist/capabilities.js +2 -2
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/host-hooks.d.ts +13 -2
  11. package/dist/host-hooks.d.ts.map +1 -1
  12. package/dist/host-hooks.js +18 -5
  13. package/dist/host-hooks.js.map +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +54 -14
  16. package/dist/index.js.map +1 -1
  17. package/dist/prompts/lead.d.ts +1 -1
  18. package/dist/prompts/lead.d.ts.map +1 -1
  19. package/dist/prompts/lead.js +92 -25
  20. package/dist/prompts/lead.js.map +1 -1
  21. package/dist/prompts/shared.d.ts +2 -2
  22. package/dist/prompts/shared.d.ts.map +1 -1
  23. package/dist/prompts/shared.js +62 -10
  24. package/dist/prompts/shared.js.map +1 -1
  25. package/dist/prompts/specialists.d.ts +2 -2
  26. package/dist/prompts/specialists.d.ts.map +1 -1
  27. package/dist/prompts/specialists.js +43 -10
  28. package/dist/prompts/specialists.js.map +1 -1
  29. package/dist/task-offload.d.ts +67 -0
  30. package/dist/task-offload.d.ts.map +1 -0
  31. package/dist/task-offload.js +123 -0
  32. package/dist/task-offload.js.map +1 -0
  33. package/dist/tm/args-schema.d.ts +4 -0
  34. package/dist/tm/args-schema.d.ts.map +1 -1
  35. package/dist/tm/args-schema.js +36 -3
  36. package/dist/tm/args-schema.js.map +1 -1
  37. package/dist/tm/board.d.ts +113 -0
  38. package/dist/tm/board.d.ts.map +1 -0
  39. package/dist/tm/board.js +275 -0
  40. package/dist/tm/board.js.map +1 -0
  41. package/dist/tm/browser.d.ts +325 -6
  42. package/dist/tm/browser.d.ts.map +1 -1
  43. package/dist/tm/browser.js +1329 -135
  44. package/dist/tm/browser.js.map +1 -1
  45. package/dist/tm/config.d.ts +36 -25
  46. package/dist/tm/config.d.ts.map +1 -1
  47. package/dist/tm/config.js +29 -7
  48. package/dist/tm/config.js.map +1 -1
  49. package/dist/tm/dispatch.d.ts +86 -53
  50. package/dist/tm/dispatch.d.ts.map +1 -1
  51. package/dist/tm/dispatch.js +254 -251
  52. package/dist/tm/dispatch.js.map +1 -1
  53. package/dist/tm/dupe-guard.js +1 -1
  54. package/dist/tm/dupe-guard.js.map +1 -1
  55. package/dist/tm/egress.d.ts +44 -0
  56. package/dist/tm/egress.d.ts.map +1 -0
  57. package/dist/tm/egress.js +191 -0
  58. package/dist/tm/egress.js.map +1 -0
  59. package/dist/tm/guard.js +1 -1
  60. package/dist/tm/guard.js.map +1 -1
  61. package/dist/tm/index.d.ts +28 -2
  62. package/dist/tm/index.d.ts.map +1 -1
  63. package/dist/tm/index.js +198 -19
  64. package/dist/tm/index.js.map +1 -1
  65. package/dist/tm/perm-ask.d.ts +47 -5
  66. package/dist/tm/perm-ask.d.ts.map +1 -1
  67. package/dist/tm/perm-ask.js +98 -7
  68. package/dist/tm/perm-ask.js.map +1 -1
  69. package/dist/tm/pipelines.d.ts.map +1 -1
  70. package/dist/tm/pipelines.js +24 -2
  71. package/dist/tm/pipelines.js.map +1 -1
  72. package/dist/tm/ptc/tool.d.ts.map +1 -1
  73. package/dist/tm/ptc/tool.js +2 -4
  74. package/dist/tm/ptc/tool.js.map +1 -1
  75. package/dist/tm/pty.d.ts.map +1 -1
  76. package/dist/tm/pty.js +6 -3
  77. package/dist/tm/pty.js.map +1 -1
  78. package/dist/tm/search.d.ts +5 -0
  79. package/dist/tm/search.d.ts.map +1 -1
  80. package/dist/tm/search.js +19 -17
  81. package/dist/tm/search.js.map +1 -1
  82. package/dist/tm/stats.d.ts +16 -0
  83. package/dist/tm/stats.d.ts.map +1 -1
  84. package/dist/tm/stats.js +34 -4
  85. package/dist/tm/stats.js.map +1 -1
  86. package/dist/tm/webfetch.d.ts +52 -0
  87. package/dist/tm/webfetch.d.ts.map +1 -1
  88. package/dist/tm/webfetch.js +124 -23
  89. package/dist/tm/webfetch.js.map +1 -1
  90. package/dist/tool-coerce.d.ts +24 -0
  91. package/dist/tool-coerce.d.ts.map +1 -0
  92. package/dist/tool-coerce.js +63 -0
  93. package/dist/tool-coerce.js.map +1 -0
  94. package/dist/types.d.ts +7 -1
  95. package/dist/types.d.ts.map +1 -1
  96. package/docs/installation.md +69 -2
  97. package/package.json +61 -61
@@ -12,10 +12,10 @@
12
12
  * verbatim as the fallback. On cdp-legacy only the core subset works
13
13
  * (open/navigate/read/screenshot/close + navigate_page/take_screenshot/
14
14
  * list_pages/evaluate_script aliases); the snapshot-first actions
15
- * (take_snapshot + uid addressing, 16-verb chrome-devtools-mcp surface)
15
+ * (take_snapshot + uid addressing, 18-verb chrome-devtools-mcp surface)
16
16
  * require the playwright engine.
17
17
  *
18
- * Action surface aligns with chrome-devtools-mcp's 16 verbs (R2 mapping):
18
+ * Action surface aligns with chrome-devtools-mcp's 18 verbs (R2 mapping):
19
19
  * navigate_page · take_snapshot · click · fill · hover · drag ·
20
20
  * press_key · select_page · upload_file · wait_for · evaluate_script ·
21
21
  * list_console_messages · list_network_requests · list_pages ·
@@ -61,12 +61,12 @@
61
61
  * Role access mirrors tm_webfetch: network roles (team + researcher) only,
62
62
  * tester browser-only (UI verification).
63
63
  */
64
- import { execFileSync, spawn } from "node:child_process";
64
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
65
65
  import * as fs from "node:fs";
66
66
  import * as os from "node:os";
67
67
  import * as path from "node:path";
68
68
  import { checkWebUrl, seedWebfetchDomains } from "./webfetch.js";
69
- import { askUserForTarget } from "./perm-ask.js";
69
+ import { askGrantNote, askRefusalNote, askUserForTarget, askUserForTargetDetailed } from "./perm-ask.js";
70
70
  import { assertReadablePath } from "./guard.js";
71
71
  import { isEnvFilePath } from "../envprotect.js";
72
72
  import { tmError, toToolResult } from "./result.js";
@@ -291,6 +291,44 @@ export function findBrowserExecutable(env = process.env) {
291
291
  * auto (default) → headless ONLY when there is no
292
292
  * display possible (Linux without DISPLAY/WAYLAND_DISPLAY).
293
293
  */
294
+ /** Decide what profile directory TM_BROWSER_USER_DATA_DIR asks for.
295
+ *
296
+ * The tool description has claimed for a long time that "the real profile is
297
+ * never touched" while nothing enforced it: the string went straight into
298
+ * `launchPersistentContext`. Pointing it at the user's own browser data dir is
299
+ * not a neutral choice — with the browser open, the new process hands its URL
300
+ * to the running instance and exits (the confusing "本机已有同品牌浏览器在跑"
301
+ * failure), and with it closed the agent browses AS THE USER, with every cookie
302
+ * and saved session, while our orphan reaper force-kills (`taskkill /T /F`) any
303
+ * browser whose owner died — a real route to corrupting a profile.
304
+ *
305
+ * So: a real browser data dir is refused with the fix named, anything else is
306
+ * honored, and an unset value keeps the isolated temp profile (the default).
307
+ * Pure, so both engines and the tests share one answer. */
308
+ export function classifyProfileDir(env = process.env) {
309
+ const raw = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim();
310
+ if (!raw)
311
+ return { dir: null, refusal: null };
312
+ const norm = raw.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
313
+ const segs = norm.split("/").filter(Boolean);
314
+ const last = segs[segs.length - 1] ?? "";
315
+ const looksReal = last === "user data" ||
316
+ last === "google-chrome" ||
317
+ last === "chromium" ||
318
+ last === "profiles" ||
319
+ /(^|\/)(microsoft|google)\/(edge|chrome|chromium|chrome sxs)(\/(user data))?$/.test(norm) ||
320
+ /\/mozilla\/firefox\/(profiles|profile)$/.test(norm);
321
+ if (looksReal) {
322
+ return {
323
+ dir: null,
324
+ refusal: `TM_BROWSER_USER_DATA_DIR 指向的看起来是浏览器自己的数据目录(${raw}),拒绝用它启动。` +
325
+ `那样 agent 就是以你的真实身份上网,而孤儿回收是强杀进程,可能损坏这个目录。` +
326
+ `要保留登录态:把它改成一个专用空目录(例如 D:\\tm-browser-profile 或 ~/.tm-browser-profile),` +
327
+ `第一次让 agent 打开登录页、你人工登录一次,之后 cookie 就在这个目录里跨会话保留。`,
328
+ };
329
+ }
330
+ return { dir: raw, refusal: null };
331
+ }
294
332
  export function resolveHeadless(env = process.env) {
295
333
  const raw = String(env.TM_BROWSER_HEADLESS ?? "auto").trim().toLowerCase();
296
334
  if (["1", "true", "force", "yes"].includes(raw))
@@ -378,6 +416,102 @@ export function rememberSite(allowedSites, url) {
378
416
  /* unparseable — nothing to remember */
379
417
  }
380
418
  }
419
+ // ---------- a page that is not a page, but a wall ----------------------------
420
+ /**
421
+ * Human-verification walls. This exists because "0 个可寻址节点" and "this
422
+ * site wants a human to prove it is one" look identical to the agent, and the
423
+ * two have opposite next moves: an empty page is worth retrying, a wall never
424
+ * is — retrying just spends rounds on a captcha. Measured on baike.baidu.com
425
+ * item pages, where every arm (raw playwright included, all resources allowed)
426
+ * returns 0–6 nodes titled 百度安全验证, and the agent reported "该网站没有内容".
427
+ *
428
+ * Deliberately narrow: signatures we observed live, plus the unambiguous
429
+ * English ones. Callers consult it ONLY when the page came back thin, so an
430
+ * article that merely mentions 验证码 is never mislabelled as a wall.
431
+ */
432
+ export const CHALLENGE_WALL_SIGNS = [
433
+ { re: /百度安全验证|百度的安全验证/, label: "百度安全验证" },
434
+ { re: /安全验证|访问验证|请完成验证|滑动验证|拖动滑块|人机验证|点击按钮进行验证/, label: "站点安全验证" },
435
+ { re: /just a moment|checking before the link|cf-browser-verification/i, label: "Cloudflare 人机校验" },
436
+ { re: /verify you are a human|are you a robot|enable javascript and cookies/i, label: "人机校验" },
437
+ { re: /access denied|attention required|blocked because|request blocked/i, label: "访问被拒(反爬拦截)" },
438
+ ];
439
+ /** Which wall this thin page actually is, or null when it is just empty. */
440
+ export function challengeWallOf(...texts) {
441
+ const hay = texts.filter(Boolean).join("\n").slice(0, 4000);
442
+ if (!hay.trim())
443
+ return null;
444
+ for (const s of CHALLENGE_WALL_SIGNS)
445
+ if (s.re.test(hay))
446
+ return s.label;
447
+ return null;
448
+ }
449
+ /** One hostname for `allow_host`, or "" when the argument is not a single bare
450
+ * domain. Strict on purpose: this value becomes the pattern in an official
451
+ * consent dialog, and a user approving "bkssl.bdimg.com" must not be getting a
452
+ * wildcard, a URL with a path, or a port. */
453
+ export function normalizeApprovalHost(raw) {
454
+ const s = String(raw ?? "").trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/^\.+|\.+$/g, "");
455
+ if (!s || s.length > 253)
456
+ return "";
457
+ if (/[\s/@:*?#%\\]/.test(s))
458
+ return "";
459
+ if (!/^[a-z0-9一-鿿-]+(\.[a-z0-9一-鿿-]+)+$/.test(s))
460
+ return "";
461
+ return s;
462
+ }
463
+ /** The document title, or "" — a title is a diagnostic, never a reason for the
464
+ * action that was asked for to fail. */
465
+ async function readTitle(page) {
466
+ try {
467
+ return String((await page.title?.()) ?? "");
468
+ }
469
+ catch {
470
+ return "";
471
+ }
472
+ }
473
+ /**
474
+ * The launch pid, asked more than once.
475
+ *
476
+ * Why this exists: `childProcRows` folds EVERY OS-query failure (PowerShell
477
+ * exiting non-zero, the 6 s timeout killing it mid-flight, a JSON parse miss)
478
+ * into an empty list, and the launcher used to ask exactly once. Under load —
479
+ * this plugin's own parallel test runner reproduces it, four suites hammering
480
+ * CIM at once — one such query fails now and then, and a single failed query
481
+ * used to cost the session two things permanently: the orphan-ledger entry (so
482
+ * the next boot could not reclaim that browser) and `close`'s process
483
+ * verification. Worse, an exception escaping the lookup failed `open` itself,
484
+ * reporting a browser that had launched fine as an error.
485
+ *
486
+ * So: a failed or empty answer is retried a bounded number of times, and any
487
+ * throw is swallowed into the retry counter — the pid is diagnostic and
488
+ * reclamation metadata, never a reason to lose the session. When it still
489
+ * cannot be resolved, `via: "none"` says so and close keeps refusing the
490
+ * 已确认关闭 sentence (pinned by §25).
491
+ */
492
+ export const LAUNCH_PID_ATTEMPTS = 3;
493
+ export const LAUNCH_PID_RETRY_MS = 250;
494
+ export async function resolveLaunchPid(opts) {
495
+ const pwPid = Number(opts.playwrightPid?.() ?? 0) || 0;
496
+ if (pwPid > 0)
497
+ return { pid: pwPid, via: "playwright", failures: 0 };
498
+ const attempts = Math.max(1, Number(opts.attempts) || LAUNCH_PID_ATTEMPTS);
499
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
500
+ let failures = 0;
501
+ for (let i = 0; i < attempts; i++) {
502
+ if (i)
503
+ await sleep(LAUNCH_PID_RETRY_MS);
504
+ try {
505
+ const scan = launchedBrowserPid(opts.executablePath, { rows: opts.rows() });
506
+ if (scan > 0)
507
+ return { pid: scan, via: "child-scan", failures };
508
+ }
509
+ catch {
510
+ failures++;
511
+ }
512
+ }
513
+ return { pid: 0, via: "none", failures };
514
+ }
381
515
  // ---------- evaluate_script: consent + result redaction ---------------------
382
516
  /** `evaluate_script` is the one browser verb the network gate cannot cover:
383
517
  * the allowlist limits where we NAVIGATE, not what an already-loaded page
@@ -413,11 +547,375 @@ export function redactEvalResult(text) {
413
547
  }
414
548
  return { text: out, masked: kinds };
415
549
  }
550
+ export function browserLedgerFile(storeRoot) {
551
+ return path.join(storeRoot, "browsers.jsonl");
552
+ }
553
+ /** Best-effort append: a ledger that cannot be written must never cost the
554
+ * user their browser session. */
555
+ export function appendBrowserLedger(file, rec) {
556
+ try {
557
+ fs.mkdirSync(path.dirname(file), { recursive: true });
558
+ fs.appendFileSync(file, JSON.stringify(rec) + "\n", "utf8");
559
+ }
560
+ catch {
561
+ /* observability + reclamation only */
562
+ }
563
+ }
564
+ /** Read the ledger, terminate the orphans, and rewrite what is still live.
565
+ * Returns the counts so the caller can log them; never throws. */
566
+ export function reapOrphanBrowsers(file, opts = {}) {
567
+ const alive = opts.alive ?? pidAlive;
568
+ const kill = opts.kill ?? ((p) => killBrowserTree(p));
569
+ const identity = opts.identity ?? procIdentity;
570
+ const out = { reaped: [], kept: 0, dropped: 0 };
571
+ let raw;
572
+ try {
573
+ raw = fs.readFileSync(file, "utf8");
574
+ }
575
+ catch {
576
+ return out;
577
+ }
578
+ const survivors = [];
579
+ const seen = new Set();
580
+ for (const line of raw.split("\n")) {
581
+ if (!line.trim())
582
+ continue;
583
+ let e;
584
+ try {
585
+ e = JSON.parse(line);
586
+ }
587
+ catch {
588
+ continue; // a torn line is not an instruction to kill anything
589
+ }
590
+ const pid = Number(e?.pid);
591
+ const owner = Number(e?.ownerPid);
592
+ if (!Number.isFinite(pid) || pid <= 0 || seen.has(pid)) {
593
+ out.dropped++;
594
+ continue;
595
+ }
596
+ seen.add(pid);
597
+ if (!alive(pid)) {
598
+ out.dropped++; // browser exited on its own — the entry is history
599
+ continue;
600
+ }
601
+ if (owner > 0 && !alive(owner)) {
602
+ // An OS pid is a recyclable number, and the kill below is a FORCE kill of
603
+ // a whole process tree. So the entry has to still describe the
604
+ // executable we launched: if it does not, that pid belongs to an
605
+ // unrelated program now and killing it would be our bug, not our job.
606
+ const exe = String(e?.exe ?? "").trim();
607
+ if (exe && !identityMatchesExecutable(exe, identity(pid))) {
608
+ out.dropped++;
609
+ continue;
610
+ }
611
+ try {
612
+ kill(pid);
613
+ out.reaped.push(pid);
614
+ }
615
+ catch {
616
+ survivors.push(e); // not ours to signal (or already gone) — keep the record, say nothing false
617
+ }
618
+ continue;
619
+ }
620
+ survivors.push(e);
621
+ }
622
+ out.kept = survivors.length;
623
+ try {
624
+ if (survivors.length)
625
+ fs.writeFileSync(file, survivors.map((s) => JSON.stringify(s) + "\n").join(""), "utf8");
626
+ else
627
+ fs.rmSync(file, { force: true });
628
+ }
629
+ catch {
630
+ /* the next boot retries */
631
+ }
632
+ return out;
633
+ }
634
+ /** Is this OS pid still alive? `process.kill(pid, 0)` is the portable probe:
635
+ * ESRCH means gone, EPERM means alive but owned by someone else — both are
636
+ * answers, and "no answer" is never one of them. */
637
+ export function pidAlive(pid) {
638
+ if (!Number.isFinite(pid) || pid <= 0)
639
+ return false;
640
+ try {
641
+ process.kill(pid, 0);
642
+ return true;
643
+ }
644
+ catch (err) {
645
+ return err?.code === "EPERM";
646
+ }
647
+ }
648
+ /** Wait up to `ms` for a pid to disappear, then terminate it once and give it
649
+ * a short grace. Returns whether the process is GONE — the only honest basis
650
+ * for a "已确认关闭" claim. */
651
+ export async function waitForPidExit(pid, ms = 3000, kill = (p) => killBrowserTree(p)) {
652
+ if (!pidAlive(pid))
653
+ return true;
654
+ const deadline = Date.now() + Math.max(0, ms);
655
+ while (Date.now() < deadline && pidAlive(pid)) {
656
+ await new Promise((r) => setTimeout(r, 100));
657
+ }
658
+ if (!pidAlive(pid))
659
+ return true;
660
+ try {
661
+ kill(pid);
662
+ }
663
+ catch {
664
+ /* already gone, or not ours to signal — the re-check below decides */
665
+ }
666
+ await new Promise((r) => setTimeout(r, 250));
667
+ return !pidAlive(pid);
668
+ }
669
+ /** What a pid actually is right now — its image name and command line, or null
670
+ * when the pid is gone. Used to prove a recorded pid is still the browser we
671
+ * launched before anything force-kills it (see reapOrphanBrowsers). */
672
+ function procIdentity(pid) {
673
+ const p = Number(pid);
674
+ if (!Number.isInteger(p) || p <= 0)
675
+ return null;
676
+ if (process.platform === "win32") {
677
+ const script = `Get-CimInstance Win32_Process -Filter 'ProcessId=${p}' | Select-Object Name,CommandLine | ConvertTo-Json -Compress`;
678
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
679
+ encoding: "utf8",
680
+ windowsHide: true,
681
+ timeout: 6000,
682
+ maxBuffer: 4 * 1024 * 1024,
683
+ });
684
+ if (r.status !== 0)
685
+ return null;
686
+ const text = String(r.stdout ?? "").trim();
687
+ if (!text)
688
+ return null;
689
+ try {
690
+ const row = JSON.parse(text);
691
+ const one = (Array.isArray(row) ? row[0] : row) ?? {};
692
+ return { name: String(one?.Name ?? ""), cmdline: String(one?.CommandLine ?? "") };
693
+ }
694
+ catch {
695
+ return null;
696
+ }
697
+ }
698
+ try {
699
+ const raw = fs.readFileSync(`/proc/${p}/cmdline`);
700
+ const parts = String(raw).split("\0").filter((s) => s !== "");
701
+ if (!parts.length)
702
+ return null;
703
+ return { name: path.basename(parts[0]), cmdline: parts.join(" ") };
704
+ }
705
+ catch {
706
+ return null;
707
+ }
708
+ }
709
+ /** Does this identity look like the executable we launched? Compared on the
710
+ * file NAME: the command line's quoting and path form can differ from ours
711
+ * (8.3-short vs long path, escaped quotes), while a pid recycled by an
712
+ * unrelated program almost never carries the same image name. A missing
713
+ * identity counts as NO — the safe answer before a force-kill. */
714
+ export function identityMatchesExecutable(exePath, ident) {
715
+ const want = path.basename(String(exePath ?? "")).toLowerCase();
716
+ if (!want || !ident)
717
+ return false;
718
+ const name = String(ident.name ?? "").toLowerCase();
719
+ const cmd = String(ident.cmdline ?? "").toLowerCase();
720
+ return name === want || cmd.includes(want);
721
+ }
722
+ /** The same question asked of a live pid (see identityMatchesExecutable). */
723
+ export function pidMatchesExecutable(pid, exePath) {
724
+ return identityMatchesExecutable(exePath, procIdentity(pid));
725
+ }
726
+ /** Terminate a browser process AND everything it spawned.
727
+ *
728
+ * `process.kill(pid)` signals the ROOT only. Measured on the desktop host the
729
+ * day this was written: the orphan reaper's default kill took the msedge root
730
+ * down and left all NINE of its child processes running — which is exactly the
731
+ * leftover the reaper exists to remove. The graceful path does not have this
732
+ * problem (playwright's `browser.close()` shuts its children down first, and
733
+ * the real-host leg verifies it), so this is the fallback's job.
734
+ *
735
+ * win32: `taskkill /T /F` walks the tree for us.
736
+ * POSIX: no tree flag, so descendants are enumerated and killed deepest-first.
737
+ * Best-effort throughout: a process that vanishes mid-walk is not an error.
738
+ */
739
+ export function killBrowserTree(pid, opts = {}) {
740
+ const p = Number(pid);
741
+ if (!Number.isInteger(p) || p <= 0)
742
+ return;
743
+ const run = opts.run ?? ((cmd, args) => {
744
+ spawnSync(cmd, args, { windowsHide: true, timeout: 8000 });
745
+ });
746
+ if ((opts.platform ?? process.platform) === "win32") {
747
+ try {
748
+ run("taskkill.exe", ["/PID", String(p), "/T", "/F"]);
749
+ }
750
+ catch {
751
+ /* gone already, or not ours to signal */
752
+ }
753
+ return;
754
+ }
755
+ const children = opts.children ?? childProcRows;
756
+ const order = [];
757
+ const queue = [p];
758
+ while (queue.length) {
759
+ const cur = queue.shift();
760
+ let kids = [];
761
+ try {
762
+ kids = children(cur) ?? [];
763
+ }
764
+ catch {
765
+ kids = [];
766
+ }
767
+ for (const k of kids) {
768
+ const kid = Number(k?.pid);
769
+ if (Number.isInteger(kid) && kid > 0 && kid !== p && !order.includes(kid)) {
770
+ order.push(kid);
771
+ queue.push(kid);
772
+ }
773
+ }
774
+ }
775
+ const signal = opts.signal ?? ((p) => process.kill(p));
776
+ for (const kid of order.reverse()) {
777
+ try {
778
+ signal(kid);
779
+ }
780
+ catch {
781
+ /* racy by design — the re-check above is what decides */
782
+ }
783
+ }
784
+ try {
785
+ signal(p);
786
+ }
787
+ catch {
788
+ /* already gone */
789
+ }
790
+ }
791
+ /** Children of `ppid`, straight from the OS. Best-effort by design: a host
792
+ * without PowerShell/CIM or a refused query returns [], and the caller then
793
+ * reports that the process could not be verified rather than guessing. */
794
+ function childProcRows(ppid) {
795
+ if (process.platform === "win32") {
796
+ // The filter runs server-side, so the reply holds ONLY our own children —
797
+ // never a full-table scan, never a name match on someone else's browser.
798
+ const script = `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${ppid}' | ` +
799
+ "Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress";
800
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
801
+ encoding: "utf8",
802
+ windowsHide: true,
803
+ timeout: 6000,
804
+ maxBuffer: 8 * 1024 * 1024,
805
+ });
806
+ if (r.status !== 0)
807
+ return [];
808
+ const text = String(r.stdout ?? "").trim();
809
+ if (!text)
810
+ return [];
811
+ let rows;
812
+ try {
813
+ rows = JSON.parse(text);
814
+ }
815
+ catch {
816
+ return [];
817
+ }
818
+ const list = Array.isArray(rows) ? rows : [rows];
819
+ return list
820
+ .map((x) => ({
821
+ pid: Number(x?.ProcessId) || 0,
822
+ name: String(x?.Name ?? ""),
823
+ cmdline: String(x?.CommandLine ?? ""),
824
+ }))
825
+ .filter((x) => x.pid > 0);
826
+ }
827
+ const r = spawnSync("ps", ["-eo", "pid=,ppid=,args="], { encoding: "utf8", timeout: 4000 });
828
+ if (r.status !== 0)
829
+ return [];
830
+ const out = [];
831
+ for (const line of String(r.stdout ?? "").split("\n")) {
832
+ const m = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
833
+ if (!m || Number(m[2]) !== ppid)
834
+ continue;
835
+ const cmdline = m[3].trim();
836
+ out.push({ pid: Number(m[1]), name: path.basename(cmdline.split(/\s+/)[0] ?? ""), cmdline });
837
+ }
838
+ return out;
839
+ }
840
+ /** The OS pid behind a playwright-launched browser.
841
+ *
842
+ * playwright-core has NO `Browser.process()` — that accessor belongs to
843
+ * ElectronApplication and BrowserServer. Measured on the desktop host with a
844
+ * real 1.63 session: `typeof browser.process === "undefined"`, so the pid
845
+ * stayed 0, the orphan ledger recorded nothing, and close printed 已确认关闭
846
+ * over a browser process it had never checked.
847
+ *
848
+ * The only exact anchor available is that the browser is a DIRECT CHILD of
849
+ * this process, started from the executable WE resolved. That matters: this
850
+ * machine carries a dozen unrelated msedge trees (the user's own windows,
851
+ * msedgewebview2 under SearchHost.exe and under a vendor utility), so matching
852
+ * by process name would be a loaded gun pointed at them.
853
+ *
854
+ * 0 = not found, and every caller treats 0 as "no verification" — never as
855
+ * "verified gone".
856
+ */
857
+ export function launchedBrowserPid(executablePath, opts = {}) {
858
+ const exe = String(executablePath ?? "").trim();
859
+ const base = path.basename(exe).toLowerCase();
860
+ if (!base)
861
+ return 0;
862
+ let rows;
863
+ try {
864
+ rows = opts.rows ?? childProcRows(opts.ppid ?? process.pid);
865
+ }
866
+ catch {
867
+ return 0;
868
+ }
869
+ const mine = (rows ?? []).filter((r) => Number.isFinite(Number(r?.pid)) && Number(r.pid) > 0);
870
+ const byPath = mine.filter((r) => String(r.cmdline ?? "").toLowerCase().includes(exe.toLowerCase()));
871
+ if (byPath.length === 1)
872
+ return Number(byPath[0].pid);
873
+ // More than one of OUR children from the same executable = an older session
874
+ // that has not been reaped yet. pids climb, so the highest is this launch.
875
+ if (byPath.length > 1)
876
+ return Math.max(...byPath.map((r) => Number(r.pid)));
877
+ // Name fallback, and only for rows where the OS gave us NO command line at
878
+ // all. A row that carries a command line for some OTHER executable is
879
+ // evidence against, not a missing clue — adopting it would mean signalling a
880
+ // browser we never launched.
881
+ const byName = mine.filter((r) => String(r.cmdline ?? "").trim() === "" && String(r.name ?? "").toLowerCase() === base);
882
+ return byName.length === 1 ? Number(byName[0].pid) : 0;
883
+ }
416
884
  /** Consent is per BROWSER SESSION, not per call: one dialog when the lead
417
885
  * decides to script the page, then the round stops paying for it. */
418
886
  export function needsEvalConsent(policy, alreadyApproved) {
419
887
  return policy !== "off" && !alreadyApproved;
420
888
  }
889
+ /** A function-shaped source has to be INVOKED. Every browser MCP documents
890
+ * `function: "() => …"`, but evaluate() treats a STRING as an expression: a
891
+ * function source evaluates to a function object, which is not serializable,
892
+ * so the call resolves to undefined and the agent reads "null" and goes
893
+ * looking for a bug that is in our call rather than in the page (measured
894
+ * live 2026-09-21 — six rounds lost this way). An expression the model
895
+ * already wrapped, or a bare `document.title`, passes through untouched. */
896
+ export function evalExpression(src) {
897
+ const t = src.trim();
898
+ if (!t)
899
+ return t;
900
+ const fnLike = /^(async\s+)?function\b/.test(t) ||
901
+ /^(async\s+)?\(\s*[^()]*?\s*\)\s*=>/.test(t) ||
902
+ /^(async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(t);
903
+ return fnLike ? `(${t})()` : t;
904
+ }
905
+ /** `null` is not a diagnosis. The render names the type, so "the page has no
906
+ * such links" and "your function returned nothing" and "it was not called"
907
+ * stop looking like the same answer. */
908
+ export function renderEvalResult(value) {
909
+ if (value === undefined) {
910
+ return ("执行结果:undefined —— 函数没有 return 值(或返回了不可序列化的对象,比如 DOM 节点本身)。" +
911
+ "要拿数据就返回字符串或数组,例:() => [...document.querySelectorAll('a')].map(a => a.href)");
912
+ }
913
+ if (value === null)
914
+ return "执行结果:null(页面确实返回了 null —— 这是成功执行,不是失败)";
915
+ const kind = Array.isArray(value) ? `array(${value.length})` : typeof value;
916
+ const json = JSON.stringify(value) ?? String(value);
917
+ return `执行结果(${kind}):${json}`;
918
+ }
421
919
  /** Host only — the consent line in the dialog and in the trajectory never
422
920
  * carries a query string (a search URL can hold the very token we guard). */
423
921
  export function hostOnly(url) {
@@ -428,6 +926,133 @@ export function hostOnly(url) {
428
926
  return url.slice(0, 60);
429
927
  }
430
928
  }
929
+ const CLICK_EFFECT_BUDGET_MS = 900;
930
+ const CLICK_POLL_MS = 150;
931
+ const CLICK_SETTLE_MS = 5000;
932
+ /** Runs in the page. Kept free of TS annotations and outer-scope references
933
+ * because playwright serializes it. */
934
+ const clickProbeFn = (el) => {
935
+ const attrs = {};
936
+ for (const a of ["aria-expanded", "aria-checked", "aria-selected", "aria-pressed", "disabled"]) {
937
+ const v = el.getAttribute ? el.getAttribute(a) : null;
938
+ if (v !== null && v !== undefined)
939
+ attrs[a] = String(v);
940
+ }
941
+ if (typeof el.value === "string")
942
+ attrs.value = el.value;
943
+ return {
944
+ attrs,
945
+ url: String(location.href || ""),
946
+ ready: String(document.readyState || ""),
947
+ nodes: document.getElementsByTagName("*").length,
948
+ };
949
+ };
950
+ async function probeClickTarget(loc) {
951
+ if (typeof loc.evaluate !== "function")
952
+ return null;
953
+ try {
954
+ const raw = (await loc.evaluate(clickProbeFn));
955
+ if (!raw || typeof raw !== "object")
956
+ return null;
957
+ return {
958
+ attrs: (raw.attrs ?? {}),
959
+ url: String(raw.url ?? ""),
960
+ ready: String(raw.ready ?? ""),
961
+ nodes: Number(raw.nodes ?? 0),
962
+ };
963
+ }
964
+ catch {
965
+ return null; // a detached element is an answer, not a crash
966
+ }
967
+ }
968
+ /** Bounded settle. `open` deliberately returns at domcontentloaded — reading a
969
+ * page must not wait for analytics — so the wait belongs to the action that
970
+ * needs a handler attached, not to every navigation. */
971
+ async function settlePage(page, timeoutMs = CLICK_SETTLE_MS) {
972
+ const waiting = typeof page.waitForLoadState === "function" ? page.waitForLoadState("load", { timeout: timeoutMs }) : null;
973
+ if (waiting) {
974
+ await Promise.race([
975
+ Promise.resolve(waiting).catch(() => undefined),
976
+ new Promise((r) => setTimeout(r, timeoutMs)),
977
+ ]);
978
+ }
979
+ await new Promise((r) => setTimeout(r, 250));
980
+ }
981
+ /** What differs between two probes, in the order a reader cares about. */
982
+ export function describeClickChange(before, after) {
983
+ const out = [];
984
+ const attrsBefore = before.attrs ?? {};
985
+ const attrsAfter = after.attrs ?? {};
986
+ for (const k of Object.keys(attrsBefore)) {
987
+ if (attrsAfter[k] !== attrsBefore[k])
988
+ out.push(`${k}: ${attrsBefore[k]} → ${attrsAfter[k] ?? "(消失)"}`);
989
+ }
990
+ for (const k of Object.keys(attrsAfter)) {
991
+ if (!(k in attrsBefore))
992
+ out.push(`${k}: → ${attrsAfter[k]}`);
993
+ }
994
+ if (before.url && after.url && before.url !== after.url)
995
+ out.push(`已跳转 → ${after.url.slice(0, 120)}`);
996
+ if (before.nodes && after.nodes !== before.nodes)
997
+ out.push(`DOM 节点 ${before.nodes} → ${after.nodes}`);
998
+ return out;
999
+ }
1000
+ /** Poll until something changed or the budget runs out. A navigating click can
1001
+ * detach the element, so the URL is checked independently of the probe. */
1002
+ async function observeClickEffect(loc, page, before, budgetMs = CLICK_EFFECT_BUDGET_MS) {
1003
+ const deadline = Date.now() + Math.max(0, budgetMs);
1004
+ for (;;) {
1005
+ const after = await probeClickTarget(loc);
1006
+ if (after) {
1007
+ const changed = describeClickChange(before, after);
1008
+ if (changed.length)
1009
+ return { changed, after };
1010
+ }
1011
+ else {
1012
+ const urlNow = typeof page.url === "function" ? String(page.url() ?? "") : "";
1013
+ if (urlNow && before.url && urlNow !== before.url)
1014
+ return { changed: [`已跳转 → ${urlNow.slice(0, 120)}`], after: null };
1015
+ }
1016
+ if (Date.now() >= deadline)
1017
+ return { changed: [], after };
1018
+ await new Promise((r) => setTimeout(r, CLICK_POLL_MS));
1019
+ }
1020
+ }
1021
+ /** The reply. Pure, so the wording is pinnable without a browser: a click that
1022
+ * observably did something says what changed; one that did not says so and
1023
+ * names the next move instead of reporting success. */
1024
+ export function clickVerdict(target, before, changed, opts) {
1025
+ const head = `已点击 ${target}`;
1026
+ const dialogNote = opts.dialog ? ";有未处理对话框——handle_dialog(同轮观察,勿另起动作)" : "";
1027
+ if (!before)
1028
+ return head + dialogNote; // nothing could be probed: claim no more than we know
1029
+ if (changed.length) {
1030
+ const retryNote = opts.retried
1031
+ ? `(第一次点击落在页面还没就绪时 readyState=${before.ready || "?"},未观测到变化;等加载完重试才生效)`
1032
+ : "";
1033
+ return `${head} · ${changed.join(" · ")}${retryNote}${dialogNote}`;
1034
+ }
1035
+ const watched = Object.keys(before.attrs).length
1036
+ ? Object.entries(before.attrs).map(([k, v]) => `${k}=${v}`).join(" ")
1037
+ : "无可观测状态属性";
1038
+ const facts = `${watched} · URL 未变 · DOM 节点 ${before.nodes} 未变 · readyState=${before.ready || "?"}`;
1039
+ // Three different reasons produce the same "nothing changed", and the advice
1040
+ // that fits one of them is a dead end for the others. Measured live: a page
1041
+ // at readyState=complete whose framework bundles the governance gate had
1042
+ // trimmed was told to "wait for the text" — wait_for then threw a strict-mode
1043
+ // violation, so the recommended way out led nowhere.
1044
+ const why = opts.blocked && opts.blocked.count > 0
1045
+ ? `最可能的原因不是"还没加载完",而是**这个页面的脚本被治理闸门拦掉了**:本次有 ${opts.blocked.count} 个子资源被拦截` +
1046
+ `${opts.blocked.hosts.length ? `(${opts.blocked.hosts.join(", ")})` : ""},而它们的可执行资源不属于你导航过的那个站。` +
1047
+ `出路:请操作者把这些主机加进 TM_WEBFETCH_ALLOWED_DOMAINS(或先在确认窗里批准该站点)后重新 open;` +
1048
+ `也可以直接 TM_BROWSER_SUBRESOURCE=off 之外逐案判断——总之现在这个按钮上没有 handler。`
1049
+ : before.ready === "complete"
1050
+ ? `页面已经加载完成(readyState=complete),所以这不是加载时机问题:这个元素本身不响应这种点击(常见于 <a> 需要 navigate、事件绑在父节点、或被上层遮罩)。` +
1051
+ `下一步:重新 take_snapshot 看清结构再决定,或改用 navigate / evaluate_script 直接取你要的东西。`
1052
+ : `最常见的原因是页面脚本还没跑完(readyState=${before.ready || "?"}),而 playwright 的可见/稳定/可点检查在这种情况下全部通过。` +
1053
+ `下一步:wait_for 你要点的文字,或重新 take_snapshot 再点一次。`;
1054
+ return `${head},但页面没有任何可观测变化(${facts})。点击送达了,可是没有 handler 响应。${why}不要把这次点击当成成功。${dialogNote}`;
1055
+ }
431
1056
  class PipeCdp {
432
1057
  child;
433
1058
  nextId = 1;
@@ -608,8 +1233,8 @@ export class SnapshotIndex {
608
1233
  export function unescapeAriaName(raw) {
609
1234
  return String(raw).replace(/\\(.)/g, (_, c) => (c === "s" ? " " : c === "n" ? "\n" : c === "t" ? "\t" : c));
610
1235
  }
611
- // ---------- action surface (R2: chrome-devtools-mcp 16-verb alignment) --------
612
- /** The 16 canonical verbs, named byte-identical to chrome-devtools-mcp. */
1236
+ // ---------- action surface (R2: chrome-devtools-mcp 18-verb alignment) --------
1237
+ /** The 18 canonical verbs, named byte-identical to chrome-devtools-mcp. */
613
1238
  export const BROWSER_PLAYWRIGHT_ACTIONS = [
614
1239
  "navigate_page",
615
1240
  "take_snapshot",
@@ -619,6 +1244,8 @@ export const BROWSER_PLAYWRIGHT_ACTIONS = [
619
1244
  "drag",
620
1245
  "press_key",
621
1246
  "select_page",
1247
+ "new_page",
1248
+ "close_page",
622
1249
  "upload_file",
623
1250
  "wait_for",
624
1251
  "evaluate_script",
@@ -628,8 +1255,26 @@ export const BROWSER_PLAYWRIGHT_ACTIONS = [
628
1255
  "take_screenshot",
629
1256
  "handle_dialog",
630
1257
  ];
631
- /** Legacy compat verbs that ride on top of the 16 (both engines). */
1258
+ /** Legacy compat verbs that ride on top of the 18 (both engines). */
632
1259
  export const BROWSER_COMPAT_ACTIONS = ["open", "navigate", "read", "screenshot", "close"];
1260
+ /** Verbs of our own that act on the GATE rather than the DOM: no page is
1261
+ * involved, so both engines serve them and neither can drift. */
1262
+ export const BROWSER_GATE_ACTIONS = ["allow_host"];
1263
+ /** Every verb the execute layer accepts — the ONE table the refusal menu and
1264
+ * the args schema are both built from. The hand-written copies drifted the
1265
+ * day `new_page` landed (§6o/unknown-action menu), and a schema that omits a
1266
+ * verb is a capability no agent knows it has. */
1267
+ export const ALL_BROWSER_ACTIONS = [
1268
+ ...BROWSER_PLAYWRIGHT_ACTIONS,
1269
+ ...BROWSER_COMPAT_ACTIONS,
1270
+ ...BROWSER_GATE_ACTIONS,
1271
+ ];
1272
+ export const BROWSER_ACTION_MENU = ALL_BROWSER_ACTIONS.join(" | ");
1273
+ /** The verbs that must still WORK when a browser has zero tabs: `new_page` is
1274
+ * the remedy the error text points at, `list_pages` is how you find out you
1275
+ * are at zero, and `close_page` answers with its own "index 越界(0..-1)"
1276
+ * rather than this sentence. */
1277
+ const ZERO_TAB_SAFE = new Set(["new_page", "list_pages", "close_page"]);
633
1278
  /** What the cdp-legacy fallback can still do (R2: degradation must keep the
634
1279
  * §6o behavior; everything snapshot-shaped is playwright-only). */
635
1280
  export const BROWSER_LEGACY_ACTIONS = [
@@ -648,23 +1293,144 @@ export const BROWSER_LEGACY_ACTIONS = [
648
1293
  const PW_DEFAULT_TIMEOUT_MS = 3000;
649
1294
  const OBSERVE_BUFFER_CAP = 200;
650
1295
  const HARDENED_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
1296
+ // The browser's OWN site-permission bubble ("…想要 访问此设备上的其他应用和服务",
1297
+ // 阻止/允许) is not our ctx.ask channel, so nothing in perm-ask or the approval
1298
+ // gate can bound it: measured on npmjs.com, an unanswered bubble held
1299
+ // `page.goto` past its 45 s timeout without even reaching domcontentloaded, and
1300
+ // a server-side session has nobody to click. Denying at launch is the safe
1301
+ // direction and the ONLY one this toolset takes — there is deliberately no
1302
+ // auto-allow counterpart (no `permissions:` in the context options either),
1303
+ // because a browser that grants a site device access on the plugin's say-so
1304
+ // would be the self-allowing the rest of this codebase refuses to do.
1305
+ export const BROWSER_DENY_PERMISSIONS_ARG = "--deny-permission-prompts";
651
1306
  const SPAWN_TIMEOUT_MS = 15_000;
652
1307
  const NAVIGATE_EVENT_TIMEOUT_MS = 15_000;
1308
+ export function callerOf(ctx) {
1309
+ const c = (ctx ?? {});
1310
+ return { owner: String(c.sessionID ?? "").trim(), agent: String(c.agent ?? "").trim() };
1311
+ }
653
1312
  export function buildTmBrowserTool(deps) {
654
1313
  const { pipelines } = deps;
655
1314
  const env = deps.env ?? process.env;
656
1315
  const notify = typeof deps.notify === "function" ? deps.notify : undefined;
657
1316
  const tool = "tm_browser";
658
1317
  const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
659
- let session = null;
1318
+ // The orphan ledger lives beside the run store (per workspace since the
1319
+ // tmpdir sharding), so a crash in one project can never reap another's
1320
+ // browser — and a test run, which gets its own store dir, never reaches a
1321
+ // real one either.
1322
+ const ledgerFile = (() => {
1323
+ const root = pipelines.store?.blackboardRoot;
1324
+ // No store root (a test double, or a host that never gave us one) means no
1325
+ // ledger — and then nothing is recorded and nothing is reaped. It must not
1326
+ // throw on the way to opening a browser.
1327
+ return typeof root === "string" && root ? browserLedgerFile(root) : "";
1328
+ })();
1329
+ const recordBrowser = (pid, engine, exe) => {
1330
+ if (!ledgerFile || !Number.isFinite(pid) || pid <= 0)
1331
+ return;
1332
+ appendBrowserLedger(ledgerFile, { pid, ownerPid: process.pid, engine, at: Date.now(), exe });
1333
+ };
1334
+ if (ledgerFile && String(env.TM_BROWSER_REAP ?? "on").trim().toLowerCase() !== "off") {
1335
+ const r = reapOrphanBrowsers(ledgerFile);
1336
+ if (r.reaped.length)
1337
+ traj({ step_id: "browser", event: "orphans_reaped", count: r.reaped.length, pids: r.reaped.join(",") });
1338
+ }
1339
+ /** Live browser leases by id — ONE browser per caller (see BrowserLease). */
1340
+ const leases = new Map();
1341
+ let leaseSeq = 0;
1342
+ const liveLeases = () => [...leases.values()];
1343
+ const ownedBy = (owner) => liveLeases().filter((l) => l.owner === owner);
1344
+ const leaseTable = () => liveLeases()
1345
+ .map((l) => `${l.id} · ${l.agent || "未知角色"} · ${String(l.session.currentUrl || "").slice(0, 80) || "空白页"}`)
1346
+ .join("\n ");
1347
+ /** Idle-reaper timers, one per lease: a forgotten window belongs to its
1348
+ * owner, so B's activity must not keep A's browser alive (or reap it). */
1349
+ const idleTimers = new Map();
1350
+ function clearIdle(id) {
1351
+ const t = idleTimers.get(id);
1352
+ if (t)
1353
+ clearTimeout(t);
1354
+ idleTimers.delete(id);
1355
+ }
1356
+ /** Drop the lease whose session died on its own (a cdp-legacy child exiting,
1357
+ * a browser the user closed) so a corpse is never handed back out. */
1358
+ const dropSession = (sess) => {
1359
+ for (const [id, l] of leases) {
1360
+ if (l.session === sess) {
1361
+ leases.delete(id);
1362
+ clearIdle(id);
1363
+ }
1364
+ }
1365
+ };
660
1366
  // m2 (fix round): SAME seed policy as tm_webfetch / tm_search — the
661
1367
  // built-in default list gains the T4 engine home hosts (api.stackexchange
662
1368
  // .com, hn.algolia.com) so jump-reading a search hit stops re-popping the
663
1369
  // dialog; a narrowed TM_WEBFETCH_ALLOWED_DOMAINS is never widened.
664
1370
  const allowlist = seedWebfetchDomains(deps.cfg.webfetchAllowedDomains ?? ["*"]);
665
- // hosts approved through the OFFICIAL dialog this plugin lifetime — the
666
- // network layer (both engines) consults this in addition to the static allowlist
667
- const approvedHosts = new Set();
1371
+ /** Hosts approved through the OFFICIAL dialog, keyed by the CALLER's session.
1372
+ * It used to be one process-wide set, which meant a host the lead approved
1373
+ * became a silent pass for the researcher's browser too — consent given to
1374
+ * one agent is not a pass for another. The network layer (both engines)
1375
+ * consults the caller's set in addition to the static allowlist. */
1376
+ const approvedBy = new Map();
1377
+ const approvedFor = (owner) => {
1378
+ let s = approvedBy.get(owner);
1379
+ if (!s) {
1380
+ s = new Set();
1381
+ approvedBy.set(owner, s);
1382
+ }
1383
+ return s;
1384
+ };
1385
+ /** Which browser an action may drive.
1386
+ *
1387
+ * The id is REQUIRED as soon as it could mean more than one thing: with a
1388
+ * single live browser that belongs to the caller, omitting it is unambiguous
1389
+ * and allowed (and every reply echoes the id back); the moment a second
1390
+ * agent opens one, every call must name its own. An id belonging to somebody
1391
+ * else is refused WITH the owner named, because `b1` is guessable and the
1392
+ * whole point is that guessing must not work. */
1393
+ function resolveLease(args, caller) {
1394
+ const want = String(args.id ?? "").trim();
1395
+ const all = liveLeases();
1396
+ const mine = ownedBy(caller.owner);
1397
+ if (want) {
1398
+ const hit = all.find((l) => l.id === want);
1399
+ if (!hit) {
1400
+ return {
1401
+ error: all.length
1402
+ ? `没有 id 为 "${want}" 的浏览器。活着的:\n ${leaseTable()}`
1403
+ : `没有 id 为 "${want}" 的浏览器——现在一个都没开,先用 action:"open"。`,
1404
+ };
1405
+ }
1406
+ if (hit.owner !== caller.owner) {
1407
+ return {
1408
+ error: `${hit.id} 是 ${hit.agent || "另一个会话"} 的浏览器,不是你的——两个 agent 共用一个窗口会互相踩当前标签和 uid 编号,` +
1409
+ `而你看到的每一次"成功"都可能是对方的页面。` +
1410
+ (mine.length
1411
+ ? `你自己的是 ${mine.map((l) => l.id).join(" / ")}。`
1412
+ : `你还没有浏览器:用 action:"open" 开一个,它会给你自己的 id。`),
1413
+ };
1414
+ }
1415
+ hit.lastActivity = Date.now();
1416
+ return { lease: hit };
1417
+ }
1418
+ if (all.length === 1 && mine.length === 1) {
1419
+ mine[0].lastActivity = Date.now();
1420
+ return { lease: mine[0] };
1421
+ }
1422
+ if (!mine.length) {
1423
+ return {
1424
+ error: all.length
1425
+ ? `你还没有浏览器(活着的:\n ${leaseTable()})——先用 action:"open" 开一个,之后带上它给你的 id。`
1426
+ : '没有打开的浏览器会话——先用 action:"open"',
1427
+ };
1428
+ }
1429
+ return {
1430
+ error: `现在有 ${all.length} 个浏览器在跑,动作必须写明 id(你自己的:${mine.map((l) => l.id).join(" / ")})——` +
1431
+ `省略 id 只在"全场只有一个、且是你的"时才安全。\n ${leaseTable()}`,
1432
+ };
1433
+ }
668
1434
  const snapshotBudget = Math.max(10, Number(deps.cfg?.browserSnapshotMaxTokens) || 1200);
669
1435
  const subPolicy = deps.cfg?.browserSubresource ?? "same-site";
670
1436
  const evalAskPolicy = deps.cfg?.browserAskEval ?? "on";
@@ -764,8 +1530,15 @@ export function buildTmBrowserTool(deps) {
764
1530
  }
765
1531
  /** One line telling the agent that the page is NOT actually empty — the
766
1532
  * subresource gate dropped N requests. Without this the model reads a
767
- * blank-looking page and reports "该网站没有图片". */
768
- function blockedNote(sess) {
1533
+ * blank-looking page and reports "该网站没有图片".
1534
+ *
1535
+ * `thin` means the page ALSO came back with nothing addressable, which
1536
+ * changes the sentence from a footnote into the diagnosis: on baike the
1537
+ * blocked request was the site's own bundle, so 0 nodes and "no content"
1538
+ * were our doing. An agent that is told the causality has two real moves
1539
+ * (allow_host, or the env seed + restart); one that is not, reports the
1540
+ * site as empty. */
1541
+ function blockedNote(sess, thin = false) {
769
1542
  if (!sess.blocked.count)
770
1543
  return "";
771
1544
  const n = sess.blocked.count;
@@ -775,9 +1548,28 @@ export function buildTmBrowserTool(deps) {
775
1548
  // drain (and the note would never appear again for the session).
776
1549
  sess.blocked.count = 0;
777
1550
  sess.blocked.hosts.clear();
778
- traj({ step_id: "browser", event: "blocked", count: n, hosts: hosts.slice(0, 20).join(",") });
779
- return (`\n注意:本页有 ${n} 个子资源请求被治理白名单拦截(${hosts.slice(0, 6).join(", ")}${hosts.length > 6 ? ` …+${hosts.length - 6}` : ""})——不是站点没有内容。` +
780
- `当前策略 TM_BROWSER_SUBRESOURCE=${subPolicy};同域资源已自动放行,跨域脚本仍需白名单或弹窗批准。`);
1551
+ traj({ step_id: "browser", event: "blocked", count: n, hosts: hosts.slice(0, 20).join(","), thin });
1552
+ const list = `${hosts.slice(0, 6).join(", ")}${hosts.length > 6 ? ` …+${hosts.length - 6}` : ""}`;
1553
+ const base = `\n注意:本页有 ${n} 个子资源请求被治理白名单拦截(${list})——不是站点没有内容。` +
1554
+ `当前策略 TM_BROWSER_SUBRESOURCE=${subPolicy};同域资源已自动放行,跨域脚本仍需白名单或弹窗批准。`;
1555
+ if (!thin)
1556
+ return base;
1557
+ return (base +
1558
+ `这一页的可寻址内容为 0,而被拦的正是页面自己的脚本域名——空白是我们的门禁造成的,不是页面没有内容。` +
1559
+ `两条出路:action:"allow_host" {host:"${hosts[0] ?? "被拦域名"}"} 由我拿官方确认窗去问用户(只放行这个域名、只对你这个浏览器、本次会话),` +
1560
+ `或请用户把域名加进 TM_WEBFETCH_ALLOWED_DOMAINS 后重启 OpenCode。批准后要重新 navigate 一次——门禁在请求时判定,已加载的页面不会自己补回来。` +
1561
+ `在拿到真实内容之前不要把这一页当成"网站没有内容"汇报。`);
1562
+ }
1563
+ /** A thin page may be a wall rather than an empty site. Say which, because
1564
+ * the next move is the opposite: retry an empty page, never a captcha. */
1565
+ function wallNote(...texts) {
1566
+ const wall = challengeWallOf(...texts);
1567
+ if (!wall)
1568
+ return "";
1569
+ traj({ step_id: "browser", event: "challenge_wall", sign: wall });
1570
+ return (`\n这一页不是"没有内容",它是一张${wall}墙——站点要求真人完成验证,自动化通道到此为止。` +
1571
+ `不要重试这一页来"等它加载完",也不要把"该站点无内容"写进结论:换来源(tm_search 的其它引擎 / 别的站点),` +
1572
+ `或者告诉用户需要他本人在这个窗口里完成一次验证后再继续。`);
781
1573
  }
782
1574
  function pageTextOutput(url, text) {
783
1575
  return `页面文本(${url}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`;
@@ -821,7 +1613,7 @@ export function buildTmBrowserTool(deps) {
821
1613
  * under EVERY subresource policy: they are not consentable, so a passive
822
1614
  * image or a same-site script can never relax them.
823
1615
  */
824
- function gateDecide(st, url, resourceType) {
1616
+ function gateDecide(st, url, resourceType, approvedHosts) {
825
1617
  const verdict = checkWebUrl(url, allowlist);
826
1618
  if (!verdict.ok && !verdict.askable)
827
1619
  return { pass: false, via: "red-line" };
@@ -858,11 +1650,18 @@ export function buildTmBrowserTool(deps) {
858
1650
  // and flush as ONE line (blockedNote / close).
859
1651
  return v;
860
1652
  }
861
- async function openLegacySession(headless) {
1653
+ async function openLegacySession(headless, approvedHosts) {
862
1654
  const executable = discover(env);
863
1655
  if (!executable)
864
1656
  throw discoveryError();
865
- const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
1657
+ // The SAME knob on BOTH engines. This used to be temp-only, so a session
1658
+ // that degraded to cdp-legacy kept its logins nowhere while the reply still
1659
+ // read the env and announced 持久登录配置.
1660
+ const profile = classifyProfileDir(env);
1661
+ if (profile.refusal)
1662
+ throw new Error(profile.refusal);
1663
+ const persistent = profile.dir !== null;
1664
+ const profileDir = persistent ? profile.dir : fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
866
1665
  const child = spawn(executable, [
867
1666
  headless ? "--headless=new" : "--start-maximized",
868
1667
  "--remote-debugging-pipe",
@@ -873,6 +1672,7 @@ export function buildTmBrowserTool(deps) {
873
1672
  "--accept-lang=zh-CN,zh;q=0.9,en;q=0.8",
874
1673
  "--no-first-run", "--no-default-browser-check", "--disable-extensions",
875
1674
  "--disable-background-networking", "--mute-audio",
1675
+ BROWSER_DENY_PERMISSIONS_ARG,
876
1676
  "about:blank",
877
1677
  ], { stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"], windowsHide: true });
878
1678
  const cdp = new PipeCdp(child);
@@ -909,7 +1709,7 @@ export function buildTmBrowserTool(deps) {
909
1709
  const requestId = String(m.params.requestId ?? "");
910
1710
  const p = m.params.request ?? {};
911
1711
  const url = String(p.url ?? "");
912
- const { pass } = gateDecide({ allowedSites, blocked }, url, p.resourceType);
1712
+ const { pass } = gateDecide({ allowedSites, blocked }, url, p.resourceType, approvedHosts);
913
1713
  if (pass) {
914
1714
  void cdp.call("Fetch.continueRequest", { requestId }, sessionId, 5000).catch(() => { });
915
1715
  }
@@ -917,14 +1717,18 @@ export function buildTmBrowserTool(deps) {
917
1717
  void cdp.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, sessionId, 5000).catch(() => { });
918
1718
  }
919
1719
  });
1720
+ recordBrowser(Number(child.pid ?? 0), "cdp-legacy", executable);
920
1721
  const sess = {
921
1722
  kind: "cdp-legacy",
922
1723
  currentUrl: "about:blank",
923
1724
  headless,
1725
+ persistentProfile: persistent ? profileDir : null,
924
1726
  label: `cdp-legacy · ${path.basename(executable)} · pid ${child.pid ?? "?"}`,
925
1727
  attachments: [],
926
1728
  blocked,
927
1729
  allowedSites,
1730
+ // the CDP child still running IS the browser being alive on this engine
1731
+ alive: () => child.exitCode === null,
928
1732
  async act(action, args, stepId) {
929
1733
  if (action === "navigate" || action === "navigate_page") {
930
1734
  const url = String(args.url ?? "").trim();
@@ -939,7 +1743,17 @@ export function buildTmBrowserTool(deps) {
939
1743
  const ev = await cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, sessionId);
940
1744
  const text = String(ev.result?.value ?? "");
941
1745
  traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
942
- return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
1746
+ // Same diagnosis as the playwright leg — the two engines must not
1747
+ // drift on what a blank page is allowed to be called.
1748
+ const thin = text.replace(/\s+/g, "").length < 80;
1749
+ let title = "";
1750
+ if (thin) {
1751
+ const t = await cdp
1752
+ .call("Runtime.evaluate", { expression: "document.title", returnByValue: true }, sessionId)
1753
+ .catch(() => null);
1754
+ title = String(t?.result?.value ?? "");
1755
+ }
1756
+ return `${pageTextOutput(sess.currentUrl, text)}${wallNote(title, text)}${blockedNote(sess, thin)}`;
943
1757
  }
944
1758
  if (action === "screenshot" || action === "take_screenshot") {
945
1759
  traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
@@ -962,9 +1776,17 @@ export function buildTmBrowserTool(deps) {
962
1776
  if (!src)
963
1777
  throw new Error('缺少 expression/function 参数(evaluate_script 需要一个 JS 表达式)');
964
1778
  traj({ step_id: stepId, event: "call", kind: "evaluate" });
965
- const ev = await cdp.call("Runtime.evaluate", { expression: src, returnByValue: true }, sessionId);
966
- const value = ev.result?.value;
967
- return capTokens(`执行结果:${JSON.stringify(value ?? null)}`, "…(结果超长已截断)");
1779
+ const ev = await cdp.call("Runtime.evaluate", { expression: evalExpression(src), returnByValue: true, awaitPromise: true }, sessionId);
1780
+ // A page-side throw used to render as "执行结果:null", which is how an
1781
+ // agent spends six rounds wondering whether the selector was wrong.
1782
+ const det = ev?.exceptionDetails;
1783
+ if (det) {
1784
+ const first = String(det.exception?.description ?? det.text ?? JSON.stringify(det))
1785
+ .split("\n")[0]
1786
+ .slice(0, 300);
1787
+ throw new Error(`页面内抛出异常(不是"没找到"):${first}`);
1788
+ }
1789
+ return capTokens(renderEvalResult(ev.result?.value), "…(结果超长已截断)");
968
1790
  }
969
1791
  throw new Error(`action "${action}" 需要 playwright 引擎(当前为 cdp-legacy 降级:playwright-core 未安装或 node<20)。` +
970
1792
  `cdp-legacy 可用: ${BROWSER_LEGACY_ACTIONS.filter((a) => a !== "open" && a !== "close").join(" | ")}`);
@@ -987,24 +1809,33 @@ export function buildTmBrowserTool(deps) {
987
1809
  });
988
1810
  });
989
1811
  const dead = child.exitCode !== null;
990
- try {
991
- rmForceSafe(profileDir, { recursive: true });
992
- }
993
- catch {
994
- /* a locked leftover temp dir is reclaimed by the OS — never fail close */
1812
+ // Only a directory WE created is ours to remove. Deleting a user-named
1813
+ // persistent profile on close would destroy the logins the knob exists
1814
+ // to keep.
1815
+ if (!persistent) {
1816
+ try {
1817
+ rmForceSafe(profileDir, { recursive: true });
1818
+ }
1819
+ catch {
1820
+ /* a locked leftover temp dir is reclaimed by the OS — never fail close */
1821
+ }
995
1822
  }
996
1823
  return dead
997
- ? { closed: true, text: "浏览器进程已退出(cdp-legacy),临时配置目录已清理。" }
1824
+ ? {
1825
+ closed: true,
1826
+ text: persistent
1827
+ ? `浏览器进程已退出(cdp-legacy);持久配置目录已保留(下次 open 复用登录态):${profileDir}。`
1828
+ : "浏览器进程已退出(cdp-legacy),临时配置目录已清理。",
1829
+ }
998
1830
  : {
999
1831
  closed: false,
1000
1832
  text: `警告:cdp-legacy 子进程 pid ${child.pid ?? "?"} 未在 3s 内退出,窗口可能仍在前台——` +
1001
- `请手动关闭该浏览器窗口(临时目录 ${profileDir})。`,
1833
+ `请手动关闭该浏览器窗口(配置目录 ${profileDir}${persistent ? ",持久目录我不会删" : ",临时目录"})。`,
1002
1834
  };
1003
1835
  },
1004
1836
  };
1005
1837
  const onExit = () => {
1006
- if (session === sess)
1007
- session = null;
1838
+ dropSession(sess);
1008
1839
  // best-effort: the profile may still be file-locked during shutdown —
1009
1840
  // a leftover temp dir is reclaimed by the OS, never an error for the task
1010
1841
  try {
@@ -1018,13 +1849,14 @@ export function buildTmBrowserTool(deps) {
1018
1849
  return sess;
1019
1850
  }
1020
1851
  // ================= playwright engine (primary) ==============================
1021
- async function openPlaywrightSession(pw, headless) {
1852
+ async function openPlaywrightSession(pw, headless, approvedHosts) {
1022
1853
  const executable = discover(env);
1023
1854
  if (!executable)
1024
1855
  throw discoveryError();
1025
1856
  const launchArgs = [
1026
1857
  "--no-first-run", "--no-default-browser-check", "--disable-extensions",
1027
1858
  "--disable-background-networking", "--mute-audio",
1859
+ BROWSER_DENY_PERMISSIONS_ARG,
1028
1860
  ...(headless ? [] : ["--start-maximized"]),
1029
1861
  ];
1030
1862
  const contextOpts = {
@@ -1036,7 +1868,10 @@ export function buildTmBrowserTool(deps) {
1036
1868
  // headful: honor --start-maximized (null viewport = the OS window size)
1037
1869
  viewport: headless ? undefined : null,
1038
1870
  };
1039
- const persistentDir = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim();
1871
+ const profile = classifyProfileDir(env);
1872
+ if (profile.refusal)
1873
+ throw new Error(profile.refusal);
1874
+ const persistentDir = profile.dir ?? "";
1040
1875
  // R2 (revised 2026-09-18): playwright's `channel` resolves the executable
1041
1876
  // ITSELF, which silently overrode our discovery (a Beta-default Windows
1042
1877
  // host opened STABLE Edge). executablePath is now the primary launch and
@@ -1070,6 +1905,32 @@ export function buildTmBrowserTool(deps) {
1070
1905
  context = await browser.newContext(contextOpts);
1071
1906
  }
1072
1907
  const snapIndex = new SnapshotIndex();
1908
+ // The OS process behind the connection. playwright exposes it as
1909
+ // browser.process(); on the persistent path the browser is reached through
1910
+ // the context. Without a pid there is no way to tell "the CDP connection
1911
+ // dropped" apart from "the browser is gone" — and they are NOT the same
1912
+ // thing (measured: close printed 已确认关闭 while the msedge tree was still
1913
+ // alive under OpenCode.exe).
1914
+ const owningBrowser = browser ??
1915
+ (() => {
1916
+ try {
1917
+ return context.browser?.() ?? null;
1918
+ }
1919
+ catch {
1920
+ return null;
1921
+ }
1922
+ })();
1923
+ const { pid: browserPid, via: pidRoute, failures: pidFailures } = await resolveLaunchPid({
1924
+ // playwright's own accessor first — it does not exist on 1.63, so the OS
1925
+ // scan is what answers today. Which route replied is AUDITED: a silent
1926
+ // fall-back to 0 would put us straight back to printing 已确认关闭 about a
1927
+ // process nobody looked at.
1928
+ playwrightPid: () => Number(owningBrowser?.process?.()?.pid ?? 0) || 0,
1929
+ executablePath: target.executablePath ?? executable,
1930
+ rows: () => (deps.listChildren ? deps.listChildren(process.pid) : undefined),
1931
+ });
1932
+ traj({ step_id: "browser", event: "launch_pid", via: pidRoute, pid: browserPid, tries: pidFailures });
1933
+ recordBrowser(browserPid, persistentDir ? "playwright-persistent" : "playwright", executable);
1073
1934
  const state = {
1074
1935
  page: context.pages()[0] ?? (await context.newPage()),
1075
1936
  dialogs: [],
@@ -1130,7 +1991,7 @@ export function buildTmBrowserTool(deps) {
1130
1991
  await route.continue().catch(() => { });
1131
1992
  return;
1132
1993
  }
1133
- const { pass } = gateDecide(state, u, request.resourceType?.());
1994
+ const { pass } = gateDecide(state, u, request.resourceType?.(), approvedHosts);
1134
1995
  if (pass) {
1135
1996
  await route.continue().catch(() => { });
1136
1997
  }
@@ -1185,11 +2046,26 @@ export function buildTmBrowserTool(deps) {
1185
2046
  kind: "playwright",
1186
2047
  currentUrl: "about:blank",
1187
2048
  headless,
2049
+ persistentProfile: persistentDir || null,
1188
2050
  label: `playwright/${via} · ${path.basename(executable)} · ${headless ? "headless" : "headful"}`,
1189
2051
  attachments: [],
1190
2052
  blocked: state.blocked,
1191
2053
  allowedSites: state.allowedSites,
2054
+ // The browser object is the truth about liveness; a closed TAB is not it.
2055
+ alive: () => owningBrowser?.isConnected?.() !== false,
1192
2056
  async act(action, args, stepId) {
2057
+ // A context with zero pages is ALIVE — it simply has no tab. Addressing
2058
+ // the tab close_page removed threw "Target page, context or browser has
2059
+ // been closed", which the recovery in execute() reads as a dead BROWSER:
2060
+ // the lease was dropped, `close` then reported "no browser open", and the
2061
+ // user kept a window nothing was claiming any more (measured live,
2062
+ // 1-6-0-click.json step 11 — right after our own reply promised
2063
+ // "浏览器仍在运行"). An empty window gets its own honest answer here and
2064
+ // keeps its lease.
2065
+ if (!ZERO_TAB_SAFE.has(action) && context.pages().length === 0) {
2066
+ throw new Error("这个浏览器已经没有标签页了(0 个,窗口和进程都还在,你的租约我没有丢)。" +
2067
+ '要继续用 new_page 在同一个窗口开一个标签;要结束整个会话用 action:"close"(它会验进程真的退出)。');
2068
+ }
1193
2069
  if (action === "navigate" || action === "navigate_page") {
1194
2070
  const url = String(args.url ?? "").trim();
1195
2071
  // the navigation itself already cleared the allowlist (execute()
@@ -1205,7 +2081,9 @@ export function buildTmBrowserTool(deps) {
1205
2081
  traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
1206
2082
  const text = String((await state.page.evaluate("document.body ? document.body.innerText : ''")) ?? "");
1207
2083
  traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
1208
- return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
2084
+ const thin = text.replace(/\s+/g, "").length < 80;
2085
+ const title = thin ? await readTitle(state.page) : "";
2086
+ return `${pageTextOutput(sess.currentUrl, text)}${wallNote(title, text)}${blockedNote(sess, thin)}`;
1209
2087
  }
1210
2088
  if (action === "screenshot" || action === "take_screenshot") {
1211
2089
  traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
@@ -1220,18 +2098,62 @@ export function buildTmBrowserTool(deps) {
1220
2098
  yaml = await state.page.locator("body").ariaSnapshot();
1221
2099
  }
1222
2100
  catch (e) {
1223
- throw new Error(`ariaSnapshot 不可用:${e.message} —— locator.ariaSnapshot 需要 playwright-core ≥1.49(本包 optionalDependencies 锁 1.63);未安装或过旧会走 cdp-legacy 降级,那边没有快照动作。`);
2101
+ const m = String(e?.message ?? e);
2102
+ // Only talk about VERSIONS when the accessor itself is missing. It
2103
+ // threw "…has been closed" live and blamed playwright-core 1.63 for
2104
+ // it, which sent the agent off to check dependencies instead of
2105
+ // looking at the page — a wrong diagnosis costs more than none.
2106
+ throw new Error(/not a function|is not defined|unsupported|no method/i.test(m)
2107
+ ? `ariaSnapshot 不可用:${m} —— locator.ariaSnapshot 需要 playwright-core ≥1.49(本包 optionalDependencies 锁 1.63);未安装或过旧会走 cdp-legacy 降级,那边没有快照动作。`
2108
+ : `快照读取失败:${m.slice(0, 300)}`);
1224
2109
  }
1225
2110
  const annotated = snapIndex.annotate(yaml);
1226
2111
  traj({ step_id: stepId, event: "result", tokens: estimateTokens(annotated), nodes: snapIndex.size });
1227
2112
  const body = capTokens(annotated, `…(快照超过 browserSnapshotMaxTokens=${snapshotBudget},后续行已截断——用 read 拿原文或先滚动再快照)`);
1228
2113
  const hint = state.dialogs.length ? `\n注意:有 ${state.dialogs.length} 个未处理对话框——先 handle_dialog` : "";
1229
- return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}${blockedNote(sess)}`;
2114
+ // A snapshot with nothing addressable is the one case worth spending
2115
+ // a title read on: it is either our gate, or the site asking for a
2116
+ // human, and the agent has to be told which — "0 个可寻址节点" alone
2117
+ // reads as "this site is empty".
2118
+ const thin = snapIndex.size === 0;
2119
+ const wall = thin ? wallNote(await readTitle(state.page), yaml) : "";
2120
+ return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}${wall}${blockedNote(sess, thin)}`;
1230
2121
  }
1231
2122
  if (action === "click") {
1232
2123
  const loc = targetOf("uid", "selector", args);
2124
+ const target = describeTarget(args);
2125
+ const before = await probeClickTarget(loc);
1233
2126
  await loc.click();
1234
- return `已点击 ${describeTarget(args)}${state.dialogs.length ? ";有未处理对话框——handle_dialog(同轮观察,勿另起动作)" : ""}`;
2127
+ const dialogOpen = state.dialogs.length > 0;
2128
+ let seen = before ? await observeClickEffect(loc, state.page, before) : { changed: [], after: null };
2129
+ let retried = false;
2130
+ // A delivered click that changed NOTHING is the SPA hazard this
2131
+ // verification exists for. Retrying is safe precisely BECAUSE nothing
2132
+ // changed — a toggle that worked is never clicked twice — and a dialog
2133
+ // is open means the click did land, so it is never retried.
2134
+ if (before && !seen.changed.length && !dialogOpen) {
2135
+ await settlePage(state.page);
2136
+ const again = await probeClickTarget(loc);
2137
+ await loc.click().catch(() => { });
2138
+ retried = true;
2139
+ seen = await observeClickEffect(loc, state.page, again ?? before);
2140
+ }
2141
+ traj({
2142
+ step_id: stepId,
2143
+ event: "click_verified",
2144
+ effective: seen.changed.length > 0,
2145
+ retried,
2146
+ probed: Boolean(before),
2147
+ ready: String(before?.ready ?? ""),
2148
+ });
2149
+ return clickVerdict(target, before, seen.changed, {
2150
+ retried,
2151
+ dialog: state.dialogs.length > 0,
2152
+ // The gate's own ledger rides along: "nothing changed" on a page
2153
+ // whose scripts were trimmed needs a different next move than a page
2154
+ // that is still loading.
2155
+ blocked: { count: state.blocked.count, hosts: [...state.blocked.hosts].slice(0, 5) },
2156
+ });
1235
2157
  }
1236
2158
  if (action === "fill") {
1237
2159
  const loc = targetOf("uid", "selector", args);
@@ -1277,17 +2199,24 @@ export function buildTmBrowserTool(deps) {
1277
2199
  const what = uid ? `uid "${uid}"` : text ? `文本 "${text.slice(0, 80)}"` : "";
1278
2200
  if (!uid && !text)
1279
2201
  throw new Error('wait_for 需要 text 或 uid');
1280
- const loc = uid ? targetOf("uid", "selector", { uid }) : state.page.getByText(text);
2202
+ const base = uid ? targetOf("uid", "selector", { uid }) : state.page.getByText(text);
2203
+ // A visible label matches several nodes on a real page, and playwright's
2204
+ // STRICT mode answers that by throwing its whole internal report at the
2205
+ // agent — measured live, where `wait_for` was the very recovery step our
2206
+ // own click message had just recommended. So: wait for the first match
2207
+ // and SAY that there were several, instead of blocking the way out.
2208
+ const hits = typeof base.count === "function" ? Number(await base.count().catch(() => 1)) || 1 : 1;
2209
+ const loc = hits > 1 && typeof base.first === "function" ? base.first() : base;
1281
2210
  await loc.waitFor({ state: "visible", timeout });
1282
- return `等待命中:${what}(≤${timeout}ms 内可见)`;
2211
+ return `等待命中:${what}(≤${timeout}ms 内可见)${hits > 1 ? `\n(这个文本命中 ${hits} 个元素,等的是第一个——要精确就改用 uid 寻址)` : ""}`;
1283
2212
  }
1284
2213
  if (action === "evaluate_script") {
1285
2214
  const src = String(args.function ?? args.expression ?? "").trim();
1286
2215
  if (!src)
1287
2216
  throw new Error('缺少 expression/function 参数(JS 函数源或表达式)');
1288
2217
  traj({ step_id: stepId, event: "call", kind: "evaluate" });
1289
- const value = await state.page.evaluate(src);
1290
- return capTokens(`执行结果:${JSON.stringify(value ?? null)}`, "…(结果超长已截断)");
2218
+ const value = await state.page.evaluate(evalExpression(src));
2219
+ return capTokens(renderEvalResult(value), "…(结果超长已截断)");
1291
2220
  }
1292
2221
  if (action === "list_pages") {
1293
2222
  const pages = context.pages();
@@ -1311,6 +2240,52 @@ export function buildTmBrowserTool(deps) {
1311
2240
  sess.currentUrl = String(state.page.url?.() ?? "");
1312
2241
  return `已切换到标签页 ${idx}:${sess.currentUrl}`;
1313
2242
  }
2243
+ if (action === "new_page") {
2244
+ // Multi-tab is an operator need, not a nicety: comparing two pages,
2245
+ // or reading a doc while keeping the first open, was impossible
2246
+ // because `open` on a live session only navigates the CURRENT tab.
2247
+ const p = await context.newPage();
2248
+ state.page = p;
2249
+ attach(p);
2250
+ const url = String(args.url ?? "").trim();
2251
+ const index = context.pages().indexOf(p);
2252
+ if (!url) {
2253
+ sess.currentUrl = String(p.url?.() ?? "about:blank");
2254
+ traj({ step_id: stepId, event: "call", kind: "new_page", url: "" });
2255
+ return `已新建空白标签页 ${index}(共 ${context.pages().length} 个,当前在它上面):${sess.currentUrl || "about:blank"}\n后续动作按 [uid] 寻址前请先 take_snapshot;切回去用 select_page { index }。`;
2256
+ }
2257
+ // The URL cleared the allowlist at the execute layer (that is where
2258
+ // the official dialog lives), exactly like navigate_page does.
2259
+ rememberSite(state.allowedSites, url);
2260
+ traj({ step_id: stepId, event: "call", kind: "new_page", url: url.slice(0, 200) });
2261
+ await p.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
2262
+ sess.currentUrl = p.url?.() || url;
2263
+ rememberSite(state.allowedSites, sess.currentUrl);
2264
+ return `已新建标签页 ${index} 并导航(共 ${context.pages().length} 个,当前在它上面):${url}${blockedNote(sess)}`;
2265
+ }
2266
+ if (action === "close_page") {
2267
+ const pages = context.pages();
2268
+ const raw = args.index;
2269
+ const idx = raw === undefined || raw === "" ? pages.indexOf(state.page) : Number(raw);
2270
+ if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) {
2271
+ throw new Error(`index 越界(0..${pages.length - 1})——先 list_pages`);
2272
+ }
2273
+ const victim = pages[idx];
2274
+ await Promise.resolve(victim.close?.()).catch(() => { });
2275
+ const rest = context.pages();
2276
+ const closedIndex = idx;
2277
+ if (!rest.length) {
2278
+ state.page = victim;
2279
+ sess.currentUrl = "";
2280
+ return `标签页 ${closedIndex} 已关闭,这是最后一个——浏览器仍在运行。` +
2281
+ `要结束整个会话用 action:"close"(它会验进程真的退出);要继续就用 new_page 再开一个。`;
2282
+ }
2283
+ state.page = rest.includes(state.page) ? state.page : rest[rest.length - 1];
2284
+ attach(state.page);
2285
+ const nextIdx = rest.indexOf(state.page);
2286
+ sess.currentUrl = String(state.page.url?.() ?? "");
2287
+ return `标签页 ${closedIndex} 已关闭,剩 ${rest.length} 个,当前在 ${nextIdx}:${sess.currentUrl}`;
2288
+ }
1314
2289
  if (action === "list_console_messages") {
1315
2290
  const lines = state.consoleBuf.map((m, i) => `${i + 1}. [${m.type}] ${m.text.slice(0, 300)}`);
1316
2291
  if (args.clear)
@@ -1355,66 +2330,108 @@ export function buildTmBrowserTool(deps) {
1355
2330
  alivePages = 0;
1356
2331
  }
1357
2332
  const connected = browser ? Boolean(browser.isConnected?.()) : false;
1358
- const closed = errs.length === 0 && alivePages === 0 && !connected;
2333
+ // The connection is NOT the process. Wait for the pid to actually
2334
+ // disappear; if it refuses, terminate it once and re-check. Only then
2335
+ // may this line say 已确认关闭 — an agent quotes it to the user as
2336
+ // proof the window is gone, and a lingering msedge tree is not gone.
2337
+ const pidWasThere = pidAlive(browserPid);
2338
+ const processGone = browserPid > 0 ? await waitForPidExit(browserPid) : true;
2339
+ const pidNote = browserPid > 0
2340
+ ? processGone
2341
+ ? pidWasThere
2342
+ ? `进程 ${browserPid} 已退出`
2343
+ : `进程 ${browserPid} 早已不在`
2344
+ : `进程 ${browserPid} 仍在(已尝试终止但系统未放行)`
2345
+ : "未取得浏览器 pid,进程未核验";
2346
+ // The pid check is what makes the word 确认 mean anything. An
2347
+ // unverifiable close must not borrow the verified sentence, because an
2348
+ // agent quotes this line to the user as proof the window is gone.
2349
+ const verified = browserPid > 0 && processGone;
2350
+ const closed = errs.length === 0 && alivePages === 0 && !connected && processGone;
1359
2351
  const profile = persistentDir
1360
2352
  ? `持久配置目录已保留(下次 open 复用登录态):${persistentDir}`
1361
2353
  : "临时配置目录由 playwright 自行回收";
1362
- if (closed)
1363
- return { closed: true, text: `浏览器会话已确认关闭(${sess.label});${profile}。` };
2354
+ if (closed && verified)
2355
+ return { closed: true, text: `浏览器会话已确认关闭(${sess.label} · ${pidNote});${profile}。` };
2356
+ if (closed) {
2357
+ return {
2358
+ closed: false,
2359
+ text: `连接与窗口已释放,但进程未核验(${sess.label} · ${pidNote})——这次没拿到浏览器 pid,` +
2360
+ `所以这句不是"已确认关闭"。若桌面上窗口仍在请手动关闭;反复出现可换 TM_BROWSER_ENGINE=cdp-legacy 试一次。${profile}。`,
2361
+ };
2362
+ }
1364
2363
  return {
1365
2364
  closed: false,
1366
2365
  text: `警告:关闭未完全成功(${sess.label}${errs.length ? `:${errs.join("; ")}` : ""})——` +
1367
- `残留标签页 ${alivePages} 个${connected ? ",浏览器进程仍处于连接状态" : ""}。` +
1368
- `窗口可能仍在前台,需要用户手动关闭。${profile}。`,
2366
+ `残留标签页 ${alivePages} 个${connected ? ",浏览器进程仍处于连接状态" : ""} · ${pidNote}。` +
2367
+ // Three states, and the sentence must match the one we are in. A
2368
+ // dead pid cannot have a window on the desktop, so telling the user
2369
+ // to go hunt it sends them after something that is not there — and
2370
+ // the leftover count is OUR own stale reference (close_page leaves
2371
+ // state.page on the tab it closed). No pid at all is a DIFFERENT
2372
+ // fact: nothing was verified, so the window may genuinely remain.
2373
+ (browserPid > 0 && processGone
2374
+ ? `进程已确认不在(pid ${browserPid} 已退出),所以桌面上不该还有它的窗口——那个"残留标签页"是我自己留着的路由引用,不是窗口,你不用去找。${profile}。`
2375
+ : browserPid > 0
2376
+ ? `窗口很可能仍在前台(pid ${browserPid} 还在),请用户手动关闭该浏览器窗口。${profile}。`
2377
+ : `这次没拿到浏览器 pid,进程无法核验——若桌面上窗口仍在请手动关闭。${profile}。`),
1369
2378
  };
1370
2379
  },
1371
2380
  };
1372
2381
  return sess;
1373
2382
  }
1374
2383
  // ---------- engine-neutral session lifecycle ----------
1375
- /** Idle reaper. The pre-v1.5.13 layer had NO cleanup path other than the
1376
- * agent remembering to call `close` — and an agent that believes it
1377
- * closed the window (issue #3) leaves the user staring at a Chromium
1378
- * window nobody owns. An untouched session now closes itself and tells
1379
- * the user it did. */
1380
- let idleTimer = null;
1381
- const armIdle = () => {
1382
- if (idleTimer)
1383
- clearTimeout(idleTimer);
1384
- idleTimer = null;
1385
- if (!idleCloseMs || !session)
2384
+ /** Idle reaper, PER LEASE. The pre-v1.5.13 layer had NO cleanup path other
2385
+ * than the agent remembering to call `close` — and an agent that believes it
2386
+ * closed the window (issue #3) leaves the user staring at a Chromium window
2387
+ * nobody owns. With one browser per caller the timer belongs to the lease:
2388
+ * one agent going quiet must not keep another's window alive, and a reap must
2389
+ * never touch a browser somebody is still driving. */
2390
+ function armIdle(lease) {
2391
+ clearIdle(lease.id);
2392
+ if (!idleCloseMs)
1386
2393
  return;
1387
- idleTimer = setTimeout(() => {
1388
- idleTimer = null;
1389
- if (!session)
2394
+ const t = setTimeout(() => {
2395
+ idleTimers.delete(lease.id);
2396
+ if (!leases.has(lease.id))
1390
2397
  return;
1391
- traj({ step_id: "browser", event: "idle_close", idle_ms: idleCloseMs });
1392
- void closeActive()
1393
- .then((r) => notify?.(`tm_browser 空闲 ${Math.round(idleCloseMs / 1000)}s,会话已自动关闭${r.closed ? "(窗口应已消失)" : "——但窗口可能仍需手动关闭"}`))
2398
+ traj({ step_id: "browser", event: "idle_close", idle_ms: idleCloseMs, id: lease.id, agent: lease.agent });
2399
+ void closeLease(lease)
2400
+ .then((r) => notify?.(`tm_browser ${lease.id}(${lease.agent || "agent"} 的浏览器)空闲 ${Math.round(idleCloseMs / 1000)}s,已自动关闭${r.closed ? "(窗口应已消失)" : "——但窗口可能仍需手动关闭"}`))
1394
2401
  .catch(() => { });
1395
2402
  }, idleCloseMs);
1396
- idleTimer.unref?.();
1397
- };
1398
- async function ensureSession(headless) {
1399
- if (session)
1400
- return session;
2403
+ t.unref?.();
2404
+ idleTimers.set(lease.id, t);
2405
+ }
2406
+ /** The caller's browser, launched on first use. `open` on an existing lease
2407
+ * navigates that window instead of spawning a second one. */
2408
+ async function ensureLease(headless, caller) {
2409
+ const mine = ownedBy(caller.owner);
2410
+ if (mine.length)
2411
+ return mine[0];
1401
2412
  const sel = await selection();
1402
- session =
1403
- sel.kind === "playwright" && sel.pw
1404
- ? await openPlaywrightSession(sel.pw, headless)
1405
- : await openLegacySession(headless);
1406
- return session;
1407
- }
1408
- async function closeActive() {
1409
- const s = session;
1410
- if (!s)
2413
+ const approved = approvedFor(caller.owner);
2414
+ const session = sel.kind === "playwright" && sel.pw
2415
+ ? await openPlaywrightSession(sel.pw, headless, approved)
2416
+ : await openLegacySession(headless, approved);
2417
+ const lease = {
2418
+ id: `b${++leaseSeq}`,
2419
+ owner: caller.owner,
2420
+ agent: caller.agent,
2421
+ session,
2422
+ at: Date.now(),
2423
+ lastActivity: Date.now(),
2424
+ };
2425
+ leases.set(lease.id, lease);
2426
+ traj({ step_id: "browser", event: "lease", id: lease.id, agent: lease.agent, live: leases.size });
2427
+ return lease;
2428
+ }
2429
+ async function closeLease(lease) {
2430
+ if (!lease)
1411
2431
  return { text: "没有打开的浏览器会话。", closed: true };
1412
- session = null;
1413
- if (idleTimer) {
1414
- clearTimeout(idleTimer);
1415
- idleTimer = null;
1416
- }
1417
- return s.close();
2432
+ leases.delete(lease.id);
2433
+ clearIdle(lease.id);
2434
+ return lease.session.close();
1418
2435
  }
1419
2436
  /** Move any screenshot pixels act() produced out of the session and onto
1420
2437
  * the ToolResult. Drained per call so a stale attachment can never ride
@@ -1428,85 +2445,203 @@ export function buildTmBrowserTool(deps) {
1428
2445
  const base = typeof res === "string" ? { output: res } : res;
1429
2446
  return { ...base, attachments: atts };
1430
2447
  }
1431
- const ALL_ACTIONS = new Set([...BROWSER_PLAYWRIGHT_ACTIONS, ...BROWSER_COMPAT_ACTIONS, "navigate_page"]);
2448
+ const ALL_ACTIONS = new Set(ALL_BROWSER_ACTIONS);
2449
+ // The menu IS the table the gate below reads. A hand-written copy drifted the
2450
+ // day `new_page` landed: an agent that mistyped a verb was shown a list that
2451
+ // did not contain the capability it wanted.
2452
+ const ACTION_MENU = BROWSER_ACTION_MENU;
1432
2453
  const execute = async (rawArgs, ctx) => {
2454
+ // Set once a lease is resolved, so the catch below knows WHICH browser died
2455
+ // — with one browser per caller, dropping the wrong lease would kill an
2456
+ // agent that was working fine.
2457
+ let activeLease = null;
1433
2458
  try {
1434
2459
  const args = rawArgs ?? {};
1435
2460
  const action = String(args.action ?? "").trim();
1436
2461
  const url = String(args.url ?? "").trim();
2462
+ // Whose browser this is. The host hands every tool ctx a sessionID, and
2463
+ // a lease is keyed by it; without one (an older host, a test harness) all
2464
+ // callers share the single anonymous bucket, which is the old behaviour.
2465
+ const caller = callerOf(ctx);
2466
+ // The profile knob is checked BEFORE any engine runs: a refused value must
2467
+ // not spawn a browser that then discovers the problem.
2468
+ {
2469
+ const probe = classifyProfileDir(env);
2470
+ if (probe.refusal)
2471
+ return toToolResult(tmError(tool, "args", probe.refusal));
2472
+ }
2473
+ // Set when this call proceeded on a DIALOG approval rather than the
2474
+ // static allowlist; appended to whichever reply the action returns.
2475
+ let grantNote = "";
1437
2476
  // allowlist FIRST — an out-of-allowlist URL only proceeds after the
1438
2477
  // OFFICIAL dialog approves it (then the host passes the network layer
1439
2478
  // too); hard red lines (scheme / env-file) reject with no dialog.
1440
2479
  // NOTE: this gate runs BEFORE engine selection — a blocked target
1441
2480
  // never touches playwright nor spawns anything (§6o invariant).
1442
- if ((action === "open" || action === "navigate" || action === "navigate_page") && url) {
2481
+ // new_page carries a URL too — it must clear the SAME allowlist gate, or
2482
+ // "open a second tab" becomes a way around the official dialog.
2483
+ if ((action === "open" || action === "navigate" || action === "navigate_page" || action === "new_page") && url) {
1443
2484
  const verdict = checkWebUrl(url, allowlist);
1444
2485
  if (!verdict.ok) {
1445
2486
  if (!verdict.askable || !verdict.url) {
1446
2487
  return toToolResult(tmError(tool, "permission", verdict.message));
1447
2488
  }
1448
- const outcome = await askUserForTarget(ctx, {
2489
+ const res = await askUserForTargetDetailed(ctx, {
1449
2490
  permission: tool,
1450
2491
  patterns: [verdict.url.toString()],
1451
2492
  metadata: { tool, url: url.slice(0, 200) },
1452
2493
  });
1453
- if (outcome !== "approved") {
1454
- return toToolResult(tmError(tool, "permission", verdict.message +
1455
- (outcome === "rejected" ? "。用户未批准。" : "。宿主无法弹出确认窗口(旧版协议)。")));
2494
+ if (res.outcome !== "approved") {
2495
+ return toToolResult(tmError(tool, "permission", verdict.message + " " + askRefusalNote(res.outcome)));
1456
2496
  }
1457
- approvedHosts.add(verdict.url.hostname);
2497
+ approvedFor(caller.owner).add(verdict.url.hostname);
2498
+ // The approval is recorded in the REPLY, not only the trajectory: an
2499
+ // agent that cannot tell a saved "always" from a fresh click reports
2500
+ // "no dialog appeared" as evidence about the allowlist — measured live,
2501
+ // that is exactly what a researcher wrote into its deliverable.
2502
+ grantNote = askGrantNote(res);
2503
+ traj({
2504
+ step_id: "browser",
2505
+ event: res.autoGranted ? "silent_grant" : "dialog_approved",
2506
+ host: verdict.url.hostname,
2507
+ answered_ms: res.answeredInMs,
2508
+ });
2509
+ }
2510
+ }
2511
+ // The remedy for "our gate blanked this page". A blocked asset host used
2512
+ // to be a dead end: the only move was an env edit plus a restart, which no
2513
+ // agent can do mid-task, so it reported the site as empty instead. This
2514
+ // routes the SAME official dialog at ONE host, for ONE owner, for the
2515
+ // length of the session — and it cannot widen anything: a host already on
2516
+ // the static allowlist is refused (asking would train the user to approve
2517
+ // what is already allowed), a red-line host is refused with no dialog, and
2518
+ // the pattern handed to the dialog is the exact hostname, never a glob.
2519
+ if (action === "allow_host") {
2520
+ const host = normalizeApprovalHost(args.host);
2521
+ if (!host) {
2522
+ return toToolResult(tmError(tool, "args", '缺少或非法的 host 参数——要形如 "bkssl.bdimg.com" 的单个域名(不接受通配、URL、路径或端口)'));
1458
2523
  }
2524
+ const verdict = checkWebUrl(`https://${host}/`, allowlist);
2525
+ if (verdict.ok) {
2526
+ return toToolResult(`不用批准:${host} 本来就在静态白名单里,它的子资源不会被拦。这一页的空白另有原因——看 take_snapshot 的拦截说明(被拦的是别的域名)或用 read 拿原文。`);
2527
+ }
2528
+ if (!verdict.askable || !verdict.url) {
2529
+ return toToolResult(tmError(tool, "permission", `拒绝放行 ${host}:${verdict.message}`));
2530
+ }
2531
+ const outcome = await askUserForTarget(ctx, {
2532
+ permission: tool,
2533
+ patterns: [host],
2534
+ metadata: { tool, host, reason: "被拦的跨域脚本域名,放行后需重新导航" },
2535
+ });
2536
+ if (outcome !== "approved") {
2537
+ return toToolResult(tmError(tool, "permission", `未放行 ${host}。` + askRefusalNote(outcome)));
2538
+ }
2539
+ approvedFor(caller.owner).add(host);
2540
+ traj({ step_id: "browser", event: "host_approved", host, agent: caller.agent });
2541
+ const mine = ownedBy(caller.owner)[0];
2542
+ return toToolResult(`${mine ? `[${mine.id}] ` : ""}已按用户批准临时放行 ${host}(只对这个浏览器、本次会话,不改任何配置文件)。` +
2543
+ `现在重新 navigate 一次同一页——门禁在请求时判定,已经加载完的页面不会自己把脚本补回来。`);
1459
2544
  }
1460
2545
  if (action === "open") {
1461
2546
  if (!url)
1462
2547
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
1463
- if (session) {
2548
+ const existing = ownedBy(caller.owner)[0];
2549
+ if (existing) {
1464
2550
  // already open: navigate instead of spawning a second browser.
1465
2551
  // Report the mode the RUNNING instance is actually in — the old
1466
2552
  // code echoed the REQUESTED mode, so a sticky session could be
1467
2553
  // described as headful while it was a headless leftover.
1468
- const out = await session.act("navigate", { url }, "browser");
1469
- armIdle();
1470
- return withAttachments(toToolResult(`复用已运行的会话(${session.headless ? "无头" : "有头窗口"} · ${session.label})。${out}`), session);
2554
+ // Tracked as the active lease: a browser that dies HERE must be
2555
+ // dropped too, or the next open hands back the same corpse.
2556
+ activeLease = existing;
2557
+ const out = await existing.session.act("navigate", { url }, "browser");
2558
+ armIdle(existing);
2559
+ return withAttachments(toToolResult(`[${existing.id}] 复用你自己的浏览器(${existing.session.headless ? "无头" : "有头窗口"} · ${existing.session.label})。${out}${grantNote}`), existing.session);
1471
2560
  }
1472
2561
  // Mode is an OPERATOR decision (TM_BROWSER_HEADLESS), never a model
1473
2562
  // arg: a model that once passed headless:"false" (string → truthy
1474
2563
  // under Boolean()) pinned the whole process to headless and every
1475
2564
  // anti-bot gate in the run failed.
1476
2565
  const headless = resolveHeadless(env);
1477
- const s = await ensureSession(headless);
2566
+ const lease = await ensureLease(headless, caller);
2567
+ activeLease = lease;
2568
+ const s = lease.session;
1478
2569
  const out = await s.act("navigate", { url }, "browser");
1479
- traj({ step_id: "browser", event: "open", headless, label: s.label });
2570
+ traj({ step_id: "browser", event: "open", headless, label: s.label, id: lease.id, agent: lease.agent });
1480
2571
  const mode = s.headless ? "无头" : "有头窗口";
1481
- const profile = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim() ? "持久登录配置" : "隔离临时配置";
2572
+ // From the SESSION, never from the environment: this line used to re-read
2573
+ // TM_BROWSER_USER_DATA_DIR at reply time, so a session that had degraded
2574
+ // to cdp-legacy (temp profile) announced 持久登录配置.
2575
+ const profile = s.persistentProfile ? "持久登录配置" : "隔离临时配置";
1482
2576
  const note = lastSel && preference !== "cdp-legacy" && lastSel.kind === "cdp-legacy"
1483
2577
  ? `\n(引擎:cdp-legacy 降级——${lastSel.reason ?? ""};完整 16 动作需 npm install playwright-core + node≥20)`
1484
2578
  : "";
1485
2579
  const guard = subPolicy === "off"
1486
2580
  ? `\n(子资源策略=off:跨域图片/脚本一律拦截,页面可能显示不全——TM_BROWSER_SUBRESOURCE=same-site 可恢复)`
1487
2581
  : "";
1488
- armIdle();
1489
- return withAttachments(toToolResult(`浏览器已启动(${mode},${profile} · ${s.label})。${out}${note}${guard}`), s);
2582
+ const others = liveLeases().length - 1;
2583
+ // The close duty belongs in the reply that OPENS the window, not only in
2584
+ // the tool description: a long-lived host never re-reads a description
2585
+ // (the same finding that moved the date onto the search header), and the
2586
+ // agent's attention is here — the moment it stops needing the page is the
2587
+ // moment it forgets the window exists. When the reaper is off, say so
2588
+ // rather than promising a cleanup that will not come.
2589
+ const duty = idleCloseMs
2590
+ ? `\n用完必须 action:"close"(窗口用户看得见;空闲 ${Math.round(idleCloseMs / 1000)}s 我会替你关掉并提示他)。`
2591
+ : `\n用完必须 action:"close"——TM_BROWSER_IDLE_MS=0,没有空闲回收,你不关它就一直开着。`;
2592
+ armIdle(lease);
2593
+ return withAttachments(toToolResult(`[${lease.id}] 浏览器已启动(${mode},${profile} · ${s.label})。${out}${note}${guard}` +
2594
+ `\n这个窗口的 id 是 ${lease.id},归你(${lease.agent || "本会话"}):后续动作带 id:"${lease.id}"。` +
2595
+ duty +
2596
+ (others > 0
2597
+ ? `现在共有 ${others + 1} 个浏览器在跑(别的 agent 各有各的窗口),省略 id 会被拒绝。`
2598
+ : "") +
2599
+ grantNote), s);
1490
2600
  }
1491
2601
  if (action === "navigate" || action === "navigate_page") {
1492
2602
  if (!url)
1493
2603
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
1494
- if (!session)
1495
- return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
1496
- const out = await session.act(action, { url }, "browser");
1497
- armIdle();
1498
- return withAttachments(toToolResult(out), session);
2604
+ const got = resolveLease(args, caller);
2605
+ if ("error" in got)
2606
+ return toToolResult(tmError(tool, "args", got.error));
2607
+ activeLease = got.lease;
2608
+ const out = await got.lease.session.act(action, { url }, "browser");
2609
+ armIdle(got.lease);
2610
+ return withAttachments(toToolResult(`[${got.lease.id}] ${out}${grantNote}`), got.lease.session);
1499
2611
  }
1500
2612
  if (action === "close") {
1501
- traj({ step_id: "browser", event: "close" });
1502
- const r = await closeActive();
1503
- return toToolResult(r.text);
2613
+ // `id:"all"` closes every browser the CALLER owns — never anybody
2614
+ // else's, which is the one thing a shared kill switch would get wrong.
2615
+ const mine = ownedBy(caller.owner);
2616
+ if (String(args.id ?? "").trim().toLowerCase() === "all") {
2617
+ if (!mine.length)
2618
+ return toToolResult(tmError(tool, "args", "你没有打开的浏览器。"));
2619
+ traj({ step_id: "browser", event: "close", id: "all", count: mine.length, agent: caller.agent });
2620
+ const parts = [];
2621
+ for (const l of mine)
2622
+ parts.push(`[${l.id}] ${(await closeLease(l)).text}`);
2623
+ return toToolResult(parts.join("\n"));
2624
+ }
2625
+ const got = resolveLease(args, caller);
2626
+ if ("error" in got)
2627
+ return toToolResult(tmError(tool, "args", got.error));
2628
+ traj({ step_id: "browser", event: "close", id: got.lease.id, agent: caller.agent });
2629
+ const r = await closeLease(got.lease);
2630
+ return toToolResult(`[${got.lease.id}] ${r.text}`);
1504
2631
  }
1505
2632
  if (!ALL_ACTIONS.has(action)) {
1506
- return toToolResult(tmError(tool, "args", `未知 action "${String(action).slice(0, 30)}"——可用: open | navigate | take_snapshot | click | fill | hover | drag | press_key | select_page | upload_file | wait_for | evaluate_script | list_console_messages | list_network_requests | list_pages | take_screenshot | handle_dialog | read | screenshot | close(快照优先:先 take_snapshot,按 [uid=…] 寻址)`));
2633
+ return toToolResult(tmError(tool, "args", `未知 action "${String(action).slice(0, 30)}"——可用: ${ACTION_MENU}(快照优先:先 take_snapshot,按 [uid=…] 寻址)`));
1507
2634
  }
1508
- if (!session)
1509
- return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
2635
+ const got = resolveLease(args, caller);
2636
+ if ("error" in got)
2637
+ return toToolResult(tmError(tool, "args", got.error));
2638
+ const lease = got.lease;
2639
+ // Bound locally so the dispatch below reads the way it did when the whole
2640
+ // process had one browser — except `session` now means "the CALLER's
2641
+ // browser", never "the browser".
2642
+ const session = lease.session;
2643
+ activeLease = lease;
2644
+ armIdle(lease);
1510
2645
  // M1: upload_file SOURCE containment runs at the execute layer — the
1511
2646
  // engine act() has no host ctx. Out-of-P2 paths and R6 env files are
1512
2647
  // refused before setInputFiles can move any bytes.
@@ -1523,6 +2658,16 @@ export function buildTmBrowserTool(deps) {
1523
2658
  // cannot drift from the other.
1524
2659
  if (action === "evaluate_script" && needsEvalConsent(evalAskPolicy, session.evalApproved)) {
1525
2660
  const target = hostOnly(session.currentUrl);
2661
+ // An error page or a blank tab has no host to consent to. Asking the
2662
+ // official dialog about `evaluate_script:` with an EMPTY pattern gives
2663
+ // the user a prompt they cannot judge, and the refusal then reads
2664
+ // "未获批准(目标站点 )" — seen live on a chrome-error:// tab.
2665
+ if (!/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(target)) {
2666
+ const where = String(session.currentUrl ?? "").slice(0, 80) || "空白页";
2667
+ traj({ step_id: "browser", event: "eval_refused_no_host", where });
2668
+ return toToolResult(tmError(tool, "permission", `evaluate_script 被拒绝:当前页面不是可识别的 http(s) 站点(${where}),没有可批准的主机。` +
2669
+ `先 open/navigate 到一个真实页面再执行 JS——对着错误页要权限,用户无法判断该不该批。`));
2670
+ }
1526
2671
  const outcome = await askUserForTarget(ctx, {
1527
2672
  permission: tool,
1528
2673
  patterns: [`evaluate_script:${target}`],
@@ -1535,17 +2680,18 @@ export function buildTmBrowserTool(deps) {
1535
2680
  });
1536
2681
  traj({ step_id: "browser", event: "eval_consent", host: target, verdict: outcome });
1537
2682
  if (outcome !== "approved") {
1538
- return toToolResult(tmError(tool, "permission", outcome === "unavailable"
2683
+ return toToolResult(tmError(tool, "permission", (outcome === "unavailable"
1539
2684
  ? `evaluate_script 需要官方确认窗授权,本宿主没有 ask 桥——已拒绝执行。TM_BROWSER_ASK_EVAL=off 可关掉这道确认(不建议:这个动词能在你已登录的浏览器里读任意页面数据)。`
1540
- : `evaluate_script 未获批准(目标站点 ${target}),已拒绝执行。改用 take_snapshot/read 的受治理读取,或让用户单独批准。`));
2685
+ : `evaluate_script 未获批准(目标站点 ${target}),已拒绝执行。改用 take_snapshot/read 的受治理读取,或让用户单独批准。`) +
2686
+ " " +
2687
+ askRefusalNote(outcome)));
1541
2688
  }
1542
2689
  session.evalApproved = true;
1543
2690
  }
1544
2691
  const stepId = pipelines.nextStepId();
1545
2692
  const out = await session.act(action, args, stepId);
1546
2693
  if (action !== "evaluate_script") {
1547
- armIdle();
1548
- return withAttachments(toToolResult(out), session);
2694
+ return withAttachments(toToolResult(`[${lease.id}] ${out}${grantNote}`), session);
1549
2695
  }
1550
2696
  // The value is masked BEFORE it can reach the context window, the run
1551
2697
  // store or the trajectory; the agent is told a mask happened (a silent
@@ -1553,28 +2699,65 @@ export function buildTmBrowserTool(deps) {
1553
2699
  const red = redactEvalResult(out);
1554
2700
  if (red.masked.length) {
1555
2701
  traj({ step_id: "browser", event: "eval_redacted", kinds: red.masked.join(",") });
1556
- armIdle();
1557
- return withAttachments(toToolResult(`${red.text}\n注意:结果里有 ${red.masked.join("、")} 被识别为密钥形状并已脱敏——不是页面没有这些值,是插件不把它们送进上下文。需要它们请让用户自己在浏览器里看。`), session);
2702
+ return withAttachments(toToolResult(`[${lease.id}] ${red.text}\n注意:结果里有 ${red.masked.join("、")} 被识别为密钥形状并已脱敏——不是页面没有这些值,是插件不把它们送进上下文。需要它们请让用户自己在浏览器里看。`), session);
1558
2703
  }
1559
- armIdle();
1560
- return withAttachments(toToolResult(red.text), session);
2704
+ return withAttachments(toToolResult(`[${lease.id}] ${red.text}`), session);
1561
2705
  }
1562
2706
  catch (err) {
1563
2707
  const e = err;
1564
- return toToolResult(tmError(tool, "execute", String(e?.message ?? err ?? "browser 操作失败")));
2708
+ const msg = String(e?.message ?? err ?? "browser 操作失败");
2709
+ // A browser the user closed — or a launch that handed its request to an
2710
+ // already-running Edge and exited — leaves a cached session whose every
2711
+ // action throws the SAME line. Live session: three identical failures,
2712
+ // then a fourth, with no way out except our own bookkeeping. Drop the
2713
+ // corpse, say that we did, and name the one move that works. With one
2714
+ // browser per caller, only THAT lease is dropped: another agent's window
2715
+ // is not ours to declare dead.
2716
+ if (/has been closed|Target closed|Browser closed|browser has been closed/i.test(msg)) {
2717
+ const dead = activeLease;
2718
+ const leaseId = dead?.id ?? "";
2719
+ // That one string covers two very different facts — a TAB the user closed
2720
+ // and a BROWSER that died. Only the second may cost the lease: measured
2721
+ // live, closing the last tab threw this line while the window was still
2722
+ // up, the lease got dropped, `close` then reported "no browser open", and
2723
+ // the user was left holding a browser no agent could address.
2724
+ const alive = dead ? dead.session.alive?.() !== false : false;
2725
+ if (dead && !alive) {
2726
+ leases.delete(dead.id);
2727
+ clearIdle(dead.id);
2728
+ }
2729
+ traj({
2730
+ step_id: "browser",
2731
+ event: alive ? "page_dead_browser_alive" : "session_dead",
2732
+ cleared: Boolean(dead && !alive),
2733
+ id: leaseId,
2734
+ reason: msg.slice(0, 160),
2735
+ });
2736
+ return toToolResult(tmError(tool, "execute", alive
2737
+ ? `${msg.slice(0, 200)}\n但浏览器本身还连着(${leaseId} 我保留了,你的 id 不变)——死的是**这一个页面/标签**,不是实例。` +
2738
+ `下一步:list_pages 看还剩几个标签,或 new_page 在同一个窗口开一个;要结束整个会话用 close。`
2739
+ : `${msg.slice(0, 200)}\n浏览器实例已失效${dead ? `(${leaseId},${dead.agent || "本会话"} 的窗口):我已丢弃它,下一次 action:"open" 会真的重启一个新实例` : ",当前没有可复用的会话"}。` +
2740
+ `连着两次都这样,通常是本机已有同品牌浏览器在跑、新进程把请求移交给旧实例后退掉了——请用户关掉那个窗口再 open,或设 TM_BROWSER_ENGINE=cdp-legacy 换一条传输。` +
2741
+ `不要第三次重复同一个动作。`));
2742
+ }
2743
+ return toToolResult(tmError(tool, "execute", msg));
1565
2744
  }
1566
2745
  };
1567
- const DESCRIPTION = `Interactive browser (governed, Plan C): drives the user's own Chromium-family browser HEADFUL — playwright-core engine primary (npm install + node>=20; auto-degrades to the zero-dep CDP pipe when absent, snapshot actions then unavailable). Snapshot-first flow: open → take_snapshot → act by [uid] → observe again.
1568
- - 16 actions (chrome-devtools-mcp aligned): navigate_page { url } · take_snapshot { } → ariaSnapshot YAML with injected [uid=eN] · click/fill{text}/hover { uid|selector } · drag { uid, targetUid } · press_key { key } · select_page { index } · upload_file { uid, filePath } (filePath must live INSIDE the workspace/blackboard scope — .env & shell-rc files are refused) · wait_for { text|uid, timeoutMs<=3000 default } · evaluate_script { function } (arbitrary JS in YOUR browser: needs one official-dialog consent per browser session, and the result is scanned so JWT/bearer/cookie/api-key shapes never enter the context) · list_console_messages · list_network_requests · list_pages · take_screenshot { fullPage?, image? } → the PNG always lands in the run store (path in the reply); with image:true a quality-70 JPEG of the same view is attached to THIS result so a vision model can actually see it (opt-in: pixels cost context, so ask only when the screenshot is the evidence) · handle_dialog { dialogAction: accept|dismiss, promptText }.
1569
- - Compat actions: open { url } — launch/reuse + navigate (TM_BROWSER_PATH override → DEFAULT browser, Chromium-family only; the registry ProgId decides the CHANNEL, so an Edge Beta default opens Edge Beta, not stable). Isolated temp profile by default; persistent login ONLY via TM_BROWSER_USER_DATA_DIR (the real profile is never touched). navigate / read (page text) / screenshot / close also work.
1570
- - Lifecycle (the user SEES this window): headful by default — headless is an operator setting (TM_BROWSER_HEADLESS), not a parameter you can pass. An idle session closes itself (TM_BROWSER_IDLE_MS, default 180s) and the user is told. ALWAYS action:"close" when your browser work is done, and quote the tool's own close line — "已确认关闭" vs "警告:关闭未完全成功" — instead of asserting the window is gone.
1571
- - Discipline (enforced by defaults): act ONLY on uids from the latest take_snapshot — no guessed locators; one action then one observation; fold dialogs into the same round (snapshot header warns while a dialog is held); 3000 ms action budget; networkidle is never waited on; screenshots are the visual-last-resort, not the primary read.
1572
- - Network: the top-level navigation must clear the domain allowlist (seeded = tm_webfetch's hosts; out-of-allowlist open/navigate asks the user through the OFFICIAL confirmation dialog BEFORE any spawn). SUBRESOURCES then follow TM_BROWSER_SUBRESOURCE (default same-site): images/media/fonts/stylesheets load, a script/XHR loads when it belongs to a site this session actually opened, anything else is blocked and reported as a "N 个子资源被拦截" note on the next snapshot — that note means the gate trimmed the page, NOT that the site has no images. env-file URLs and non-http(s) schemes hard-reject under every policy.
1573
- - Fixed priority ladder: ① tm_* governed tools → ② user MCP/plugin tools → ③ reasoning (never fabricate). No browser installed → structured error, fall back to tm_webfetch / MCP. Role grant: team + researcher full, tester browser-only (UI verification).`;
2746
+ const DESCRIPTION = `Interactive browser (governed, Plan C): drives the user's own Chromium-family browser HEADFUL — playwright-core engine primary (npm install + node>=20; auto-degrades to the zero-dep CDP pipe when absent, snapshot actions then unavailable). Snapshot-first flow: open → take_snapshot → act by [uid] → observe again.
2747
+ - MULTI-TAB is a first-class flow: new_page { url? } opens a tab and makes it current (its url clears the SAME allowlist gate and dialog as navigate_page), list_pages numbers them, select_page { index } switches, close_page { index? } closes one and moves you to a survivor — closing the LAST tab does NOT close the browser, and the reply says so. Compare two pages without losing either.
2748
+ - ONE BROWSER PER AGENT: open returns an id ("b1") and that window is YOURS — its own uid numbering, its own current tab, its own dialog-approved hosts. Every reply is prefixed with the id it ran on. Pass id on every action once more than one browser is live (omitting it is only unambiguous while exactly one exists and it is yours); another agent's id is refused with the owner named, because sharing one window means your take_snapshot renumbers the uids they are holding and their next click lands somewhere else while STILL reporting success. close { id: "all" } closes all of yours — never anybody else's.
2749
+ - 18 actions (chrome-devtools-mcp aligned): navigate_page { url } · take_snapshot { } → ariaSnapshot YAML with injected [uid=eN] · click/fill{text}/hover { uid|selector } · drag { uid, targetUid } · press_key { key } · select_page { index } · upload_file { uid, filePath } (filePath must live INSIDE the workspace/blackboard scope — .env & shell-rc files are refused) · wait_for { text|uid, timeoutMs<=3000 default } · evaluate_script { function } (arbitrary JS in YOUR browser: needs one official-dialog consent per browser session, and the result is scanned so JWT/bearer/cookie/api-key shapes never enter the context) · list_console_messages · list_network_requests · list_pages · take_screenshot { fullPage?, image? } → the PNG always lands in the run store (path in the reply); with image:true a quality-70 JPEG of the same view is attached to THIS result so a vision model can actually see it (opt-in: pixels cost context, so ask only when the screenshot is the evidence) · handle_dialog { dialogAction: accept|dismiss, promptText }.
2750
+ - Compat actions: open { url } — launch/reuse + navigate (TM_BROWSER_PATH override → DEFAULT browser, Chromium-family only; the registry ProgId decides the CHANNEL, so an Edge Beta default opens Edge Beta, not stable). Isolated temp profile by default; persistent login ONLY via TM_BROWSER_USER_DATA_DIR — honored by BOTH engines, and a value pointing at your browser's OWN data dir (…\\Microsoft\\Edge\\User Data, google-chrome, Firefox Profiles) is refused before anything spawns, because browsing as your real identity plus a force-kill reaper is how a profile gets corrupted. navigate / read (page text) / screenshot / close also work.
2751
+ - Lifecycle (the user SEES this window): headful by default — headless is an operator setting (TM_BROWSER_HEADLESS), not a parameter you can pass. An idle session closes itself (TM_BROWSER_IDLE_MS, default 180s) and the user is told. ALWAYS action:"close" when your browser work is done, and quote the tool's own close line — "已确认关闭" vs "警告:关闭未完全成功" — instead of asserting the window is gone.
2752
+ - Discipline (enforced by defaults): act ONLY on uids from the latest take_snapshot — no guessed locators; one action then one observation; fold dialogs into the same round (snapshot header warns while a dialog is held); 3000 ms action budget; networkidle is never waited on; screenshots are the visual-last-resort, not the primary read.
2753
+ - Network: the top-level navigation must clear the domain allowlist (seeded = tm_webfetch's hosts; out-of-allowlist open/navigate asks the user through the OFFICIAL confirmation dialog BEFORE any spawn). SUBRESOURCES then follow TM_BROWSER_SUBRESOURCE (default same-site): images/media/fonts/stylesheets load, a script/XHR loads when it belongs to a site this session actually opened, anything else is blocked and reported as a "N 个子资源被拦截" note on the next snapshot — that note means the gate trimmed the page, NOT that the site has no images. env-file URLs and non-http(s) schemes hard-reject under every policy.
2754
+ - An EMPTY page gets a reason, never a shrug. A site whose own bundle sits on a brand-unrelated CDN (Baidu's bdimg.com is the case that started this) renders 0 addressable nodes behind our gate; the reply then names the blocked host and says the blankness is OURS, with two ways out: allow_host { host } — one bare domain, this browser, this session, via the OFFICIAL dialog, nothing written to any config (re-navigate afterwards, the gate decides per request) — or the user adding the host to TM_WEBFETCH_ALLOWED_DOMAINS and restarting. And if the page is really a human-verification wall (百度安全验证 / Cloudflare / access denied) the reply says so, because a wall is not an empty site: retrying it buys nothing, the move is another source or the user's own hands.
2755
+ - A dead instance is never retried: if an action reports the browser/page "has been closed", the cached session is dropped, the reply says the next open really rebuilds, and the trajectory records session_dead — do NOT repeat the same open a third time (close the competing browser window, or set TM_BROWSER_ENGINE=cdp-legacy). evaluate_script on a hostless page (chrome-error://, about:blank) is refused outright: an empty dialog pattern is something no user can judge. No browser installed → structured error, fall back to tm_webfetch / MCP. Role grant: team + researcher full, tester browser-only (UI verification).`;
1574
2756
  return {
1575
2757
  description: DESCRIPTION,
1576
2758
  args: deps.args ?? {
1577
- action: { descriptor: "action: take_snapshot|click|fill|navigate_page|... (16 playwright verbs + open/navigate/read/screenshot/close)" },
2759
+ action: { descriptor: "action: take_snapshot|click|fill|navigate_page|... (18 playwright verbs + open/navigate/read/screenshot/close)" },
2760
+ id: { descriptor: 'id: which browser — the id open gave you ("b1"). One browser per agent: required once more than one is live, and it must be YOURS (another agent\'s id is refused). close {id:"all"} closes all of yours' },
1578
2761
  url: { descriptor: "url: string (open/navigate/navigate_page, allowlisted https)" },
1579
2762
  image: { descriptor: "image: true (take_screenshot only — attach the PNG pixels to this result; default is path-only)" },
1580
2763
  uid: { descriptor: "uid: snapshot [uid=eN] token (click/fill/hover/drag/upload_file/wait_for)" },
@@ -1594,18 +2777,29 @@ export function buildTmBrowserTool(deps) {
1594
2777
  },
1595
2778
  execute,
1596
2779
  dispose: async () => {
1597
- if (idleTimer) {
1598
- clearTimeout(idleTimer);
1599
- idleTimer = null;
1600
- }
2780
+ for (const t of idleTimers.values())
2781
+ clearTimeout(t);
2782
+ idleTimers.clear();
1601
2783
  // AWAITED: the host's dispose hook is Promise-returning, and tearing
1602
2784
  // down while a close was still in flight left the window on screen.
1603
- await closeActive().catch(() => { });
2785
+ // EVERY lease, not just one caller's: plugin unload owns all of them.
2786
+ for (const l of liveLeases()) {
2787
+ await closeLease(l).catch(() => { });
2788
+ }
1604
2789
  },
1605
2790
  engineInfo: () => ({
1606
2791
  kind: lastSel?.kind ?? null,
1607
2792
  reason: lastSel?.reason ?? null,
1608
2793
  }),
2794
+ leases: () => {
2795
+ const nowMs = Date.now();
2796
+ return liveLeases().map((l) => ({
2797
+ id: l.id,
2798
+ owner: l.owner,
2799
+ agent: l.agent,
2800
+ idleMs: Math.max(0, nowMs - l.lastActivity),
2801
+ }));
2802
+ },
1609
2803
  };
1610
2804
  }
1611
2805
  //# sourceMappingURL=browser.js.map