@te-river/opencode-team-mode 1.5.12 → 1.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -6
- package/README.zh-CN.md +28 -4
- package/dist/agents.d.ts +5 -1
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +24 -2
- package/dist/agents.js.map +1 -1
- package/dist/capabilities.d.ts +66 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +165 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/host-hooks.d.ts +115 -0
- package/dist/host-hooks.d.ts.map +1 -0
- package/dist/host-hooks.js +270 -0
- package/dist/host-hooks.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +103 -4
- package/dist/index.js.map +1 -1
- package/dist/prompts/lead.d.ts +1 -1
- package/dist/prompts/lead.d.ts.map +1 -1
- package/dist/prompts/lead.js +71 -8
- package/dist/prompts/lead.js.map +1 -1
- package/dist/prompts/shared.d.ts +1 -1
- package/dist/prompts/shared.d.ts.map +1 -1
- package/dist/prompts/shared.js +51 -2
- package/dist/prompts/shared.js.map +1 -1
- package/dist/prompts/specialists.d.ts +1 -1
- package/dist/prompts/specialists.d.ts.map +1 -1
- package/dist/prompts/specialists.js +38 -17
- package/dist/prompts/specialists.js.map +1 -1
- package/dist/tm/args-schema.d.ts +7 -1
- package/dist/tm/args-schema.d.ts.map +1 -1
- package/dist/tm/args-schema.js +9 -3
- package/dist/tm/args-schema.js.map +1 -1
- package/dist/tm/bash-timeout.d.ts +72 -0
- package/dist/tm/bash-timeout.d.ts.map +1 -0
- package/dist/tm/bash-timeout.js +100 -0
- package/dist/tm/bash-timeout.js.map +1 -0
- package/dist/tm/browser.d.ts +91 -5
- package/dist/tm/browser.d.ts.map +1 -1
- package/dist/tm/browser.js +564 -89
- package/dist/tm/browser.js.map +1 -1
- package/dist/tm/cache.d.ts +68 -0
- package/dist/tm/cache.d.ts.map +1 -0
- package/dist/tm/cache.js +157 -0
- package/dist/tm/cache.js.map +1 -0
- package/dist/tm/config.d.ts +87 -0
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +66 -0
- package/dist/tm/config.js.map +1 -1
- package/dist/tm/dispatch.d.ts +123 -0
- package/dist/tm/dispatch.d.ts.map +1 -0
- package/dist/tm/dispatch.js +476 -0
- package/dist/tm/dispatch.js.map +1 -0
- package/dist/tm/dupe-guard.d.ts +58 -0
- package/dist/tm/dupe-guard.d.ts.map +1 -0
- package/dist/tm/dupe-guard.js +105 -0
- package/dist/tm/dupe-guard.js.map +1 -0
- package/dist/tm/index.d.ts +32 -4
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +58 -5
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/perm-ask.d.ts +1 -0
- package/dist/tm/perm-ask.d.ts.map +1 -1
- package/dist/tm/perm-ask.js +15 -1
- package/dist/tm/perm-ask.js.map +1 -1
- package/dist/tm/pipelines.d.ts.map +1 -1
- package/dist/tm/pipelines.js +11 -0
- package/dist/tm/pipelines.js.map +1 -1
- package/dist/tm/pty.d.ts +70 -0
- package/dist/tm/pty.d.ts.map +1 -0
- package/dist/tm/pty.js +242 -0
- package/dist/tm/pty.js.map +1 -0
- package/dist/tm/search.d.ts +56 -7
- package/dist/tm/search.d.ts.map +1 -1
- package/dist/tm/search.js +195 -41
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/stats.d.ts +117 -0
- package/dist/tm/stats.d.ts.map +1 -0
- package/dist/tm/stats.js +296 -0
- package/dist/tm/stats.js.map +1 -0
- package/dist/tm/webfetch.d.ts +23 -0
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +97 -8
- package/dist/tm/webfetch.js.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/installation.md +216 -17
- package/package.json +2 -2
package/dist/tm/browser.js
CHANGED
|
@@ -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
|
|
34
|
-
* context.route
|
|
35
|
-
*
|
|
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
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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,133 @@ 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
|
+
}
|
|
381
|
+
// ---------- evaluate_script: consent + result redaction ---------------------
|
|
382
|
+
/** `evaluate_script` is the one browser verb the network gate cannot cover:
|
|
383
|
+
* the allowlist limits where we NAVIGATE, not what an already-loaded page
|
|
384
|
+
* hands back. In a `TM_BROWSER_USER_DATA_DIR` profile that page may be a
|
|
385
|
+
* signed-in session, and `document.cookie` / `localStorage` / a token in the
|
|
386
|
+
* DOM is one expression away — and its value would then ride into the model
|
|
387
|
+
* context, the run store and the trajectory. So: ask once per browser
|
|
388
|
+
* session (the OFFICIAL dialog), and name-anchor the result on the shapes
|
|
389
|
+
* that are secrets, not on "looks random" (which would blank legitimate
|
|
390
|
+
* JSON and teach the agent to distrust every read). */
|
|
391
|
+
export const EVAL_SECRET_SHAPES = [
|
|
392
|
+
{ name: "JWT", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
393
|
+
{ name: "bearer", re: /\b(bearer\s+[A-Za-z0-9._~+/-]{12,}=*)/gi },
|
|
394
|
+
{ name: "auth-header", re: /\b(authorization\s*[:=]\s*[^\s"',}]{8,})/gi },
|
|
395
|
+
{ name: "cookie", re: /\b(set-cookie|cookie)\s*[:=]\s*[^"',}\n]{6,}/gi },
|
|
396
|
+
{ name: "api-key", re: /\b(sk|ghp|gho|github_pat|xox[baprs]|AKIA|AIza)[A-Za-z0-9_-]{8,}\b/g },
|
|
397
|
+
{ name: "secret-pair", re: /\b((?:password|passwd|secret|api_?key|access_?token|refresh_?token|client_?secret)\s*[:=]\s*"?[^\s"',}]{4,})/gi },
|
|
398
|
+
];
|
|
399
|
+
/** Mask every known secret shape in an in-page result. Returns the masked
|
|
400
|
+
* text plus WHICH kinds were masked (counted, never the values). */
|
|
401
|
+
export function redactEvalResult(text) {
|
|
402
|
+
let out = text;
|
|
403
|
+
const kinds = [];
|
|
404
|
+
for (const shape of EVAL_SECRET_SHAPES) {
|
|
405
|
+
const re = new RegExp(shape.re.source, shape.re.flags);
|
|
406
|
+
let hits = 0;
|
|
407
|
+
out = out.replace(re, () => {
|
|
408
|
+
hits++;
|
|
409
|
+
return `〔已脱敏:${shape.name}〕`;
|
|
410
|
+
});
|
|
411
|
+
if (hits)
|
|
412
|
+
kinds.push(`${shape.name}×${hits}`);
|
|
413
|
+
}
|
|
414
|
+
return { text: out, masked: kinds };
|
|
415
|
+
}
|
|
416
|
+
/** Consent is per BROWSER SESSION, not per call: one dialog when the lead
|
|
417
|
+
* decides to script the page, then the round stops paying for it. */
|
|
418
|
+
export function needsEvalConsent(policy, alreadyApproved) {
|
|
419
|
+
return policy !== "off" && !alreadyApproved;
|
|
420
|
+
}
|
|
421
|
+
/** Host only — the consent line in the dialog and in the trajectory never
|
|
422
|
+
* carries a query string (a search URL can hold the very token we guard). */
|
|
423
|
+
export function hostOnly(url) {
|
|
424
|
+
try {
|
|
425
|
+
return new URL(url).host;
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return url.slice(0, 60);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
203
431
|
class PipeCdp {
|
|
204
432
|
child;
|
|
205
433
|
nextId = 1;
|
|
@@ -425,6 +653,7 @@ const NAVIGATE_EVENT_TIMEOUT_MS = 15_000;
|
|
|
425
653
|
export function buildTmBrowserTool(deps) {
|
|
426
654
|
const { pipelines } = deps;
|
|
427
655
|
const env = deps.env ?? process.env;
|
|
656
|
+
const notify = typeof deps.notify === "function" ? deps.notify : undefined;
|
|
428
657
|
const tool = "tm_browser";
|
|
429
658
|
const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
|
|
430
659
|
let session = null;
|
|
@@ -437,6 +666,11 @@ export function buildTmBrowserTool(deps) {
|
|
|
437
666
|
// network layer (both engines) consults this in addition to the static allowlist
|
|
438
667
|
const approvedHosts = new Set();
|
|
439
668
|
const snapshotBudget = Math.max(10, Number(deps.cfg?.browserSnapshotMaxTokens) || 1200);
|
|
669
|
+
const subPolicy = deps.cfg?.browserSubresource ?? "same-site";
|
|
670
|
+
const evalAskPolicy = deps.cfg?.browserAskEval ?? "on";
|
|
671
|
+
const discover = deps.findExecutable ?? findBrowserExecutable;
|
|
672
|
+
const imageMaxBytes = Math.max(1000, Number(deps.cfg?.browserImageMaxBytes) || 400_000);
|
|
673
|
+
const idleCloseMs = Number(deps.cfg?.browserIdleCloseMs ?? 180_000);
|
|
440
674
|
// ---------- engine selection (cached per plugin instance) ----------
|
|
441
675
|
let selPromise = null;
|
|
442
676
|
let lastSel = null;
|
|
@@ -479,8 +713,71 @@ export function buildTmBrowserTool(deps) {
|
|
|
479
713
|
traj({ step_id: stepId, event: "result", bytes: png.length });
|
|
480
714
|
return { file, png };
|
|
481
715
|
}
|
|
482
|
-
|
|
483
|
-
|
|
716
|
+
/** LLM args are booleans most of the time, but `"false"` / `"0"` strings
|
|
717
|
+
* are common — a bare `Boolean()` read both as TRUE, which is exactly how
|
|
718
|
+
* a model used to force tm_browser into headless (and get anti-bot
|
|
719
|
+
* blocked) on a desktop that defaults to headful. */
|
|
720
|
+
function isTrueArg(raw) {
|
|
721
|
+
if (raw === true)
|
|
722
|
+
return true;
|
|
723
|
+
if (typeof raw === "number")
|
|
724
|
+
return raw === 1;
|
|
725
|
+
if (typeof raw === "string")
|
|
726
|
+
return /^(1|true|yes|on)$/i.test(raw.trim());
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
/** Screenshot line + the OPT-IN pixels.
|
|
730
|
+
* The PNG always lands in the run store (it is the evidence artifact);
|
|
731
|
+
* what rides back to the MODEL is a quality-70 JPEG — a real page with
|
|
732
|
+
* images loaded is ~1.5 MB as PNG, which no token budget should ever
|
|
733
|
+
* inline, and ~150-300 KB as JPEG. Pixels stay opt-in so a UI sweep
|
|
734
|
+
* cannot spend context the agent never asked for. */
|
|
735
|
+
async function shotOutput(sess, file, png, args, captureJpeg) {
|
|
736
|
+
const head = `截图已保存(PNG ${png.length} bytes):${file}`;
|
|
737
|
+
if (!isTrueArg(args.image)) {
|
|
738
|
+
return `${head}\n(上下文只携带路径,不携带像素——需要真正看到画面时带 image:true 再截一次。)`;
|
|
739
|
+
}
|
|
740
|
+
let img = null;
|
|
741
|
+
if (captureJpeg) {
|
|
742
|
+
try {
|
|
743
|
+
img = await captureJpeg();
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
img = null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
let kind = "jpeg";
|
|
750
|
+
if (!img) {
|
|
751
|
+
img = png;
|
|
752
|
+
kind = "png";
|
|
753
|
+
}
|
|
754
|
+
if (img.length > imageMaxBytes) {
|
|
755
|
+
return `${head}\n(未附带像素:${kind.toUpperCase()} ${img.length} bytes 超过 TM_BROWSER_IMAGE_MAX_BYTES=${imageMaxBytes}。改用视口截图(去掉 fullPage)或提高该上限。)`;
|
|
756
|
+
}
|
|
757
|
+
sess.attachments.push({
|
|
758
|
+
type: "file",
|
|
759
|
+
mime: `image/${kind}`,
|
|
760
|
+
url: `data:image/${kind};base64,${img.toString("base64")}`,
|
|
761
|
+
filename: path.basename(file).replace(/\.png$/, `.${kind === "jpeg" ? "jpg" : "png"}`),
|
|
762
|
+
});
|
|
763
|
+
return `${head}\n(像素以 ${kind.toUpperCase()} ${img.length} bytes 随本条结果附带。)`;
|
|
764
|
+
}
|
|
765
|
+
/** One line telling the agent that the page is NOT actually empty — the
|
|
766
|
+
* subresource gate dropped N requests. Without this the model reads a
|
|
767
|
+
* blank-looking page and reports "该网站没有图片". */
|
|
768
|
+
function blockedNote(sess) {
|
|
769
|
+
if (!sess.blocked.count)
|
|
770
|
+
return "";
|
|
771
|
+
const n = sess.blocked.count;
|
|
772
|
+
const hosts = [...sess.blocked.hosts];
|
|
773
|
+
// RESET IN PLACE — the engine's route handler holds this very object, so
|
|
774
|
+
// swapping in a new one would silently stop counting after the first
|
|
775
|
+
// drain (and the note would never appear again for the session).
|
|
776
|
+
sess.blocked.count = 0;
|
|
777
|
+
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};同域资源已自动放行,跨域脚本仍需白名单或弹窗批准。`);
|
|
484
781
|
}
|
|
485
782
|
function pageTextOutput(url, text) {
|
|
486
783
|
return `页面文本(${url}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`;
|
|
@@ -517,8 +814,52 @@ export function buildTmBrowserTool(deps) {
|
|
|
517
814
|
return null;
|
|
518
815
|
}
|
|
519
816
|
// ================= cdp-legacy engine (原手写 CDP 管道,保留不删) =============
|
|
817
|
+
/**
|
|
818
|
+
* Shared network verdict for BOTH engines — one implementation, so the
|
|
819
|
+
* playwright and cdp-legacy legs can never disagree about what passes.
|
|
820
|
+
* The hard red lines (non-http scheme, remote env-file spelling) stay hard
|
|
821
|
+
* under EVERY subresource policy: they are not consentable, so a passive
|
|
822
|
+
* image or a same-site script can never relax them.
|
|
823
|
+
*/
|
|
824
|
+
function gateDecide(st, url, resourceType) {
|
|
825
|
+
const verdict = checkWebUrl(url, allowlist);
|
|
826
|
+
if (!verdict.ok && !verdict.askable)
|
|
827
|
+
return { pass: false, via: "red-line" };
|
|
828
|
+
let approved = false;
|
|
829
|
+
try {
|
|
830
|
+
approved = approvedHosts.has(new URL(url).hostname);
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
approved = false;
|
|
834
|
+
}
|
|
835
|
+
const v = subresourcePass({
|
|
836
|
+
url,
|
|
837
|
+
resourceType,
|
|
838
|
+
policy: subPolicy,
|
|
839
|
+
allowlistHit: verdict.ok,
|
|
840
|
+
approvedHost: approved,
|
|
841
|
+
allowedSites: st.allowedSites,
|
|
842
|
+
});
|
|
843
|
+
if (v.pass) {
|
|
844
|
+
if (normalizeResourceType(resourceType) === "document")
|
|
845
|
+
rememberSite(st.allowedSites, url);
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
st.blocked.count++;
|
|
849
|
+
try {
|
|
850
|
+
st.blocked.hosts.add(new URL(url).hostname);
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
/* no host to name */
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// NO per-request trajectory: a real page is hundreds of requests and the
|
|
857
|
+
// log would be pure noise. Blocked verdicts aggregate into st.blocked
|
|
858
|
+
// and flush as ONE line (blockedNote / close).
|
|
859
|
+
return v;
|
|
860
|
+
}
|
|
520
861
|
async function openLegacySession(headless) {
|
|
521
|
-
const executable =
|
|
862
|
+
const executable = discover(env);
|
|
522
863
|
if (!executable)
|
|
523
864
|
throw discoveryError();
|
|
524
865
|
const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
|
|
@@ -557,56 +898,58 @@ export function buildTmBrowserTool(deps) {
|
|
|
557
898
|
throw new Error("浏览器启动后未找到 page target");
|
|
558
899
|
const { sessionId } = (await cdp.call("Target.attachToTarget", { targetId: page.targetId, flatten: true }));
|
|
559
900
|
await cdp.call("Page.enable", {}, sessionId);
|
|
560
|
-
// network-layer
|
|
901
|
+
// network-layer enforcement — the SAME gateDecide the playwright leg uses
|
|
902
|
+
// (subresource policy + per-hop redirect re-check + dialog-approved hosts)
|
|
903
|
+
const allowedSites = new Set();
|
|
904
|
+
const blocked = { count: 0, hosts: new Set() };
|
|
561
905
|
await cdp.call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }, sessionId);
|
|
562
906
|
cdp.onEvent((m) => {
|
|
563
907
|
if (m.method !== "Fetch.requestPaused" || m.sessionId !== sessionId)
|
|
564
908
|
return;
|
|
565
909
|
const requestId = String(m.params.requestId ?? "");
|
|
566
|
-
const
|
|
567
|
-
const
|
|
568
|
-
|
|
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
|
-
}
|
|
910
|
+
const p = m.params.request ?? {};
|
|
911
|
+
const url = String(p.url ?? "");
|
|
912
|
+
const { pass } = gateDecide({ allowedSites, blocked }, url, p.resourceType);
|
|
578
913
|
if (pass) {
|
|
579
914
|
void cdp.call("Fetch.continueRequest", { requestId }, sessionId, 5000).catch(() => { });
|
|
580
915
|
}
|
|
581
916
|
else {
|
|
582
|
-
traj({ step_id: "browser", event: "blocked", url: url.slice(0, 200) });
|
|
583
917
|
void cdp.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, sessionId, 5000).catch(() => { });
|
|
584
918
|
}
|
|
585
919
|
});
|
|
586
920
|
const sess = {
|
|
587
921
|
kind: "cdp-legacy",
|
|
588
922
|
currentUrl: "about:blank",
|
|
923
|
+
headless,
|
|
924
|
+
label: `cdp-legacy · ${path.basename(executable)} · pid ${child.pid ?? "?"}`,
|
|
925
|
+
attachments: [],
|
|
926
|
+
blocked,
|
|
927
|
+
allowedSites,
|
|
589
928
|
async act(action, args, stepId) {
|
|
590
929
|
if (action === "navigate" || action === "navigate_page") {
|
|
591
930
|
const url = String(args.url ?? "").trim();
|
|
931
|
+
rememberSite(allowedSites, url);
|
|
592
932
|
await cdp.call("Page.navigate", { url }, sessionId);
|
|
593
933
|
await cdp.waitEvent("Page.loadEventFired", sessionId, NAVIGATE_EVENT_TIMEOUT_MS).catch(() => { });
|
|
594
934
|
sess.currentUrl = url;
|
|
595
|
-
return `已导航:${url}`;
|
|
935
|
+
return `已导航:${url}${blockedNote(sess)}`;
|
|
596
936
|
}
|
|
597
937
|
if (action === "read") {
|
|
598
938
|
traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
|
|
599
939
|
const ev = await cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, sessionId);
|
|
600
940
|
const text = String(ev.result?.value ?? "");
|
|
601
941
|
traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
|
|
602
|
-
return pageTextOutput(sess.currentUrl, text)
|
|
942
|
+
return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
|
|
603
943
|
}
|
|
604
944
|
if (action === "screenshot" || action === "take_screenshot") {
|
|
605
945
|
traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
|
|
606
946
|
const shot = await cdp.call("Page.captureScreenshot", { format: "png" }, sessionId);
|
|
607
947
|
const png = Buffer.from(String(shot.data ?? ""), "base64");
|
|
608
948
|
const { file } = saveShotPng(new Uint8Array(png), stepId);
|
|
609
|
-
return shotOutput(file, png
|
|
949
|
+
return shotOutput(sess, file, png, args, async () => {
|
|
950
|
+
const j = await cdp.call("Page.captureScreenshot", { format: "jpeg", quality: 70 }, sessionId);
|
|
951
|
+
return Buffer.from(String(j.data ?? ""), "base64");
|
|
952
|
+
});
|
|
610
953
|
}
|
|
611
954
|
if (action === "list_pages") {
|
|
612
955
|
const t = await cdp.call("Target.getTargets");
|
|
@@ -643,13 +986,20 @@ export function buildTmBrowserTool(deps) {
|
|
|
643
986
|
res();
|
|
644
987
|
});
|
|
645
988
|
});
|
|
989
|
+
const dead = child.exitCode !== null;
|
|
646
990
|
try {
|
|
647
991
|
rmForceSafe(profileDir, { recursive: true });
|
|
648
992
|
}
|
|
649
993
|
catch {
|
|
650
994
|
/* a locked leftover temp dir is reclaimed by the OS — never fail close */
|
|
651
995
|
}
|
|
652
|
-
return
|
|
996
|
+
return dead
|
|
997
|
+
? { closed: true, text: "浏览器进程已退出(cdp-legacy),临时配置目录已清理。" }
|
|
998
|
+
: {
|
|
999
|
+
closed: false,
|
|
1000
|
+
text: `警告:cdp-legacy 子进程 pid ${child.pid ?? "?"} 未在 3s 内退出,窗口可能仍在前台——` +
|
|
1001
|
+
`请手动关闭该浏览器窗口(临时目录 ${profileDir})。`,
|
|
1002
|
+
};
|
|
653
1003
|
},
|
|
654
1004
|
};
|
|
655
1005
|
const onExit = () => {
|
|
@@ -669,7 +1019,7 @@ export function buildTmBrowserTool(deps) {
|
|
|
669
1019
|
}
|
|
670
1020
|
// ================= playwright engine (primary) ==============================
|
|
671
1021
|
async function openPlaywrightSession(pw, headless) {
|
|
672
|
-
const executable =
|
|
1022
|
+
const executable = discover(env);
|
|
673
1023
|
if (!executable)
|
|
674
1024
|
throw discoveryError();
|
|
675
1025
|
const launchArgs = [
|
|
@@ -687,32 +1037,35 @@ export function buildTmBrowserTool(deps) {
|
|
|
687
1037
|
viewport: headless ? undefined : null,
|
|
688
1038
|
};
|
|
689
1039
|
const persistentDir = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim();
|
|
690
|
-
// R2
|
|
691
|
-
//
|
|
692
|
-
// executablePath
|
|
693
|
-
|
|
1040
|
+
// R2 (revised 2026-09-18): playwright's `channel` resolves the executable
|
|
1041
|
+
// ITSELF, which silently overrode our discovery (a Beta-default Windows
|
|
1042
|
+
// host opened STABLE Edge). executablePath is now the primary launch and
|
|
1043
|
+
// the channel spelling only a fallback — the opposite of the old order.
|
|
1044
|
+
const target = playwrightLaunchTarget(executable, {
|
|
1045
|
+
explicitOverride: String(env.TM_BROWSER_PATH ?? "").trim() !== "",
|
|
1046
|
+
});
|
|
694
1047
|
let browser = null;
|
|
695
1048
|
let context;
|
|
696
1049
|
let via = "executablePath";
|
|
697
1050
|
if (persistentDir) {
|
|
698
1051
|
context = await pw.chromium.launchPersistentContext(persistentDir, {
|
|
699
1052
|
...contextOpts,
|
|
700
|
-
|
|
1053
|
+
executablePath: target.executablePath,
|
|
701
1054
|
headless,
|
|
702
1055
|
args: launchArgs,
|
|
703
1056
|
});
|
|
704
|
-
via = `persistent(
|
|
1057
|
+
via = `persistent(executablePath)`;
|
|
705
1058
|
}
|
|
706
1059
|
else {
|
|
707
1060
|
try {
|
|
708
|
-
browser = await pw.chromium.launch({
|
|
709
|
-
via =
|
|
1061
|
+
browser = await pw.chromium.launch({ executablePath: target.executablePath, headless, args: launchArgs });
|
|
1062
|
+
via = "executablePath";
|
|
710
1063
|
}
|
|
711
1064
|
catch (e1) {
|
|
712
|
-
if (!channel)
|
|
1065
|
+
if (!target.channel)
|
|
713
1066
|
throw e1;
|
|
714
|
-
browser = await pw.chromium.launch({
|
|
715
|
-
via =
|
|
1067
|
+
browser = await pw.chromium.launch({ channel: target.channel, headless, args: launchArgs });
|
|
1068
|
+
via = `channel(${target.channel})`;
|
|
716
1069
|
}
|
|
717
1070
|
context = await browser.newContext(contextOpts);
|
|
718
1071
|
}
|
|
@@ -722,6 +1075,8 @@ export function buildTmBrowserTool(deps) {
|
|
|
722
1075
|
dialogs: [],
|
|
723
1076
|
consoleBuf: [],
|
|
724
1077
|
netBuf: [],
|
|
1078
|
+
allowedSites: new Set(),
|
|
1079
|
+
blocked: { count: 0, hosts: new Set() },
|
|
725
1080
|
};
|
|
726
1081
|
const attached = new WeakSet();
|
|
727
1082
|
const attach = (p) => {
|
|
@@ -775,22 +1130,12 @@ export function buildTmBrowserTool(deps) {
|
|
|
775
1130
|
await route.continue().catch(() => { });
|
|
776
1131
|
return;
|
|
777
1132
|
}
|
|
778
|
-
const
|
|
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
|
-
}
|
|
1133
|
+
const { pass } = gateDecide(state, u, request.resourceType?.());
|
|
788
1134
|
if (pass) {
|
|
789
1135
|
await route.continue().catch(() => { });
|
|
790
1136
|
}
|
|
791
1137
|
else {
|
|
792
1138
|
// per-hop re-check lands here for every redirect hop too
|
|
793
|
-
traj({ step_id: "browser", event: "blocked", url: u.slice(0, 200) });
|
|
794
1139
|
state.netBuf.push({ method: request.method?.() ?? "?", url: u.slice(0, 300), blocked: true });
|
|
795
1140
|
await route.abort().catch(() => { });
|
|
796
1141
|
}
|
|
@@ -839,25 +1184,34 @@ export function buildTmBrowserTool(deps) {
|
|
|
839
1184
|
const sess = {
|
|
840
1185
|
kind: "playwright",
|
|
841
1186
|
currentUrl: "about:blank",
|
|
1187
|
+
headless,
|
|
1188
|
+
label: `playwright/${via} · ${path.basename(executable)} · ${headless ? "headless" : "headful"}`,
|
|
1189
|
+
attachments: [],
|
|
1190
|
+
blocked: state.blocked,
|
|
1191
|
+
allowedSites: state.allowedSites,
|
|
842
1192
|
async act(action, args, stepId) {
|
|
843
1193
|
if (action === "navigate" || action === "navigate_page") {
|
|
844
1194
|
const url = String(args.url ?? "").trim();
|
|
1195
|
+
// the navigation itself already cleared the allowlist (execute()
|
|
1196
|
+
// asked the user otherwise) — its SITE may now load subresources
|
|
1197
|
+
rememberSite(state.allowedSites, url);
|
|
845
1198
|
// domcontentloaded ONLY — networkidle is banned (prompt discipline)
|
|
846
1199
|
await state.page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
|
847
1200
|
sess.currentUrl = state.page.url?.() || url;
|
|
848
|
-
|
|
1201
|
+
rememberSite(state.allowedSites, sess.currentUrl);
|
|
1202
|
+
return `已导航:${url}${blockedNote(sess)}`;
|
|
849
1203
|
}
|
|
850
1204
|
if (action === "read") {
|
|
851
1205
|
traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
|
|
852
1206
|
const text = String((await state.page.evaluate("document.body ? document.body.innerText : ''")) ?? "");
|
|
853
1207
|
traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
|
|
854
|
-
return pageTextOutput(sess.currentUrl, text)
|
|
1208
|
+
return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
|
|
855
1209
|
}
|
|
856
1210
|
if (action === "screenshot" || action === "take_screenshot") {
|
|
857
1211
|
traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
|
|
858
1212
|
const bytes = await state.page.screenshot({ type: "png", fullPage: Boolean(args.fullPage) });
|
|
859
1213
|
const { file, png } = saveShotPng(bytes, stepId);
|
|
860
|
-
return shotOutput(file, png.
|
|
1214
|
+
return shotOutput(sess, file, png, args, async () => Buffer.from(await state.page.screenshot({ type: "jpeg", quality: 70, fullPage: Boolean(args.fullPage) })));
|
|
861
1215
|
}
|
|
862
1216
|
if (action === "take_snapshot") {
|
|
863
1217
|
traj({ step_id: stepId, event: "call", kind: "snapshot", url: sess.currentUrl.slice(0, 200) });
|
|
@@ -866,13 +1220,13 @@ export function buildTmBrowserTool(deps) {
|
|
|
866
1220
|
yaml = await state.page.locator("body").ariaSnapshot();
|
|
867
1221
|
}
|
|
868
1222
|
catch (e) {
|
|
869
|
-
throw new Error(`ariaSnapshot
|
|
1223
|
+
throw new Error(`ariaSnapshot 不可用:${e.message} —— locator.ariaSnapshot 需要 playwright-core ≥1.49(本包 optionalDependencies 锁 1.63);未安装或过旧会走 cdp-legacy 降级,那边没有快照动作。`);
|
|
870
1224
|
}
|
|
871
1225
|
const annotated = snapIndex.annotate(yaml);
|
|
872
1226
|
traj({ step_id: stepId, event: "result", tokens: estimateTokens(annotated), nodes: snapIndex.size });
|
|
873
1227
|
const body = capTokens(annotated, `…(快照超过 browserSnapshotMaxTokens=${snapshotBudget},后续行已截断——用 read 拿原文或先滚动再快照)`);
|
|
874
1228
|
const hint = state.dialogs.length ? `\n注意:有 ${state.dialogs.length} 个未处理对话框——先 handle_dialog` : "";
|
|
875
|
-
return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}`;
|
|
1229
|
+
return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}${blockedNote(sess)}`;
|
|
876
1230
|
}
|
|
877
1231
|
if (action === "click") {
|
|
878
1232
|
const loc = targetOf("uid", "selector", args);
|
|
@@ -984,20 +1338,63 @@ export function buildTmBrowserTool(deps) {
|
|
|
984
1338
|
},
|
|
985
1339
|
async close() {
|
|
986
1340
|
state.dialogs.length = 0;
|
|
987
|
-
|
|
1341
|
+
const errs = [];
|
|
1342
|
+
const fail = (label2) => (e) => {
|
|
1343
|
+
errs.push(`${label2}: ${String(e?.message ?? e)}`);
|
|
1344
|
+
};
|
|
1345
|
+
await Promise.resolve(context.close()).catch(fail("context"));
|
|
988
1346
|
if (browser)
|
|
989
|
-
await Promise.resolve(browser.close()).catch(()
|
|
990
|
-
|
|
991
|
-
|
|
1347
|
+
await Promise.resolve(browser.close()).catch(fail("browser"));
|
|
1348
|
+
// VERIFY before claiming — an agent quotes this line to the user as
|
|
1349
|
+
// "浏览器已关闭", so a swallowed failure must not read like success.
|
|
1350
|
+
let alivePages = 0;
|
|
1351
|
+
try {
|
|
1352
|
+
alivePages = context.pages().length;
|
|
1353
|
+
}
|
|
1354
|
+
catch {
|
|
1355
|
+
alivePages = 0;
|
|
992
1356
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1357
|
+
const connected = browser ? Boolean(browser.isConnected?.()) : false;
|
|
1358
|
+
const closed = errs.length === 0 && alivePages === 0 && !connected;
|
|
1359
|
+
const profile = persistentDir
|
|
1360
|
+
? `持久配置目录已保留(下次 open 复用登录态):${persistentDir}`
|
|
1361
|
+
: "临时配置目录由 playwright 自行回收";
|
|
1362
|
+
if (closed)
|
|
1363
|
+
return { closed: true, text: `浏览器会话已确认关闭(${sess.label});${profile}。` };
|
|
1364
|
+
return {
|
|
1365
|
+
closed: false,
|
|
1366
|
+
text: `警告:关闭未完全成功(${sess.label}${errs.length ? `:${errs.join("; ")}` : ""})——` +
|
|
1367
|
+
`残留标签页 ${alivePages} 个${connected ? ",浏览器进程仍处于连接状态" : ""}。` +
|
|
1368
|
+
`窗口可能仍在前台,需要用户手动关闭。${profile}。`,
|
|
1369
|
+
};
|
|
996
1370
|
},
|
|
997
1371
|
};
|
|
998
1372
|
return sess;
|
|
999
1373
|
}
|
|
1000
1374
|
// ---------- 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)
|
|
1386
|
+
return;
|
|
1387
|
+
idleTimer = setTimeout(() => {
|
|
1388
|
+
idleTimer = null;
|
|
1389
|
+
if (!session)
|
|
1390
|
+
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 ? "(窗口应已消失)" : "——但窗口可能仍需手动关闭"}`))
|
|
1394
|
+
.catch(() => { });
|
|
1395
|
+
}, idleCloseMs);
|
|
1396
|
+
idleTimer.unref?.();
|
|
1397
|
+
};
|
|
1001
1398
|
async function ensureSession(headless) {
|
|
1002
1399
|
if (session)
|
|
1003
1400
|
return session;
|
|
@@ -1011,10 +1408,26 @@ export function buildTmBrowserTool(deps) {
|
|
|
1011
1408
|
async function closeActive() {
|
|
1012
1409
|
const s = session;
|
|
1013
1410
|
if (!s)
|
|
1014
|
-
return "没有打开的浏览器会话。";
|
|
1411
|
+
return { text: "没有打开的浏览器会话。", closed: true };
|
|
1015
1412
|
session = null;
|
|
1413
|
+
if (idleTimer) {
|
|
1414
|
+
clearTimeout(idleTimer);
|
|
1415
|
+
idleTimer = null;
|
|
1416
|
+
}
|
|
1016
1417
|
return s.close();
|
|
1017
1418
|
}
|
|
1419
|
+
/** Move any screenshot pixels act() produced out of the session and onto
|
|
1420
|
+
* the ToolResult. Drained per call so a stale attachment can never ride
|
|
1421
|
+
* along on the next action. */
|
|
1422
|
+
function withAttachments(res, sess) {
|
|
1423
|
+
const atts = sess?.attachments ?? [];
|
|
1424
|
+
if (sess)
|
|
1425
|
+
sess.attachments = [];
|
|
1426
|
+
if (!atts.length)
|
|
1427
|
+
return res;
|
|
1428
|
+
const base = typeof res === "string" ? { output: res } : res;
|
|
1429
|
+
return { ...base, attachments: atts };
|
|
1430
|
+
}
|
|
1018
1431
|
const ALL_ACTIONS = new Set([...BROWSER_PLAYWRIGHT_ACTIONS, ...BROWSER_COMPAT_ACTIONS, "navigate_page"]);
|
|
1019
1432
|
const execute = async (rawArgs, ctx) => {
|
|
1020
1433
|
try {
|
|
@@ -1048,30 +1461,46 @@ export function buildTmBrowserTool(deps) {
|
|
|
1048
1461
|
if (!url)
|
|
1049
1462
|
return toToolResult(tmError(tool, "args", "缺少 url 参数"));
|
|
1050
1463
|
if (session) {
|
|
1051
|
-
// already open: navigate instead of spawning a second browser
|
|
1052
|
-
|
|
1464
|
+
// already open: navigate instead of spawning a second browser.
|
|
1465
|
+
// Report the mode the RUNNING instance is actually in — the old
|
|
1466
|
+
// code echoed the REQUESTED mode, so a sticky session could be
|
|
1467
|
+
// 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);
|
|
1053
1471
|
}
|
|
1054
|
-
|
|
1472
|
+
// Mode is an OPERATOR decision (TM_BROWSER_HEADLESS), never a model
|
|
1473
|
+
// arg: a model that once passed headless:"false" (string → truthy
|
|
1474
|
+
// under Boolean()) pinned the whole process to headless and every
|
|
1475
|
+
// anti-bot gate in the run failed.
|
|
1476
|
+
const headless = resolveHeadless(env);
|
|
1055
1477
|
const s = await ensureSession(headless);
|
|
1056
1478
|
const out = await s.act("navigate", { url }, "browser");
|
|
1057
|
-
traj({ step_id: "browser", event: "open", headless });
|
|
1058
|
-
const mode = headless ? "无头" : "有头窗口";
|
|
1479
|
+
traj({ step_id: "browser", event: "open", headless, label: s.label });
|
|
1480
|
+
const mode = s.headless ? "无头" : "有头窗口";
|
|
1059
1481
|
const profile = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim() ? "持久登录配置" : "隔离临时配置";
|
|
1060
1482
|
const note = lastSel && preference !== "cdp-legacy" && lastSel.kind === "cdp-legacy"
|
|
1061
1483
|
? `\n(引擎:cdp-legacy 降级——${lastSel.reason ?? ""};完整 16 动作需 npm install playwright-core + node≥20)`
|
|
1062
1484
|
: "";
|
|
1063
|
-
|
|
1485
|
+
const guard = subPolicy === "off"
|
|
1486
|
+
? `\n(子资源策略=off:跨域图片/脚本一律拦截,页面可能显示不全——TM_BROWSER_SUBRESOURCE=same-site 可恢复)`
|
|
1487
|
+
: "";
|
|
1488
|
+
armIdle();
|
|
1489
|
+
return withAttachments(toToolResult(`浏览器已启动(${mode},${profile} · ${s.label})。${out}${note}${guard}`), s);
|
|
1064
1490
|
}
|
|
1065
1491
|
if (action === "navigate" || action === "navigate_page") {
|
|
1066
1492
|
if (!url)
|
|
1067
1493
|
return toToolResult(tmError(tool, "args", "缺少 url 参数"));
|
|
1068
1494
|
if (!session)
|
|
1069
1495
|
return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
|
|
1070
|
-
|
|
1496
|
+
const out = await session.act(action, { url }, "browser");
|
|
1497
|
+
armIdle();
|
|
1498
|
+
return withAttachments(toToolResult(out), session);
|
|
1071
1499
|
}
|
|
1072
1500
|
if (action === "close") {
|
|
1073
1501
|
traj({ step_id: "browser", event: "close" });
|
|
1074
|
-
|
|
1502
|
+
const r = await closeActive();
|
|
1503
|
+
return toToolResult(r.text);
|
|
1075
1504
|
}
|
|
1076
1505
|
if (!ALL_ACTIONS.has(action)) {
|
|
1077
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=…] 寻址)`));
|
|
@@ -1088,8 +1517,47 @@ export function buildTmBrowserTool(deps) {
|
|
|
1088
1517
|
return toToolResult(tmError(tool, "permission", refusal));
|
|
1089
1518
|
}
|
|
1090
1519
|
}
|
|
1520
|
+
// M5 (v1.5.14): evaluate_script's consent + result redaction run at the
|
|
1521
|
+
// execute layer for BOTH engines — same reason upload_file's source
|
|
1522
|
+
// check does: the engine act() has no host ctx, and one choke point
|
|
1523
|
+
// cannot drift from the other.
|
|
1524
|
+
if (action === "evaluate_script" && needsEvalConsent(evalAskPolicy, session.evalApproved)) {
|
|
1525
|
+
const target = hostOnly(session.currentUrl);
|
|
1526
|
+
const outcome = await askUserForTarget(ctx, {
|
|
1527
|
+
permission: tool,
|
|
1528
|
+
patterns: [`evaluate_script:${target}`],
|
|
1529
|
+
metadata: {
|
|
1530
|
+
tool,
|
|
1531
|
+
action: "evaluate_script",
|
|
1532
|
+
host: target,
|
|
1533
|
+
source: String(args.function ?? args.expression ?? "").slice(0, 160),
|
|
1534
|
+
},
|
|
1535
|
+
});
|
|
1536
|
+
traj({ step_id: "browser", event: "eval_consent", host: target, verdict: outcome });
|
|
1537
|
+
if (outcome !== "approved") {
|
|
1538
|
+
return toToolResult(tmError(tool, "permission", outcome === "unavailable"
|
|
1539
|
+
? `evaluate_script 需要官方确认窗授权,本宿主没有 ask 桥——已拒绝执行。TM_BROWSER_ASK_EVAL=off 可关掉这道确认(不建议:这个动词能在你已登录的浏览器里读任意页面数据)。`
|
|
1540
|
+
: `evaluate_script 未获批准(目标站点 ${target}),已拒绝执行。改用 take_snapshot/read 的受治理读取,或让用户单独批准。`));
|
|
1541
|
+
}
|
|
1542
|
+
session.evalApproved = true;
|
|
1543
|
+
}
|
|
1091
1544
|
const stepId = pipelines.nextStepId();
|
|
1092
|
-
|
|
1545
|
+
const out = await session.act(action, args, stepId);
|
|
1546
|
+
if (action !== "evaluate_script") {
|
|
1547
|
+
armIdle();
|
|
1548
|
+
return withAttachments(toToolResult(out), session);
|
|
1549
|
+
}
|
|
1550
|
+
// The value is masked BEFORE it can reach the context window, the run
|
|
1551
|
+
// store or the trajectory; the agent is told a mask happened (a silent
|
|
1552
|
+
// blank would read as "the page has no token").
|
|
1553
|
+
const red = redactEvalResult(out);
|
|
1554
|
+
if (red.masked.length) {
|
|
1555
|
+
traj({ step_id: "browser", event: "eval_redacted", kinds: red.masked.join(",") });
|
|
1556
|
+
armIdle();
|
|
1557
|
+
return withAttachments(toToolResult(`${red.text}\n注意:结果里有 ${red.masked.join("、")} 被识别为密钥形状并已脱敏——不是页面没有这些值,是插件不把它们送进上下文。需要它们请让用户自己在浏览器里看。`), session);
|
|
1558
|
+
}
|
|
1559
|
+
armIdle();
|
|
1560
|
+
return withAttachments(toToolResult(red.text), session);
|
|
1093
1561
|
}
|
|
1094
1562
|
catch (err) {
|
|
1095
1563
|
const e = err;
|
|
@@ -1097,17 +1565,18 @@ export function buildTmBrowserTool(deps) {
|
|
|
1097
1565
|
}
|
|
1098
1566
|
};
|
|
1099
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.
|
|
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
|
|
1101
|
-
- Compat actions: open { url } — launch/reuse + navigate
|
|
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.
|
|
1102
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.
|
|
1103
|
-
- Network:
|
|
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.
|
|
1104
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).`;
|
|
1105
1574
|
return {
|
|
1106
1575
|
description: DESCRIPTION,
|
|
1107
1576
|
args: deps.args ?? {
|
|
1108
1577
|
action: { descriptor: "action: take_snapshot|click|fill|navigate_page|... (16 playwright verbs + open/navigate/read/screenshot/close)" },
|
|
1109
1578
|
url: { descriptor: "url: string (open/navigate/navigate_page, allowlisted https)" },
|
|
1110
|
-
|
|
1579
|
+
image: { descriptor: "image: true (take_screenshot only — attach the PNG pixels to this result; default is path-only)" },
|
|
1111
1580
|
uid: { descriptor: "uid: snapshot [uid=eN] token (click/fill/hover/drag/upload_file/wait_for)" },
|
|
1112
1581
|
selector: { descriptor: "selector: CSS/text locator escape hatch (only when a snapshot cannot express the node)" },
|
|
1113
1582
|
targetUid: { descriptor: "targetUid: drag destination uid" },
|
|
@@ -1124,8 +1593,14 @@ export function buildTmBrowserTool(deps) {
|
|
|
1124
1593
|
clear: { descriptor: "clear: drain the console buffer after list_console_messages" },
|
|
1125
1594
|
},
|
|
1126
1595
|
execute,
|
|
1127
|
-
dispose: () => {
|
|
1128
|
-
|
|
1596
|
+
dispose: async () => {
|
|
1597
|
+
if (idleTimer) {
|
|
1598
|
+
clearTimeout(idleTimer);
|
|
1599
|
+
idleTimer = null;
|
|
1600
|
+
}
|
|
1601
|
+
// AWAITED: the host's dispose hook is Promise-returning, and tearing
|
|
1602
|
+
// down while a close was still in flight left the window on screen.
|
|
1603
|
+
await closeActive().catch(() => { });
|
|
1129
1604
|
},
|
|
1130
1605
|
engineInfo: () => ({
|
|
1131
1606
|
kind: lastSel?.kind ?? null,
|