@te-river/opencode-team-mode 1.5.12 → 1.5.13

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 (65) hide show
  1. package/README.md +27 -6
  2. package/README.zh-CN.md +25 -4
  3. package/dist/agents.d.ts +5 -1
  4. package/dist/agents.d.ts.map +1 -1
  5. package/dist/agents.js +20 -0
  6. package/dist/agents.js.map +1 -1
  7. package/dist/host-hooks.d.ts +115 -0
  8. package/dist/host-hooks.d.ts.map +1 -0
  9. package/dist/host-hooks.js +270 -0
  10. package/dist/host-hooks.js.map +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +68 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/prompts/lead.d.ts +1 -1
  15. package/dist/prompts/lead.d.ts.map +1 -1
  16. package/dist/prompts/lead.js +71 -8
  17. package/dist/prompts/lead.js.map +1 -1
  18. package/dist/prompts/shared.d.ts +1 -1
  19. package/dist/prompts/shared.d.ts.map +1 -1
  20. package/dist/prompts/shared.js +51 -2
  21. package/dist/prompts/shared.js.map +1 -1
  22. package/dist/prompts/specialists.d.ts +1 -1
  23. package/dist/prompts/specialists.d.ts.map +1 -1
  24. package/dist/prompts/specialists.js +38 -17
  25. package/dist/prompts/specialists.js.map +1 -1
  26. package/dist/tm/args-schema.d.ts +7 -1
  27. package/dist/tm/args-schema.d.ts.map +1 -1
  28. package/dist/tm/args-schema.js +9 -3
  29. package/dist/tm/args-schema.js.map +1 -1
  30. package/dist/tm/bash-timeout.d.ts +72 -0
  31. package/dist/tm/bash-timeout.d.ts.map +1 -0
  32. package/dist/tm/bash-timeout.js +100 -0
  33. package/dist/tm/bash-timeout.js.map +1 -0
  34. package/dist/tm/browser.d.ts +66 -5
  35. package/dist/tm/browser.d.ts.map +1 -1
  36. package/dist/tm/browser.js +476 -89
  37. package/dist/tm/browser.js.map +1 -1
  38. package/dist/tm/config.d.ts +68 -0
  39. package/dist/tm/config.d.ts.map +1 -1
  40. package/dist/tm/config.js +62 -0
  41. package/dist/tm/config.js.map +1 -1
  42. package/dist/tm/dispatch.d.ts +95 -0
  43. package/dist/tm/dispatch.d.ts.map +1 -0
  44. package/dist/tm/dispatch.js +369 -0
  45. package/dist/tm/dispatch.js.map +1 -0
  46. package/dist/tm/index.d.ts +27 -4
  47. package/dist/tm/index.d.ts.map +1 -1
  48. package/dist/tm/index.js +38 -3
  49. package/dist/tm/index.js.map +1 -1
  50. package/dist/tm/pty.d.ts +70 -0
  51. package/dist/tm/pty.d.ts.map +1 -0
  52. package/dist/tm/pty.js +242 -0
  53. package/dist/tm/pty.js.map +1 -0
  54. package/dist/tm/search.d.ts +51 -7
  55. package/dist/tm/search.d.ts.map +1 -1
  56. package/dist/tm/search.js +151 -37
  57. package/dist/tm/search.js.map +1 -1
  58. package/dist/tm/webfetch.d.ts +4 -0
  59. package/dist/tm/webfetch.d.ts.map +1 -1
  60. package/dist/tm/webfetch.js +33 -5
  61. package/dist/tm/webfetch.js.map +1 -1
  62. package/dist/types.d.ts +16 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/docs/installation.md +216 -17
  65. package/package.json +2 -2
@@ -30,16 +30,21 @@
30
30
  * Hardening (unchanged from the CDP era, ported to both engines):
31
31
  * - isolated temp profile (never the user's real browser profile);
32
32
  * persistent login is ONLY via an explicit TM_BROWSER_USER_DATA_DIR;
33
- * - domain allowlist enforced at the NETWORK layer: playwright
34
- * context.route() abort / CDP Fetch.requestPaused fail — every request
35
- * and every redirect hop re-checked (per-hop analog);
33
+ * - domain allowlist enforced at the NETWORK layer on the NAVIGATION
34
+ * (playwright context.route / CDP Fetch interception), re-checked on
35
+ * every redirect hop; SUBRESOURCES then follow TM_BROWSER_SUBRESOURCE
36
+ * (default same-site: passive types load, an executable resource loads
37
+ * when it belongs to a site this session opened). Gating every request
38
+ * by the content allowlist is what made pages render picture-less — the
39
+ * 21 seeded hosts contain no CDN, so img/css/js died silently;
36
40
  * - out-of-allowlist open/navigate routes through the OFFICIAL dialog
37
41
  * BEFORE any spawn; approved hosts also pass the network layer;
38
42
  * - hardened context: real-Chrome UA + zh-CN Accept-Language,
39
43
  * no-first-run / no-extensions / mute launch flags, 3 s default action
40
44
  * timeout (the prompt's "3000 ms budget" is the tool's default, not an
41
45
  * aspiration), no networkidle waits anywhere;
42
- * - dispose kills the child / closes the browser.
46
+ * - dispose AWAITS the close, and an idle session reaps itself
47
+ * (TM_BROWSER_IDLE_MS) — an orphaned visible window is a user-facing bug.
43
48
  *
44
49
  * Environment adaptivity (different OpenCode hosts):
45
50
  * - headful by default (Plan C); display-less Linux (no DISPLAY/
@@ -67,23 +72,42 @@ import { isEnvFilePath } from "../envprotect.js";
67
72
  import { tmError, toToolResult } from "./result.js";
68
73
  import { estimateTokens } from "./config.js";
69
74
  import { rmForceSafe } from "../fs-safe.js";
70
- /** Per-OS browser candidates, in preference order (first hit wins). */
75
+ /** Per-OS browser candidates, in preference order (first hit wins). ONLY a
76
+ * fallback: on Windows the registry probe below is what actually honours the
77
+ * user's chosen channel. Edge/Chrome ship each channel in its OWN install
78
+ * directory (`Microsoft\EdgeBeta`, `EdgeDev`, `EdgeCanary`), so the stable
79
+ * path alone let a Beta-default host fall back to stable Edge (observed on
80
+ * Windows 11, 2026-09-18). */
71
81
  const BROWSER_CANDIDATES = {
72
82
  win32: [
73
83
  "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
74
84
  "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
85
+ "C:\\Program Files (x86)\\Microsoft\\Edge Beta\\Application\\msedge.exe",
86
+ "C:\\Program Files\\Microsoft\\Edge Beta\\Application\\msedge.exe",
87
+ "C:\\Program Files (x86)\\Microsoft\\Edge Dev\\Application\\msedge.exe",
88
+ "C:\\Program Files\\Microsoft\\Edge Dev\\Application\\msedge.exe",
89
+ "C:\\Program Files (x86)\\Microsoft\\Edge Canary\\Application\\msedge.exe",
90
+ "C:\\Program Files\\Microsoft\\Edge Canary\\Application\\msedge.exe",
75
91
  "%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe",
76
92
  "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
77
93
  "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
94
+ "%LOCALAPPDATA%\\Google\\Chrome Beta\\Application\\chrome.exe",
95
+ "%LOCALAPPDATA%\\Google\\Chrome Dev\\Application\\chrome.exe",
96
+ "%LOCALAPPDATA%\\Google\\Chrome SxS\\Application\\chrome.exe",
78
97
  ],
79
98
  darwin: [
80
99
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
81
100
  "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
101
+ "/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
102
+ "/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
103
+ "/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
82
104
  "/Applications/Chromium.app/Contents/MacOS/Chromium",
83
105
  ],
84
106
  linux: [
85
107
  "/usr/bin/google-chrome",
86
108
  "/usr/bin/google-chrome-stable",
109
+ "/usr/bin/google-chrome-beta",
110
+ "/usr/bin/google-chrome-unstable",
87
111
  "/usr/bin/chromium-browser",
88
112
  "/usr/bin/chromium",
89
113
  "/snap/bin/chromium",
@@ -113,24 +137,84 @@ export function parseDesktopExec(text) {
113
137
  const m = /^Exec\s*=\s*(?:"([^"]+)"|(\S+))/m.exec(String(text ?? ""));
114
138
  return m ? (m[1] ?? m[2]) : null;
115
139
  }
140
+ /** Registry roots that can carry a ProgId's `shell\open\command`. Edge is
141
+ * installed machine-wide (HKLM\SOFTWARE\Classes) while a per-user Chrome
142
+ * install registers HKCU\Software\Classes — probing HKCU only is what made
143
+ * a Beta-default host come back empty and fall through to the stable
144
+ * candidate path. */
145
+ const REG_CLASSES_ROOTS = ["HKCU\\Software\\Classes", "HKLM\\SOFTWARE\\Classes"];
146
+ /** Both URL schemes the shell association is recorded under. A user can
147
+ * set the two differently, so `http` alone is not "the default browser". */
148
+ const REG_URL_SCHEMES = ["http", "https"];
149
+ /** Channel install dirs keyed off the ProgId name (`MSEdgeBetaHTM`,
150
+ * `ChromeHTML`, …) — the last-resort probe when the launch command itself is
151
+ * unreadable but the ProgId still names the channel. NOTE the real Windows
152
+ * Edge dirs carry a SPACE ("Microsoft\Edge Beta"), which is what an
153
+ * `EdgeBeta` guess silently misses; and Chrome installs per-user under
154
+ * %LOCALAPPDATA% with a different exe name. */
155
+ const CHANNEL_INSTALL_BY_PROGID = [
156
+ [/edgebeta/i, "C:\\Program Files (x86)\\Microsoft\\Edge Beta\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Beta\\Application\\msedge.exe"],
157
+ [/edgedev/i, "C:\\Program Files (x86)\\Microsoft\\Edge Dev\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Dev\\Application\\msedge.exe"],
158
+ [/edgecanary|edgeappcanary/i, "C:\\Program Files (x86)\\Microsoft\\Edge Canary\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Canary\\Application\\msedge.exe"],
159
+ [/msedge|edgehtm/i, "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"],
160
+ [/chromebeta/i, "%LOCALAPPDATA%\\Google\\Chrome Beta\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome Beta\\Application\\chrome.exe"],
161
+ [/chromedev|chromejsx/i, "%LOCALAPPDATA%\\Google\\Chrome Dev\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome Dev\\Application\\chrome.exe"],
162
+ [/chromehtml|chromechtml/i, "%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"],
163
+ ];
164
+ /** Resolve a ProgId to its executable via every classes root. `reg query`
165
+ * exits non-zero (throws) for a missing key, so each probe is wrapped. */
166
+ function progIdToExecutable(progId, run) {
167
+ for (const root of REG_CLASSES_ROOTS) {
168
+ try {
169
+ const exe = parseRegCommand(run("reg", ["query", `${root}\\${progId}\\shell\\open\\command`, "/ve"]));
170
+ if (exe)
171
+ return exe;
172
+ }
173
+ catch {
174
+ /* next root */
175
+ }
176
+ }
177
+ return null;
178
+ }
116
179
  /** The user's DEFAULT browser, when it is Chromium-family and present.
117
180
  * null → the caller falls back to the per-OS probe list (detection is
118
181
  * best-effort and must never throw into tool discovery). */
119
182
  export function defaultBrowserExecutable(env = process.env, run = defaultRunner) {
120
183
  try {
121
184
  if (process.platform === "win32") {
122
- const choice = run("reg", [
123
- "query",
124
- "HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice",
125
- "/v",
126
- "ProgId",
127
- ]);
128
- const progId = parseProgId(choice);
129
- if (!progId)
130
- return null;
131
- const cmd = run("reg", ["query", `HKCU\\Software\\Classes\\${progId}\\shell\\open\\command`, "/ve"]);
132
- const exe = parseRegCommand(cmd);
133
- return exe && isChromiumFamily(exe) && fs.existsSync(exe) ? exe : null;
185
+ const accept = (exe) => exe && isChromiumFamily(exe) && fs.existsSync(exe) ? exe : null;
186
+ for (const scheme of REG_URL_SCHEMES) {
187
+ let progId = null;
188
+ try {
189
+ progId = parseProgId(run("reg", [
190
+ "query",
191
+ `HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\${scheme}\\UserChoice`,
192
+ "/v",
193
+ "ProgId",
194
+ ]));
195
+ }
196
+ catch {
197
+ progId = null;
198
+ }
199
+ if (!progId)
200
+ continue;
201
+ const direct = accept(progIdToExecutable(progId, run));
202
+ if (direct)
203
+ return direct;
204
+ // launch command unreadable (or points at a Firefox shim) — the
205
+ // ProgId itself still names the channel, so probe that channel's
206
+ // standard install dir before giving up on the user's default.
207
+ for (const [re, ...cands] of CHANNEL_INSTALL_BY_PROGID) {
208
+ if (!re.test(progId))
209
+ continue;
210
+ for (const cand of cands) {
211
+ const hit = accept(cand.replace(/%LOCALAPPDATA%/gi, String(env.LOCALAPPDATA ?? "").trim()));
212
+ if (hit)
213
+ return hit;
214
+ }
215
+ }
216
+ }
217
+ return null;
134
218
  }
135
219
  if (process.platform === "linux") {
136
220
  const desk = run("xdg-settings", ["get", "default-web-browser"]).trim();
@@ -157,6 +241,23 @@ export function defaultBrowserExecutable(env = process.env, run = defaultRunner)
157
241
  }
158
242
  return null;
159
243
  }
244
+ /** Playwright's `channel` launch IGNORES the executable we discovered —
245
+ * `channel:"msedge"` resolves whatever playwright thinks the stable Edge is,
246
+ * so a correct Edge-Beta discovery still opened stable (the decisive half of
247
+ * the 2026-09-18 report). The channel spelling is therefore only correct
248
+ * for the exact stable-channel install dir; everything else (Beta/Dev/
249
+ * Canary, portable, an explicit TM_BROWSER_PATH) rides on executablePath. */
250
+ export function playwrightLaunchTarget(executable, opts = {}) {
251
+ const exe = String(executable ?? "");
252
+ const p = exe.replace(/\//g, "\\").toLowerCase();
253
+ if (!opts.explicitOverride) {
254
+ if (/\\microsoft\\edge\\application\\msedge\.exe$/.test(p))
255
+ return { executablePath: exe, channel: "msedge" };
256
+ if (/\\google\\chrome\\application\\chrome\.exe$/.test(p))
257
+ return { executablePath: exe, channel: "chrome" };
258
+ }
259
+ return { executablePath: exe };
260
+ }
160
261
  /** Resolve the browser executable: TM_BROWSER_PATH override → the user's
161
262
  * DEFAULT browser (Chromium-family only) → per-OS candidate paths.
162
263
  * %LOCALAPPDATA% style vars are expanded on win32. */
@@ -200,6 +301,83 @@ export function resolveHeadless(env = process.env) {
200
301
  return !env.DISPLAY && !env.WAYLAND_DISPLAY;
201
302
  return false;
202
303
  }
304
+ // ---------- subresource gate (the "pages render without images" fix) --------
305
+ /** A resource the page cannot execute code with — it can only paint. */
306
+ const PASSIVE_RESOURCE_TYPES = new Set(["image", "media", "font", "stylesheet"]);
307
+ /** Public suffixes that make the last TWO labels insufficient for a site
308
+ * comparison. Deliberately a small spelled-out list (no PSL dependency):
309
+ * an unlisted multi-label suffix yields a LONGER site key, which auto-passes
310
+ * FEWER hosts — the conservative direction for a network gate. */
311
+ const TWO_LABEL_SUFFIXES = new Set([
312
+ "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", "co.cn", "com.hk", "org.hk", "co.jp", "ne.jp",
313
+ "co.kr", "co.uk", "org.uk", "ac.uk", "com.au", "net.au", "org.au", "co.in", "com.br", "com.mx", "com.tr",
314
+ "com.ua", "com.sg", "com.my", "com.tw", "co.za", "com.pl", "com.ru", "com.vn", "com.id", "com.ar", "com.co",
315
+ ]);
316
+ /** Approximate registrable site (eTLD+1) of a hostname. */
317
+ export function siteOf(host) {
318
+ const h = String(host ?? "").toLowerCase().replace(/\.$/, "");
319
+ if (!h || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(h))
320
+ return h;
321
+ const labels = h.split(".").filter(Boolean);
322
+ if (labels.length <= 2)
323
+ return labels.join(".");
324
+ const lastTwo = labels.slice(-2).join(".");
325
+ if (TWO_LABEL_SUFFIXES.has(lastTwo))
326
+ return labels.slice(-3).join(".");
327
+ return lastTwo;
328
+ }
329
+ /** playwright and CDP spell resource types differently (`Image` vs `image`,
330
+ * `Stylesheet` vs `stylesheet`) — one normalizer keeps both engines on the
331
+ * same verdict table. */
332
+ export function normalizeResourceType(raw) {
333
+ const t = String(raw ?? "").trim().toLowerCase();
334
+ return t === "" ? "other" : t;
335
+ }
336
+ export function isPassiveResource(raw) {
337
+ return PASSIVE_RESOURCE_TYPES.has(normalizeResourceType(raw));
338
+ }
339
+ /**
340
+ * One subresource verdict, shared VERBATIM by both engines so playwright and
341
+ * cdp-legacy can never drift. `allowedSites` holds the registrable sites of
342
+ * every host this session actually navigated to (allowlist hit or dialog
343
+ * approval), which is what makes a page's own JS/CSS work without opening
344
+ * the whole internet: the governance boundary moves from "21 content domains"
345
+ * to "the site you approved", and an off-site SCRIPT still needs a dialog.
346
+ */
347
+ export function subresourcePass(opts) {
348
+ if (opts.allowlistHit)
349
+ return { pass: true, via: "allowlist" };
350
+ if (opts.approvedHost)
351
+ return { pass: true, via: "approved" };
352
+ if (opts.policy === "off")
353
+ return { pass: false, via: "blocked" };
354
+ let host;
355
+ try {
356
+ host = new URL(opts.url).hostname;
357
+ }
358
+ catch {
359
+ return { pass: false, via: "blocked" };
360
+ }
361
+ const type = normalizeResourceType(opts.resourceType);
362
+ if (PASSIVE_RESOURCE_TYPES.has(type))
363
+ return { pass: true, via: `passive:${type}` };
364
+ if (opts.policy === "passive")
365
+ return { pass: false, via: `blocked:${type}` };
366
+ return opts.allowedSites.has(siteOf(host)) ? { pass: true, via: `same-site:${type}` } : { pass: false, via: `blocked:${type}` };
367
+ }
368
+ /** A navigation (or a host the dialog approved) widens the subresource
369
+ * footprint to its own site. Called for EVERY top-level/document request
370
+ * that passed the allowlist side. */
371
+ export function rememberSite(allowedSites, url) {
372
+ try {
373
+ const host = new URL(url).hostname;
374
+ if (host)
375
+ allowedSites.add(siteOf(host));
376
+ }
377
+ catch {
378
+ /* unparseable — nothing to remember */
379
+ }
380
+ }
203
381
  class PipeCdp {
204
382
  child;
205
383
  nextId = 1;
@@ -425,6 +603,7 @@ const NAVIGATE_EVENT_TIMEOUT_MS = 15_000;
425
603
  export function buildTmBrowserTool(deps) {
426
604
  const { pipelines } = deps;
427
605
  const env = deps.env ?? process.env;
606
+ const notify = typeof deps.notify === "function" ? deps.notify : undefined;
428
607
  const tool = "tm_browser";
429
608
  const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
430
609
  let session = null;
@@ -437,6 +616,10 @@ export function buildTmBrowserTool(deps) {
437
616
  // network layer (both engines) consults this in addition to the static allowlist
438
617
  const approvedHosts = new Set();
439
618
  const snapshotBudget = Math.max(10, Number(deps.cfg?.browserSnapshotMaxTokens) || 1200);
619
+ const subPolicy = deps.cfg?.browserSubresource ?? "same-site";
620
+ const discover = deps.findExecutable ?? findBrowserExecutable;
621
+ const imageMaxBytes = Math.max(1000, Number(deps.cfg?.browserImageMaxBytes) || 400_000);
622
+ const idleCloseMs = Number(deps.cfg?.browserIdleCloseMs ?? 180_000);
440
623
  // ---------- engine selection (cached per plugin instance) ----------
441
624
  let selPromise = null;
442
625
  let lastSel = null;
@@ -479,8 +662,71 @@ export function buildTmBrowserTool(deps) {
479
662
  traj({ step_id: stepId, event: "result", bytes: png.length });
480
663
  return { file, png };
481
664
  }
482
- function shotOutput(file, bytes) {
483
- return `截图已保存(${bytes} bytes):${file}\n(PNG 已落 run store;上下文只携带路径,不携带像素。)`;
665
+ /** LLM args are booleans most of the time, but `"false"` / `"0"` strings
666
+ * are common — a bare `Boolean()` read both as TRUE, which is exactly how
667
+ * a model used to force tm_browser into headless (and get anti-bot
668
+ * blocked) on a desktop that defaults to headful. */
669
+ function isTrueArg(raw) {
670
+ if (raw === true)
671
+ return true;
672
+ if (typeof raw === "number")
673
+ return raw === 1;
674
+ if (typeof raw === "string")
675
+ return /^(1|true|yes|on)$/i.test(raw.trim());
676
+ return false;
677
+ }
678
+ /** Screenshot line + the OPT-IN pixels.
679
+ * The PNG always lands in the run store (it is the evidence artifact);
680
+ * what rides back to the MODEL is a quality-70 JPEG — a real page with
681
+ * images loaded is ~1.5 MB as PNG, which no token budget should ever
682
+ * inline, and ~150-300 KB as JPEG. Pixels stay opt-in so a UI sweep
683
+ * cannot spend context the agent never asked for. */
684
+ async function shotOutput(sess, file, png, args, captureJpeg) {
685
+ const head = `截图已保存(PNG ${png.length} bytes):${file}`;
686
+ if (!isTrueArg(args.image)) {
687
+ return `${head}\n(上下文只携带路径,不携带像素——需要真正看到画面时带 image:true 再截一次。)`;
688
+ }
689
+ let img = null;
690
+ if (captureJpeg) {
691
+ try {
692
+ img = await captureJpeg();
693
+ }
694
+ catch {
695
+ img = null;
696
+ }
697
+ }
698
+ let kind = "jpeg";
699
+ if (!img) {
700
+ img = png;
701
+ kind = "png";
702
+ }
703
+ if (img.length > imageMaxBytes) {
704
+ return `${head}\n(未附带像素:${kind.toUpperCase()} ${img.length} bytes 超过 TM_BROWSER_IMAGE_MAX_BYTES=${imageMaxBytes}。改用视口截图(去掉 fullPage)或提高该上限。)`;
705
+ }
706
+ sess.attachments.push({
707
+ type: "file",
708
+ mime: `image/${kind}`,
709
+ url: `data:image/${kind};base64,${img.toString("base64")}`,
710
+ filename: path.basename(file).replace(/\.png$/, `.${kind === "jpeg" ? "jpg" : "png"}`),
711
+ });
712
+ return `${head}\n(像素以 ${kind.toUpperCase()} ${img.length} bytes 随本条结果附带。)`;
713
+ }
714
+ /** One line telling the agent that the page is NOT actually empty — the
715
+ * subresource gate dropped N requests. Without this the model reads a
716
+ * blank-looking page and reports "该网站没有图片". */
717
+ function blockedNote(sess) {
718
+ if (!sess.blocked.count)
719
+ return "";
720
+ const n = sess.blocked.count;
721
+ const hosts = [...sess.blocked.hosts];
722
+ // RESET IN PLACE — the engine's route handler holds this very object, so
723
+ // swapping in a new one would silently stop counting after the first
724
+ // drain (and the note would never appear again for the session).
725
+ sess.blocked.count = 0;
726
+ sess.blocked.hosts.clear();
727
+ traj({ step_id: "browser", event: "blocked", count: n, hosts: hosts.slice(0, 20).join(",") });
728
+ return (`\n注意:本页有 ${n} 个子资源请求被治理白名单拦截(${hosts.slice(0, 6).join(", ")}${hosts.length > 6 ? ` …+${hosts.length - 6}` : ""})——不是站点没有内容。` +
729
+ `当前策略 TM_BROWSER_SUBRESOURCE=${subPolicy};同域资源已自动放行,跨域脚本仍需白名单或弹窗批准。`);
484
730
  }
485
731
  function pageTextOutput(url, text) {
486
732
  return `页面文本(${url}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`;
@@ -517,8 +763,52 @@ export function buildTmBrowserTool(deps) {
517
763
  return null;
518
764
  }
519
765
  // ================= cdp-legacy engine (原手写 CDP 管道,保留不删) =============
766
+ /**
767
+ * Shared network verdict for BOTH engines — one implementation, so the
768
+ * playwright and cdp-legacy legs can never disagree about what passes.
769
+ * The hard red lines (non-http scheme, remote env-file spelling) stay hard
770
+ * under EVERY subresource policy: they are not consentable, so a passive
771
+ * image or a same-site script can never relax them.
772
+ */
773
+ function gateDecide(st, url, resourceType) {
774
+ const verdict = checkWebUrl(url, allowlist);
775
+ if (!verdict.ok && !verdict.askable)
776
+ return { pass: false, via: "red-line" };
777
+ let approved = false;
778
+ try {
779
+ approved = approvedHosts.has(new URL(url).hostname);
780
+ }
781
+ catch {
782
+ approved = false;
783
+ }
784
+ const v = subresourcePass({
785
+ url,
786
+ resourceType,
787
+ policy: subPolicy,
788
+ allowlistHit: verdict.ok,
789
+ approvedHost: approved,
790
+ allowedSites: st.allowedSites,
791
+ });
792
+ if (v.pass) {
793
+ if (normalizeResourceType(resourceType) === "document")
794
+ rememberSite(st.allowedSites, url);
795
+ }
796
+ else {
797
+ st.blocked.count++;
798
+ try {
799
+ st.blocked.hosts.add(new URL(url).hostname);
800
+ }
801
+ catch {
802
+ /* no host to name */
803
+ }
804
+ }
805
+ // NO per-request trajectory: a real page is hundreds of requests and the
806
+ // log would be pure noise. Blocked verdicts aggregate into st.blocked
807
+ // and flush as ONE line (blockedNote / close).
808
+ return v;
809
+ }
520
810
  async function openLegacySession(headless) {
521
- const executable = findBrowserExecutable(env);
811
+ const executable = discover(env);
522
812
  if (!executable)
523
813
  throw discoveryError();
524
814
  const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
@@ -557,56 +847,58 @@ export function buildTmBrowserTool(deps) {
557
847
  throw new Error("浏览器启动后未找到 page target");
558
848
  const { sessionId } = (await cdp.call("Target.attachToTarget", { targetId: page.targetId, flatten: true }));
559
849
  await cdp.call("Page.enable", {}, sessionId);
560
- // network-layer allowlist enforcement (Qoder's per-hop re-check analog)
850
+ // network-layer enforcement — the SAME gateDecide the playwright leg uses
851
+ // (subresource policy + per-hop redirect re-check + dialog-approved hosts)
852
+ const allowedSites = new Set();
853
+ const blocked = { count: 0, hosts: new Set() };
561
854
  await cdp.call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }, sessionId);
562
855
  cdp.onEvent((m) => {
563
856
  if (m.method !== "Fetch.requestPaused" || m.sessionId !== sessionId)
564
857
  return;
565
858
  const requestId = String(m.params.requestId ?? "");
566
- const url = String(m.params.request?.url ?? "");
567
- const verdict = checkWebUrl(url, allowlist);
568
- let pass = verdict.ok;
569
- if (!pass) {
570
- // an approved-via-dialog host passes the network layer too
571
- try {
572
- pass = approvedHosts.has(new URL(url).hostname);
573
- }
574
- catch {
575
- pass = false;
576
- }
577
- }
859
+ const p = m.params.request ?? {};
860
+ const url = String(p.url ?? "");
861
+ const { pass } = gateDecide({ allowedSites, blocked }, url, p.resourceType);
578
862
  if (pass) {
579
863
  void cdp.call("Fetch.continueRequest", { requestId }, sessionId, 5000).catch(() => { });
580
864
  }
581
865
  else {
582
- traj({ step_id: "browser", event: "blocked", url: url.slice(0, 200) });
583
866
  void cdp.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, sessionId, 5000).catch(() => { });
584
867
  }
585
868
  });
586
869
  const sess = {
587
870
  kind: "cdp-legacy",
588
871
  currentUrl: "about:blank",
872
+ headless,
873
+ label: `cdp-legacy · ${path.basename(executable)} · pid ${child.pid ?? "?"}`,
874
+ attachments: [],
875
+ blocked,
876
+ allowedSites,
589
877
  async act(action, args, stepId) {
590
878
  if (action === "navigate" || action === "navigate_page") {
591
879
  const url = String(args.url ?? "").trim();
880
+ rememberSite(allowedSites, url);
592
881
  await cdp.call("Page.navigate", { url }, sessionId);
593
882
  await cdp.waitEvent("Page.loadEventFired", sessionId, NAVIGATE_EVENT_TIMEOUT_MS).catch(() => { });
594
883
  sess.currentUrl = url;
595
- return `已导航:${url}`;
884
+ return `已导航:${url}${blockedNote(sess)}`;
596
885
  }
597
886
  if (action === "read") {
598
887
  traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
599
888
  const ev = await cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, sessionId);
600
889
  const text = String(ev.result?.value ?? "");
601
890
  traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
602
- return pageTextOutput(sess.currentUrl, text);
891
+ return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
603
892
  }
604
893
  if (action === "screenshot" || action === "take_screenshot") {
605
894
  traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
606
895
  const shot = await cdp.call("Page.captureScreenshot", { format: "png" }, sessionId);
607
896
  const png = Buffer.from(String(shot.data ?? ""), "base64");
608
897
  const { file } = saveShotPng(new Uint8Array(png), stepId);
609
- return shotOutput(file, png.length);
898
+ return shotOutput(sess, file, png, args, async () => {
899
+ const j = await cdp.call("Page.captureScreenshot", { format: "jpeg", quality: 70 }, sessionId);
900
+ return Buffer.from(String(j.data ?? ""), "base64");
901
+ });
610
902
  }
611
903
  if (action === "list_pages") {
612
904
  const t = await cdp.call("Target.getTargets");
@@ -643,13 +935,20 @@ export function buildTmBrowserTool(deps) {
643
935
  res();
644
936
  });
645
937
  });
938
+ const dead = child.exitCode !== null;
646
939
  try {
647
940
  rmForceSafe(profileDir, { recursive: true });
648
941
  }
649
942
  catch {
650
943
  /* a locked leftover temp dir is reclaimed by the OS — never fail close */
651
944
  }
652
- return "浏览器会话已关闭,临时配置目录已清理。";
945
+ return dead
946
+ ? { closed: true, text: "浏览器进程已退出(cdp-legacy),临时配置目录已清理。" }
947
+ : {
948
+ closed: false,
949
+ text: `警告:cdp-legacy 子进程 pid ${child.pid ?? "?"} 未在 3s 内退出,窗口可能仍在前台——` +
950
+ `请手动关闭该浏览器窗口(临时目录 ${profileDir})。`,
951
+ };
653
952
  },
654
953
  };
655
954
  const onExit = () => {
@@ -669,7 +968,7 @@ export function buildTmBrowserTool(deps) {
669
968
  }
670
969
  // ================= playwright engine (primary) ==============================
671
970
  async function openPlaywrightSession(pw, headless) {
672
- const executable = findBrowserExecutable(env);
971
+ const executable = discover(env);
673
972
  if (!executable)
674
973
  throw discoveryError();
675
974
  const launchArgs = [
@@ -687,32 +986,35 @@ export function buildTmBrowserTool(deps) {
687
986
  viewport: headless ? undefined : null,
688
987
  };
689
988
  const persistentDir = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim();
690
- // R2: channel launches are cross-version-fragile (official warning), so
691
- // they are a SMOKE-ONLY attempt; any failure retries with the explicit
692
- // executablePath from our own discovery layer.
693
- const channel = /msedge/i.test(executable) ? "msedge" : /chrome/i.test(executable) ? "chrome" : undefined;
989
+ // R2 (revised 2026-09-18): playwright's `channel` resolves the executable
990
+ // ITSELF, which silently overrode our discovery (a Beta-default Windows
991
+ // host opened STABLE Edge). executablePath is now the primary launch and
992
+ // the channel spelling only a fallback — the opposite of the old order.
993
+ const target = playwrightLaunchTarget(executable, {
994
+ explicitOverride: String(env.TM_BROWSER_PATH ?? "").trim() !== "",
995
+ });
694
996
  let browser = null;
695
997
  let context;
696
998
  let via = "executablePath";
697
999
  if (persistentDir) {
698
1000
  context = await pw.chromium.launchPersistentContext(persistentDir, {
699
1001
  ...contextOpts,
700
- ...(channel ? { channel } : { executablePath: executable }),
1002
+ executablePath: target.executablePath,
701
1003
  headless,
702
1004
  args: launchArgs,
703
1005
  });
704
- via = `persistent(${channel ?? "executablePath"})`;
1006
+ via = `persistent(executablePath)`;
705
1007
  }
706
1008
  else {
707
1009
  try {
708
- browser = await pw.chromium.launch({ ...(channel ? { channel } : { executablePath: executable }), headless, args: launchArgs });
709
- via = channel ? `channel(${channel})` : "executablePath";
1010
+ browser = await pw.chromium.launch({ executablePath: target.executablePath, headless, args: launchArgs });
1011
+ via = "executablePath";
710
1012
  }
711
1013
  catch (e1) {
712
- if (!channel)
1014
+ if (!target.channel)
713
1015
  throw e1;
714
- browser = await pw.chromium.launch({ executablePath: executable, headless, args: launchArgs });
715
- via = "executablePath(channel 回退)";
1016
+ browser = await pw.chromium.launch({ channel: target.channel, headless, args: launchArgs });
1017
+ via = `channel(${target.channel})`;
716
1018
  }
717
1019
  context = await browser.newContext(contextOpts);
718
1020
  }
@@ -722,6 +1024,8 @@ export function buildTmBrowserTool(deps) {
722
1024
  dialogs: [],
723
1025
  consoleBuf: [],
724
1026
  netBuf: [],
1027
+ allowedSites: new Set(),
1028
+ blocked: { count: 0, hosts: new Set() },
725
1029
  };
726
1030
  const attached = new WeakSet();
727
1031
  const attach = (p) => {
@@ -775,22 +1079,12 @@ export function buildTmBrowserTool(deps) {
775
1079
  await route.continue().catch(() => { });
776
1080
  return;
777
1081
  }
778
- const verdict = checkWebUrl(u, allowlist);
779
- let pass = verdict.ok;
780
- if (!pass) {
781
- try {
782
- pass = approvedHosts.has(new URL(u).hostname);
783
- }
784
- catch {
785
- pass = false;
786
- }
787
- }
1082
+ const { pass } = gateDecide(state, u, request.resourceType?.());
788
1083
  if (pass) {
789
1084
  await route.continue().catch(() => { });
790
1085
  }
791
1086
  else {
792
1087
  // per-hop re-check lands here for every redirect hop too
793
- traj({ step_id: "browser", event: "blocked", url: u.slice(0, 200) });
794
1088
  state.netBuf.push({ method: request.method?.() ?? "?", url: u.slice(0, 300), blocked: true });
795
1089
  await route.abort().catch(() => { });
796
1090
  }
@@ -839,25 +1133,34 @@ export function buildTmBrowserTool(deps) {
839
1133
  const sess = {
840
1134
  kind: "playwright",
841
1135
  currentUrl: "about:blank",
1136
+ headless,
1137
+ label: `playwright/${via} · ${path.basename(executable)} · ${headless ? "headless" : "headful"}`,
1138
+ attachments: [],
1139
+ blocked: state.blocked,
1140
+ allowedSites: state.allowedSites,
842
1141
  async act(action, args, stepId) {
843
1142
  if (action === "navigate" || action === "navigate_page") {
844
1143
  const url = String(args.url ?? "").trim();
1144
+ // the navigation itself already cleared the allowlist (execute()
1145
+ // asked the user otherwise) — its SITE may now load subresources
1146
+ rememberSite(state.allowedSites, url);
845
1147
  // domcontentloaded ONLY — networkidle is banned (prompt discipline)
846
1148
  await state.page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
847
1149
  sess.currentUrl = state.page.url?.() || url;
848
- return `已导航:${url}`;
1150
+ rememberSite(state.allowedSites, sess.currentUrl);
1151
+ return `已导航:${url}${blockedNote(sess)}`;
849
1152
  }
850
1153
  if (action === "read") {
851
1154
  traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
852
1155
  const text = String((await state.page.evaluate("document.body ? document.body.innerText : ''")) ?? "");
853
1156
  traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
854
- return pageTextOutput(sess.currentUrl, text);
1157
+ return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
855
1158
  }
856
1159
  if (action === "screenshot" || action === "take_screenshot") {
857
1160
  traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
858
1161
  const bytes = await state.page.screenshot({ type: "png", fullPage: Boolean(args.fullPage) });
859
1162
  const { file, png } = saveShotPng(bytes, stepId);
860
- return shotOutput(file, png.length);
1163
+ return shotOutput(sess, file, png, args, async () => Buffer.from(await state.page.screenshot({ type: "jpeg", quality: 70, fullPage: Boolean(args.fullPage) })));
861
1164
  }
862
1165
  if (action === "take_snapshot") {
863
1166
  traj({ step_id: stepId, event: "call", kind: "snapshot", url: sess.currentUrl.slice(0, 200) });
@@ -866,13 +1169,13 @@ export function buildTmBrowserTool(deps) {
866
1169
  yaml = await state.page.locator("body").ariaSnapshot();
867
1170
  }
868
1171
  catch (e) {
869
- throw new Error(`ariaSnapshot 不可用(playwright-core 版本过旧或缺失):${e.message} —— 升级 playwright-core(需 ≥1.65 才有 locator.ariaSnapshot)`);
1172
+ throw new Error(`ariaSnapshot 不可用:${e.message} —— locator.ariaSnapshot 需要 playwright-core ≥1.49(本包 optionalDependencies 锁 1.63);未安装或过旧会走 cdp-legacy 降级,那边没有快照动作。`);
870
1173
  }
871
1174
  const annotated = snapIndex.annotate(yaml);
872
1175
  traj({ step_id: stepId, event: "result", tokens: estimateTokens(annotated), nodes: snapIndex.size });
873
1176
  const body = capTokens(annotated, `…(快照超过 browserSnapshotMaxTokens=${snapshotBudget},后续行已截断——用 read 拿原文或先滚动再快照)`);
874
1177
  const hint = state.dialogs.length ? `\n注意:有 ${state.dialogs.length} 个未处理对话框——先 handle_dialog` : "";
875
- return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}`;
1178
+ return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}${blockedNote(sess)}`;
876
1179
  }
877
1180
  if (action === "click") {
878
1181
  const loc = targetOf("uid", "selector", args);
@@ -984,20 +1287,63 @@ export function buildTmBrowserTool(deps) {
984
1287
  },
985
1288
  async close() {
986
1289
  state.dialogs.length = 0;
987
- await Promise.resolve(context.close()).catch(() => { });
1290
+ const errs = [];
1291
+ const fail = (label2) => (e) => {
1292
+ errs.push(`${label2}: ${String(e?.message ?? e)}`);
1293
+ };
1294
+ await Promise.resolve(context.close()).catch(fail("context"));
988
1295
  if (browser)
989
- await Promise.resolve(browser.close()).catch(() => { });
990
- if (persistentDir) {
991
- return `浏览器会话已关闭;持久配置目录已保留(下次 open 复用登录态):${persistentDir}`;
1296
+ await Promise.resolve(browser.close()).catch(fail("browser"));
1297
+ // VERIFY before claiming — an agent quotes this line to the user as
1298
+ // "浏览器已关闭", so a swallowed failure must not read like success.
1299
+ let alivePages = 0;
1300
+ try {
1301
+ alivePages = context.pages().length;
1302
+ }
1303
+ catch {
1304
+ alivePages = 0;
992
1305
  }
993
- // playwright owns (and self-reclaims) the ephemeral profile under
994
- // the browser process — nothing on our side to delete
995
- return "浏览器会话已关闭,临时配置目录已清理。";
1306
+ const connected = browser ? Boolean(browser.isConnected?.()) : false;
1307
+ const closed = errs.length === 0 && alivePages === 0 && !connected;
1308
+ const profile = persistentDir
1309
+ ? `持久配置目录已保留(下次 open 复用登录态):${persistentDir}`
1310
+ : "临时配置目录由 playwright 自行回收";
1311
+ if (closed)
1312
+ return { closed: true, text: `浏览器会话已确认关闭(${sess.label});${profile}。` };
1313
+ return {
1314
+ closed: false,
1315
+ text: `警告:关闭未完全成功(${sess.label}${errs.length ? `:${errs.join("; ")}` : ""})——` +
1316
+ `残留标签页 ${alivePages} 个${connected ? ",浏览器进程仍处于连接状态" : ""}。` +
1317
+ `窗口可能仍在前台,需要用户手动关闭。${profile}。`,
1318
+ };
996
1319
  },
997
1320
  };
998
1321
  return sess;
999
1322
  }
1000
1323
  // ---------- engine-neutral session lifecycle ----------
1324
+ /** Idle reaper. The pre-v1.5.13 layer had NO cleanup path other than the
1325
+ * agent remembering to call `close` — and an agent that believes it
1326
+ * closed the window (issue #3) leaves the user staring at a Chromium
1327
+ * window nobody owns. An untouched session now closes itself and tells
1328
+ * the user it did. */
1329
+ let idleTimer = null;
1330
+ const armIdle = () => {
1331
+ if (idleTimer)
1332
+ clearTimeout(idleTimer);
1333
+ idleTimer = null;
1334
+ if (!idleCloseMs || !session)
1335
+ return;
1336
+ idleTimer = setTimeout(() => {
1337
+ idleTimer = null;
1338
+ if (!session)
1339
+ return;
1340
+ traj({ step_id: "browser", event: "idle_close", idle_ms: idleCloseMs });
1341
+ void closeActive()
1342
+ .then((r) => notify?.(`tm_browser 空闲 ${Math.round(idleCloseMs / 1000)}s,会话已自动关闭${r.closed ? "(窗口应已消失)" : "——但窗口可能仍需手动关闭"}`))
1343
+ .catch(() => { });
1344
+ }, idleCloseMs);
1345
+ idleTimer.unref?.();
1346
+ };
1001
1347
  async function ensureSession(headless) {
1002
1348
  if (session)
1003
1349
  return session;
@@ -1011,10 +1357,26 @@ export function buildTmBrowserTool(deps) {
1011
1357
  async function closeActive() {
1012
1358
  const s = session;
1013
1359
  if (!s)
1014
- return "没有打开的浏览器会话。";
1360
+ return { text: "没有打开的浏览器会话。", closed: true };
1015
1361
  session = null;
1362
+ if (idleTimer) {
1363
+ clearTimeout(idleTimer);
1364
+ idleTimer = null;
1365
+ }
1016
1366
  return s.close();
1017
1367
  }
1368
+ /** Move any screenshot pixels act() produced out of the session and onto
1369
+ * the ToolResult. Drained per call so a stale attachment can never ride
1370
+ * along on the next action. */
1371
+ function withAttachments(res, sess) {
1372
+ const atts = sess?.attachments ?? [];
1373
+ if (sess)
1374
+ sess.attachments = [];
1375
+ if (!atts.length)
1376
+ return res;
1377
+ const base = typeof res === "string" ? { output: res } : res;
1378
+ return { ...base, attachments: atts };
1379
+ }
1018
1380
  const ALL_ACTIONS = new Set([...BROWSER_PLAYWRIGHT_ACTIONS, ...BROWSER_COMPAT_ACTIONS, "navigate_page"]);
1019
1381
  const execute = async (rawArgs, ctx) => {
1020
1382
  try {
@@ -1048,30 +1410,46 @@ export function buildTmBrowserTool(deps) {
1048
1410
  if (!url)
1049
1411
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
1050
1412
  if (session) {
1051
- // already open: navigate instead of spawning a second browser
1052
- return toToolResult(await session.act("navigate", { url }, "browser"));
1413
+ // already open: navigate instead of spawning a second browser.
1414
+ // Report the mode the RUNNING instance is actually in — the old
1415
+ // code echoed the REQUESTED mode, so a sticky session could be
1416
+ // described as headful while it was a headless leftover.
1417
+ const out = await session.act("navigate", { url }, "browser");
1418
+ armIdle();
1419
+ return withAttachments(toToolResult(`复用已运行的会话(${session.headless ? "无头" : "有头窗口"} · ${session.label})。${out}`), session);
1053
1420
  }
1054
- const headless = args.headless != null ? Boolean(args.headless) : resolveHeadless(env);
1421
+ // Mode is an OPERATOR decision (TM_BROWSER_HEADLESS), never a model
1422
+ // arg: a model that once passed headless:"false" (string → truthy
1423
+ // under Boolean()) pinned the whole process to headless and every
1424
+ // anti-bot gate in the run failed.
1425
+ const headless = resolveHeadless(env);
1055
1426
  const s = await ensureSession(headless);
1056
1427
  const out = await s.act("navigate", { url }, "browser");
1057
- traj({ step_id: "browser", event: "open", headless });
1058
- const mode = headless ? "无头" : "有头窗口";
1428
+ traj({ step_id: "browser", event: "open", headless, label: s.label });
1429
+ const mode = s.headless ? "无头" : "有头窗口";
1059
1430
  const profile = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim() ? "持久登录配置" : "隔离临时配置";
1060
1431
  const note = lastSel && preference !== "cdp-legacy" && lastSel.kind === "cdp-legacy"
1061
1432
  ? `\n(引擎:cdp-legacy 降级——${lastSel.reason ?? ""};完整 16 动作需 npm install playwright-core + node≥20)`
1062
1433
  : "";
1063
- return toToolResult(`浏览器已启动(${mode},${profile})。${out}${note}`);
1434
+ const guard = subPolicy === "off"
1435
+ ? `\n(子资源策略=off:跨域图片/脚本一律拦截,页面可能显示不全——TM_BROWSER_SUBRESOURCE=same-site 可恢复)`
1436
+ : "";
1437
+ armIdle();
1438
+ return withAttachments(toToolResult(`浏览器已启动(${mode},${profile} · ${s.label})。${out}${note}${guard}`), s);
1064
1439
  }
1065
1440
  if (action === "navigate" || action === "navigate_page") {
1066
1441
  if (!url)
1067
1442
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
1068
1443
  if (!session)
1069
1444
  return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
1070
- return toToolResult(await session.act(action, { url }, "browser"));
1445
+ const out = await session.act(action, { url }, "browser");
1446
+ armIdle();
1447
+ return withAttachments(toToolResult(out), session);
1071
1448
  }
1072
1449
  if (action === "close") {
1073
1450
  traj({ step_id: "browser", event: "close" });
1074
- return toToolResult(await closeActive());
1451
+ const r = await closeActive();
1452
+ return toToolResult(r.text);
1075
1453
  }
1076
1454
  if (!ALL_ACTIONS.has(action)) {
1077
1455
  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=…] 寻址)`));
@@ -1089,7 +1467,9 @@ export function buildTmBrowserTool(deps) {
1089
1467
  }
1090
1468
  }
1091
1469
  const stepId = pipelines.nextStepId();
1092
- return toToolResult(await session.act(action, args, stepId));
1470
+ const out = await session.act(action, args, stepId);
1471
+ armIdle();
1472
+ return withAttachments(toToolResult(out), session);
1093
1473
  }
1094
1474
  catch (err) {
1095
1475
  const e = err;
@@ -1097,17 +1477,18 @@ export function buildTmBrowserTool(deps) {
1097
1477
  }
1098
1478
  };
1099
1479
  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.
1100
- - 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 } · list_console_messages · list_network_requests · list_pages · take_screenshot { fullPage? } → PNG path into the run store (pixels never enter context) · handle_dialog { dialogAction: accept|dismiss, promptText }.
1101
- - Compat actions: open { url } — launch/reuse + navigate; TM_BROWSER_PATH override → DEFAULT browser (Chromium-family only; Edge/Chrome probes fall through when it is Firefox). 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.
1480
+ - 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 } · 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 }.
1481
+ - 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.
1482
+ - 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.
1102
1483
  - 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.
1103
- - Network: per-request domain allowlist enforced INSIDE the page (playwright context.route abort / CDP Fetch interception), re-checked on every redirect hop; seeded = tm_webfetch's hosts (extend via TM_WEBFETCH_ALLOWED_DOMAINS). Out-of-allowlist open/navigate routes through the OFFICIAL confirmation dialog BEFORE any spawn; approved hosts pass the network layer for the session; env-file URLs and non-http(s) schemes hard-reject.
1484
+ - 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.
1104
1485
  - 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).`;
1105
1486
  return {
1106
1487
  description: DESCRIPTION,
1107
1488
  args: deps.args ?? {
1108
1489
  action: { descriptor: "action: take_snapshot|click|fill|navigate_page|... (16 playwright verbs + open/navigate/read/screenshot/close)" },
1109
1490
  url: { descriptor: "url: string (open/navigate/navigate_page, allowlisted https)" },
1110
- headless: { descriptor: "headless: boolean (open, optional — default auto)" },
1491
+ image: { descriptor: "image: true (take_screenshot only — attach the PNG pixels to this result; default is path-only)" },
1111
1492
  uid: { descriptor: "uid: snapshot [uid=eN] token (click/fill/hover/drag/upload_file/wait_for)" },
1112
1493
  selector: { descriptor: "selector: CSS/text locator escape hatch (only when a snapshot cannot express the node)" },
1113
1494
  targetUid: { descriptor: "targetUid: drag destination uid" },
@@ -1124,8 +1505,14 @@ export function buildTmBrowserTool(deps) {
1124
1505
  clear: { descriptor: "clear: drain the console buffer after list_console_messages" },
1125
1506
  },
1126
1507
  execute,
1127
- dispose: () => {
1128
- void closeActive().catch(() => { });
1508
+ dispose: async () => {
1509
+ if (idleTimer) {
1510
+ clearTimeout(idleTimer);
1511
+ idleTimer = null;
1512
+ }
1513
+ // AWAITED: the host's dispose hook is Promise-returning, and tearing
1514
+ // down while a close was still in flight left the window on screen.
1515
+ await closeActive().catch(() => { });
1129
1516
  },
1130
1517
  engineInfo: () => ({
1131
1518
  kind: lastSel?.kind ?? null,