@thincoder/core 0.10.5 → 0.10.6

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/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to the core package are documented here.
4
4
  Format: Keep a Changelog · 中文 · 号在发布时定(CalVer——见 `docs/RELEASE.md` §4)。
5
5
 
6
+ ## [0.10.6] — 2026-10-13
7
+
8
+ > 0.10.5 → 0.10.6(月内 +1——发布时定号;P0 热修)
9
+
10
+ ### Fixed
11
+
12
+ - **核包 `files` 白名单补 `browser/`**(P0——0.10.5 装机缺该目录 ⇒ CLI 装后启动崩(`ERR_MODULE_NOT_FOUND`):`tools/browser.mjs:13-15` ∥ `permission.mjs:16` 引 `../browser/*` 而白名单未列;台账 #1362)。发布守则教训:白名单须覆盖运行时引用闭包——发布预检现只对根 `.mjs` ∥ `prompts/` ∥ `tool-docs/` 计数(引闭包断言待另轮补,已登记)。
13
+
6
14
  ## [0.10.5] — 2026-10-13
7
15
 
8
16
  > 0.10.4 → 0.10.5(月内 +1——发布时定号)
@@ -0,0 +1,163 @@
1
+ /**
2
+ * browser/actions.mjs — 基线八动作实现(navigate / snapshot / click / type / evaluate / wait / screenshot / close)
3
+ * (BROWSER-TOOL.md §2.2 ∥ §2.4;KD-10:click / type 内部选型**不迁** Input 域——对外语义零回归)。
4
+ *
5
+ * 会话能力一律经句柄取(`h.call` / `h.evalRaw` / `h.takeSnapshot` / `h.ensureSession` / `h.pageError` /
6
+ * `h.step` / `h.sleep` …)——本档不 import `session.mjs`(DAG 单向,§5 拆分决定)。
7
+ * 硬超时(§2.9):wait 的谓词帽(`timeoutMs`:缺省 30s · 上限 120s)语义零变——动作预算 = 谓词帽 + 15s
8
+ * 余量(`queue.mjs` 预算表);轮询循环标 `wait poll` 步名(卡点可诊断——N-BT10)。
9
+ */
10
+ import { DEFAULT_MAX, clickExpression, typeExpression, truncateText, waitExpression } from "./snapshot.mjs"
11
+
12
+ export const WAIT_TIMEOUT_DEFAULT = 30_000
13
+ export const WAIT_TIMEOUT_MAX = 120_000
14
+ export const WAIT_POLL_MS = 150
15
+ export const NETWORK_QUIET_MS = 500
16
+ export const MAX_EVAL_CHARS = 8_000
17
+
18
+ function normalizeHttpUrl(value) {
19
+ const raw = String(value ?? "")
20
+ let url
21
+ try { url = new URL(raw) } catch { url = null }
22
+ if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) {
23
+ throw new Error(`navigate requires an http/https url — got "${raw}"`)
24
+ }
25
+ return url.toString()
26
+ }
27
+
28
+ function jsonOf(value) {
29
+ if (value === undefined) return "undefined"
30
+ try { return JSON.stringify(value) ?? String(value) }
31
+ catch { return String(value) }
32
+ }
33
+
34
+ /** navigate(§2.2):http/https 限;回执 = `[navigate] <final-url>` + 内联新页快照(省一轮 snapshot)。 */
35
+ async function actNavigate(args, ctx, h) {
36
+ const url = normalizeHttpUrl(args.url)
37
+ h.assertHostAllowed(url, ctx) // 静态参数面先检——被拦的 host 不得起浏览器(§3.2「不执行」)
38
+ await h.ensureSession(args)
39
+ await h.call("Page.navigate", { url })
40
+ await h.waitForReady()
41
+ const snap = await h.takeSnapshot({ max: DEFAULT_MAX })
42
+ return `[navigate] ${snap.url}\n${snap.text}`
43
+ }
44
+
45
+ /** snapshot(§2.2):引用清单(生成 / 复用 / 标记——§2.3)。 */
46
+ async function actSnapshot(args, ctx, h) {
47
+ await h.ensureSession(args)
48
+ const snap = await h.takeSnapshot({ selector: args.selector ?? null, max: args.max ?? DEFAULT_MAX })
49
+ return snap.text
50
+ }
51
+
52
+ /** click(§2.2):DOM 级语义激活(`el.click()`——KD-10);导航判据 = URL 变化 ⇒ `[navigated]` / `[no navigation]`。 */
53
+ async function actClick(args, ctx, h) {
54
+ await h.ensureSession(args)
55
+ const info = await h.pageInfo()
56
+ h.assertHostAllowed(info.url, ctx)
57
+ const entry = h.resolveRef(args.ref)
58
+ const res = await h.evalRaw(clickExpression(entry.selector, entry.tag))
59
+ if (!res?.found || res.tag) throw h.pageError(`ref ${args.ref} is stale (was "${entry.name}") — run snapshot again`)
60
+ if (res.disabled) throw h.pageError(`ref ${args.ref} is disabled ("${entry.name}")`)
61
+ const out = [`[click] ${args.ref} "${entry.name}"`]
62
+ const landed = await h.waitForUrlChange(info.url)
63
+ if (landed) {
64
+ await h.waitForReady()
65
+ const snap = await h.takeSnapshot({ max: DEFAULT_MAX })
66
+ out.push(`[navigated] ${snap.url}`, snap.text)
67
+ } else {
68
+ out.push("[no navigation]")
69
+ }
70
+ return out.join("\n")
71
+ }
72
+
73
+ /** type(§2.2):写值(原生 value setter + input/change);password 目标 ⇒ 值不回显(N-BT4)。 */
74
+ async function actType(args, ctx, h) {
75
+ await h.ensureSession(args)
76
+ const info = await h.pageInfo()
77
+ h.assertHostAllowed(info.url, ctx)
78
+ const entry = h.resolveRef(args.ref)
79
+ const text = String(args.text)
80
+ const res = await h.evalRaw(typeExpression(entry.selector, entry.tag, text, args.clear === true))
81
+ if (!res?.found || res.tag) throw h.pageError(`ref ${args.ref} is stale (was "${entry.name}") — run snapshot again`)
82
+ if (res.fillable === false) {
83
+ throw h.pageError(`ref ${args.ref} is not a fillable field ("${entry.name}", <${entry.tag}>) — use snapshot to pick an input/textarea`)
84
+ }
85
+ if (res.disabled) throw h.pageError(`ref ${args.ref} is disabled ("${entry.name}")`)
86
+ return `[type] ${args.ref} "${entry.name}" ← ${text.length} chars${res.password ? " (hidden)" : ""}`
87
+ }
88
+
89
+ /** evaluate(§2.2):页面上下文 JS;异常 ⇒ `Error: evaluate failed: <message>`;回执受 8000 字符上限(N-BT3)。 */
90
+ async function actEvaluate(args, ctx, h) {
91
+ await h.ensureSession(args)
92
+ const info = await h.pageInfo()
93
+ h.assertHostAllowed(info.url, ctx)
94
+ let value
95
+ try {
96
+ value = await h.evalRaw(String(args.expression))
97
+ } catch (e) {
98
+ if (e?.browserAbort || e?.sessionLost) throw e // 预算中止 / 外部关闭 ⇒ 原样上报(不裹进页面异常句)
99
+ throw h.pageError(`evaluate failed: ${e?.message ?? e}`)
100
+ }
101
+ return `[evaluate @ ${info.url}] ${truncateText(jsonOf(value), MAX_EVAL_CHARS, "narrow the expression")}`
102
+ }
103
+
104
+ function waitPredicate(args) {
105
+ if (args.networkIdle === true) return { kind: "networkIdle", value: null, label: "networkIdle" }
106
+ for (const kind of ["selector", "text", "url"]) {
107
+ const value = args[kind]
108
+ if (typeof value === "string" && value !== "") return { kind, value, label: `${kind} ${JSON.stringify(value)}` }
109
+ }
110
+ // 工具层已校验互斥(§2.2)——此处兜底(直调会话面时同样不悬挂)
111
+ throw new Error("wait requires exactly one of selector|text|url|networkIdle")
112
+ }
113
+
114
+ /** wait(§2.2):四选一谓词 + 150ms 轮询;networkIdle = 事件在飞归零后静默 500ms。 */
115
+ async function actWait(args, ctx, h) {
116
+ await h.ensureSession(args)
117
+ const pred = waitPredicate(args)
118
+ const wanted = Number(args.timeoutMs)
119
+ const timeoutMs = Math.max(1, Math.min(Number.isFinite(wanted) && wanted > 0 ? wanted : WAIT_TIMEOUT_DEFAULT, WAIT_TIMEOUT_MAX))
120
+ const started = Date.now()
121
+ return h.step("wait poll", async () => {
122
+ for (;;) {
123
+ if (pred.kind === "networkIdle") {
124
+ const net = h.networkState()
125
+ if (net.inflight.size === 0 && Date.now() - net.lastEventAt >= NETWORK_QUIET_MS) break
126
+ } else if (await h.evalRaw(waitExpression(pred.kind, pred.value)) === true) break
127
+ if (Date.now() - started >= timeoutMs) throw h.pageError(`wait timed out after ${timeoutMs}ms (${pred.label})`)
128
+ await h.sleep(WAIT_POLL_MS)
129
+ }
130
+ return `[wait] ${pred.label} — ok after ${Date.now() - started}ms`
131
+ })
132
+ }
133
+
134
+ /** screenshot(§2.2):PNG 落盘返路径(KD-6——模型经 read_image 查看)。 */
135
+ async function actScreenshot(args, ctx, h) {
136
+ await h.ensureSession(args)
137
+ const shot = await h.call("Page.captureScreenshot", {
138
+ format: "png",
139
+ ...(args.fullPage === true ? { captureBeyondViewport: true } : {}),
140
+ })
141
+ if (typeof shot?.data !== "string") throw new Error("screenshot failed: the browser returned no image data")
142
+ const saved = await h.saveShot(Buffer.from(shot.data, "base64"))
143
+ return `[screenshot] ${saved.path} (${saved.bytes} bytes)`
144
+ }
145
+
146
+ /** close(§2.2):幂等——无会话 ⇒ `[close] no browser session`(成功态)。 */
147
+ async function actClose(args, ctx, h) {
148
+ if (!h.connection()) return "[close] no browser session"
149
+ await h.closeSession()
150
+ return "[close] browser session closed"
151
+ }
152
+
153
+ /** 基线八动作分发表(会话档并入总表——§5)。 */
154
+ export const ACTIONS = {
155
+ navigate: actNavigate,
156
+ snapshot: actSnapshot,
157
+ click: actClick,
158
+ type: actType,
159
+ evaluate: actEvaluate,
160
+ wait: actWait,
161
+ screenshot: actScreenshot,
162
+ close: actClose,
163
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * browser/cdp.mjs — CDP 传输客户端(零第三方——N-BT1:Node ≥ 24 原生 WebSocket)。
3
+ *
4
+ * 形沿 `scripts/console-walkthrough.mjs:133-141` 的实证客户端(id 派发 ∥ 事件派发)——本文档把它
5
+ * 拆成类以承载多动作会话(connect / call / on / close),并加 HTTP 面 `/json/new` 取标签页。
6
+ * `WebSocketImpl` = 注入缝(缺省 `globalThis.WebSocket`;单元测试注假传输——缺省回落真实现,`??`)。
7
+ *
8
+ * 错误语义:CDP 回 `{ error: { message } }` ⇒ 拒以 `Error(message)`(BROWSER-TOOL.md §5)。
9
+ * 帽(§2.9 三层帽——层二 / 层三):`call` 缺省帽 15s(任何未显式配帽的调用也不悬挂);`connectCdp` open 帽
10
+ * 10s;`/json/new` fetch 帽 5s——动作内调用另随动作控制器(session 侧接线,预算先行)。
11
+ * 断连面:WS `onclose` / `onerror` ⇒ 拒在飞命令**并通知订阅者**(session 据此复位会话态——§2.10 源②)。
12
+ */
13
+
14
+ /** `call` 缺省兜底帽(§2.9 层三——未显式配帽的调用同样有界)。 */
15
+ export const DEFAULT_CALL_TIMEOUT_MS = 15_000
16
+ /** `connectCdp` open 帽(§2.9 层二)。 */
17
+ export const CONNECT_TIMEOUT_MS = 10_000
18
+ /** `/json/new` fetch 帽(§2.9 层二)。 */
19
+ export const NEWTAB_TIMEOUT_MS = 5_000
20
+
21
+ /** 与浏览器标签页的一条 CDP 连接。 */
22
+ export class CdpClient {
23
+ constructor(ws) {
24
+ this._ws = ws
25
+ this._nextId = 0
26
+ this._pending = new Map()
27
+ this._listeners = new Map()
28
+ this._disconnects = []
29
+ this.closed = false
30
+ ws.onmessage = (ev) => this._onMessage(ev)
31
+ ws.onclose = () => this._die(new Error("CDP connection closed"))
32
+ ws.onerror = () => this._die(new Error("CDP connection error"))
33
+ }
34
+
35
+ _onMessage(ev) {
36
+ let msg
37
+ try { msg = JSON.parse(ev.data) } catch { return } // 非 JSON 帧(调试器噪声)忽略
38
+ if (msg.id !== undefined && this._pending.has(msg.id)) {
39
+ const p = this._pending.get(msg.id)
40
+ this._pending.delete(msg.id)
41
+ if (msg.error) p.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)))
42
+ else p.resolve(msg.result ?? {})
43
+ return
44
+ }
45
+ const handlers = msg.method ? this._listeners.get(msg.method) : null
46
+ if (!handlers) return
47
+ for (const handler of handlers) {
48
+ try { handler(msg.params ?? {}, msg.method) } catch { /* 订阅者自担——一条订阅抛错不打断派发 */ }
49
+ }
50
+ }
51
+
52
+ /** 连接断(WS close/error)单点:通知订阅者(先)⇒ 拒在飞命令(同一因由实例——§2.10)。 */
53
+ _die(error) {
54
+ if (this.closed) return
55
+ this.closed = true
56
+ for (const handler of this._disconnects) {
57
+ try { handler(error) } catch { /* 订阅者自担 */ }
58
+ }
59
+ this._abortAll(error)
60
+ }
61
+
62
+ _abortAll(error) {
63
+ for (const pending of this._pending.values()) pending.reject(error)
64
+ this._pending.clear()
65
+ }
66
+
67
+ /** 订阅断连(会话健康接线——§2.10 源②);返回退订函数。 */
68
+ onDisconnect(handler) {
69
+ this._disconnects.push(handler)
70
+ return () => { this._disconnects = this._disconnects.filter((h) => h !== handler) }
71
+ }
72
+
73
+ /** 发一条 CDP 命令,解析其 result(失败拒以 `Error`);缺省帽 15s(§2.9 层三)。 */
74
+ call(method, params = {}, { timeoutMs = DEFAULT_CALL_TIMEOUT_MS } = {}) {
75
+ if (this.closed) return Promise.reject(new Error("CDP client is closed"))
76
+ const id = ++this._nextId
77
+ return new Promise((resolve, reject) => {
78
+ const timer = setTimeout(() => {
79
+ this._pending.delete(id)
80
+ const error = new Error(`${method} did not respond within ${timeoutMs}ms`)
81
+ error.cdpTimeout = true
82
+ reject(error)
83
+ }, timeoutMs)
84
+ timer.unref?.() // 挂死型调用不吊住宿主进程(命令真返回时在 settle 里清钟)
85
+ const settle = (fn, value) => { clearTimeout(timer); this._pending.delete(id); fn(value) }
86
+ this._pending.set(id, { resolve: (v) => settle(resolve, v), reject: (e) => settle(reject, e) })
87
+ try { this._ws.send(JSON.stringify({ id, method, params })) }
88
+ catch (e) { settle(reject, e) }
89
+ })
90
+ }
91
+
92
+ /** 订阅 CDP 事件(无 id 帧);返回退订函数。 */
93
+ on(method, handler) {
94
+ const list = this._listeners.get(method) ?? []
95
+ list.push(handler)
96
+ this._listeners.set(method, list)
97
+ return () => this._listeners.set(method, (this._listeners.get(method) ?? []).filter((h) => h !== handler))
98
+ }
99
+
100
+ close() {
101
+ if (this.closed) return
102
+ this.closed = true
103
+ this._abortAll(new Error("CDP client closed"))
104
+ try { this._ws.close() } catch { /* 已断开 */ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * 连标签页调试地址(open 帽 10s——§2.9 层二)。
110
+ * @param {string} wsUrl `webSocketDebuggerUrl`
111
+ * @param {{WebSocketImpl?: Function, openTimeoutMs?: number}} [opts] 注入缝 ∥ open 帽
112
+ */
113
+ export async function connectCdp(wsUrl, { WebSocketImpl, openTimeoutMs = CONNECT_TIMEOUT_MS } = {}) {
114
+ const Impl = WebSocketImpl ?? globalThis.WebSocket
115
+ if (typeof Impl !== "function") {
116
+ throw new Error("no WebSocket implementation available — Node >= 24 required (CDP transport is stdlib-only)")
117
+ }
118
+ const ws = new Impl(wsUrl)
119
+ await new Promise((resolve, reject) => {
120
+ let settled = false
121
+ const finish = (fn, value) => {
122
+ if (settled) return
123
+ settled = true
124
+ clearTimeout(timer)
125
+ fn(value)
126
+ }
127
+ const timer = setTimeout(
128
+ () => finish(reject, new Error(`failed to connect to CDP at ${wsUrl} (no response within ${openTimeoutMs}ms)`)),
129
+ openTimeoutMs,
130
+ )
131
+ timer.unref?.()
132
+ ws.onopen = () => finish(resolve)
133
+ ws.onerror = () => finish(reject, new Error(`failed to connect to CDP at ${wsUrl}`))
134
+ })
135
+ return new CdpClient(ws)
136
+ }
137
+
138
+ /** 开一张标签页并取其调试地址(`PUT /json/new`,GET 兜底——walkthrough `:131-132` 同式;fetch 帽 5s)。 */
139
+ export async function newTabWebSocketUrl(port, { host = "127.0.0.1", fetchImpl = globalThis.fetch, timeoutMs = NEWTAB_TIMEOUT_MS } = {}) {
140
+ const url = `http://${host}:${port}/json/new?about:blank`
141
+ let timer = null
142
+ const deadline = new Promise((_, reject) => {
143
+ timer = setTimeout(
144
+ () => reject(new Error(`browser did not answer /json/new within ${timeoutMs}ms (port ${port})`)),
145
+ timeoutMs,
146
+ )
147
+ timer.unref?.()
148
+ })
149
+ const fetchTab = async (init) => (await fetchImpl(url, init)).json()
150
+ try {
151
+ let tab
152
+ try { tab = await Promise.race([fetchTab({ method: "PUT" }), deadline]) }
153
+ catch { tab = await Promise.race([fetchTab(), deadline]) } // GET 兜底(同帽——到点即拒,不悬挂)
154
+ if (!tab?.webSocketDebuggerUrl) {
155
+ throw new Error(`browser did not return a debuggable tab from /json/new (port ${port})`)
156
+ }
157
+ return tab.webSocketDebuggerUrl
158
+ } finally {
159
+ if (timer) clearTimeout(timer)
160
+ }
161
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * browser/clipboard.mjs — 剪贴板动作(read / write / copy / paste)∥ Browser 域授权 ∥ 页侧表达式
3
+ * (BROWSER-TOOL.md §3.5 ∥ §2.2 clipboard ∥ N-BT9)。
4
+ *
5
+ * 路径(KD-13):CDP 无剪贴板直控方法 ⇒ 页面 API(`navigator.clipboard`)+ Browser 域按 origin 惰性授予;
6
+ * `write` 页面拒 / 非安全上下文 ⇒ 回落 `document.execCommand('copy')` 隐藏 textarea 面(再败才报错)。
7
+ * copy / paste = 真加速键(平台分支:darwin ⇒ Meta+C/V;win32 / linux ⇒ Ctrl+C/V——受信按键直驱浏览器复制 / 粘贴)。
8
+ * 隐私(N-BT9):read 回执上限 8000 字符 + 截断标记;write 回执不回显文本;剪贴板内容不落盘、不写日志(本档零 fs 面)。
9
+ */
10
+ import { pressSequence } from "./input.mjs"
11
+ import { truncateText } from "./snapshot.mjs"
12
+
13
+ export const CLIPBOARD_MAX_CHARS = 8_000
14
+ export const CLIPBOARD_OPS = ["read", "write", "copy", "paste"]
15
+ /** `Browser.setPermission` 取的描述符名(实测在册可用——PermissionDescriptor ≠ PermissionType)。 */
16
+ export const CLIPBOARD_PERMISSION_DESCRIPTORS = ["clipboard-read", "clipboard-write"]
17
+ /** `Browser.grantPermissions` 取的权限枚举(PermissionType——回落面,两法同名不同名面,实测在案)。 */
18
+ export const CLIPBOARD_PERMISSIONS = ["clipboardReadWrite", "clipboardSanitizedWrite"]
19
+
20
+ const mark = (kind) => `/*thincoder-browser:${kind}*/`
21
+
22
+ /** 授权缓存(§3.5:会话内按 origin 缓存——close / 换 origin 重授;键 = 连接身份)。 */
23
+ let grantCache = { client: null, origins: new Set() }
24
+ /** 无头焦点模拟缓存(§3.5:会话内一次性——仅剪贴板动作触发,不进其他动作路径)。 */
25
+ let focusCache = { client: null, done: false }
26
+
27
+ /** 无头焦点适配:`document.hasFocus()` 为假 ⇒ `Emulation.setFocusEmulationEnabled`(一次/会话)。 */
28
+ async function ensureFocusEmulation(h) {
29
+ const client = h.connection()
30
+ if (client && focusCache.client === client && focusCache.done) return
31
+ let hasFocus = true
32
+ try { hasFocus = (await h.evalRaw("document.hasFocus()")) === true } catch { hasFocus = true } // 页不可读 ⇒ 不模拟
33
+ if (!hasFocus) await h.call("Emulation.setFocusEmulationEnabled", { enabled: true })
34
+ focusCache = { client, done: true }
35
+ }
36
+
37
+ /**
38
+ * 按当前页 origin 惰性授予(§3.5):`setPermission` 两权(描述符名)→ 方法缺 / 拒 ⇒ `grantPermissions`
39
+ * 回落(PermissionType 枚举)→ 再败明示错。实测(本机 Edge):`setPermission` 只认描述符名
40
+ * (`clipboard-read` / `clipboard-write`);PermissionType 名在该方法上被拒(版本 / 名面漂移——回落层兜住)。
41
+ */
42
+ async function ensureClipboardPermission(h, origin) {
43
+ const client = h.connection()
44
+ if (grantCache.client !== client) grantCache = { client, origins: new Set() }
45
+ if (grantCache.origins.has(origin)) return
46
+ try {
47
+ for (const name of CLIPBOARD_PERMISSION_DESCRIPTORS) {
48
+ await h.call("Browser.setPermission", { permission: { name }, setting: "granted", origin })
49
+ }
50
+ } catch (e) {
51
+ try {
52
+ await h.call("Browser.grantPermissions", { permissions: CLIPBOARD_PERMISSIONS, origin })
53
+ } catch (e2) {
54
+ throw h.pageError(`clipboard permission failed — setPermission: ${e?.message ?? e}; grantPermissions fallback: ${e2?.message ?? e2}`)
55
+ }
56
+ }
57
+ grantCache.origins.add(origin)
58
+ }
59
+
60
+ /** 当前页 origin(授权键);无真实页 origin(about:blank 等)⇒ 明示拒(不静默降级)。 */
61
+ async function pageOrigin(h) {
62
+ const info = await h.pageInfo()
63
+ let origin = null
64
+ try { origin = new URL(info.url).origin } catch { origin = null }
65
+ if (!origin || origin === "null") throw h.pageError(`clipboard requires an http/https page origin — current page is "${info.url}"`)
66
+ return origin
67
+ }
68
+
69
+ /** 页侧写表达式:`navigator.clipboard.writeText` → 失败回落 `document.execCommand('copy')` 隐藏 textarea。 */
70
+ function clipboardWriteExpression(text) {
71
+ return `${mark("clipboard-write")}(async function () {
72
+ var text = ${JSON.stringify(text)}
73
+ try {
74
+ if (navigator.clipboard && navigator.clipboard.writeText) {
75
+ await navigator.clipboard.writeText(text)
76
+ return { ok: true, via: 'navigator.clipboard' }
77
+ }
78
+ } catch (e) { /* 页面拒 / 非安全上下文 ⇒ 走 execCommand 回落 */ }
79
+ try {
80
+ var ta = document.createElement('textarea')
81
+ ta.value = text
82
+ ta.setAttribute('readonly', '')
83
+ ta.style.position = 'fixed'
84
+ ta.style.opacity = '0'
85
+ document.body.appendChild(ta)
86
+ ta.select()
87
+ var ok = false
88
+ try { ok = document.execCommand('copy') } finally { document.body.removeChild(ta) }
89
+ return ok ? { ok: true, via: 'execCommand' } : { ok: false, message: 'document.execCommand("copy") returned false' }
90
+ } catch (e2) { return { ok: false, message: String((e2 && e2.message) || e2) } }
91
+ })()`
92
+ }
93
+
94
+ /** 页侧读表达式:`navigator.clipboard.readText()`(安全上下文限定——非安全上下文给引导句)。 */
95
+ function clipboardReadExpression() {
96
+ return `${mark("clipboard-read")}(async function () {
97
+ try {
98
+ if (!navigator.clipboard || !navigator.clipboard.readText) {
99
+ return { ok: false, message: 'navigator.clipboard.readText is unavailable — a secure context (https or localhost) is required' }
100
+ }
101
+ var text = await navigator.clipboard.readText()
102
+ return { ok: true, text: String(text == null ? '' : text) }
103
+ } catch (e) { return { ok: false, message: String((e && e.message) || e) } }
104
+ })()`
105
+ }
106
+
107
+ /** 加速键序列(§3.5 平台分支):darwin ⇒ Meta+C/V;win32 / linux ⇒ Ctrl+C/V。 */
108
+ export function acceleratorSequence(op, platform = process.platform) {
109
+ const modifier = platform === "darwin" ? "Meta" : "Control"
110
+ const key = op === "copy" ? "c" : "v"
111
+ return pressSequence({ key, modifiers: [modifier] }).commands
112
+ }
113
+
114
+ /** clipboard(§2.2):op ∈ read / write / copy / paste;write 必填 text;copy / paste 可携 ref 聚焦。 */
115
+ async function actClipboard(args, ctx, h) {
116
+ const op = String(args.op ?? "")
117
+ if (!CLIPBOARD_OPS.includes(op)) throw new Error(`unknown clipboard op "${op}" — ops: ${CLIPBOARD_OPS.join(", ")}`)
118
+ await h.ensureSession(args)
119
+ await ensureFocusEmulation(h)
120
+ const origin = await pageOrigin(h)
121
+ await ensureClipboardPermission(h, origin)
122
+ if (op === "write") {
123
+ const text = String(args.text)
124
+ const res = await h.evalRaw(clipboardWriteExpression(text))
125
+ if (!res?.ok) throw h.pageError(`clipboard write failed: ${res?.message ?? "the page rejected the write"}`)
126
+ return `[clipboard] write ← ${text.length} chars` // 不回显文本(N-BT9)
127
+ }
128
+ if (op === "read") {
129
+ const res = await h.evalRaw(clipboardReadExpression())
130
+ if (!res?.ok) throw h.pageError(`clipboard read failed: ${res?.message ?? "the page rejected the read"}`)
131
+ const text = String(res.text ?? "")
132
+ const body = text ? `\n${truncateText(text, CLIPBOARD_MAX_CHARS, `clipboard reads are capped at ${CLIPBOARD_MAX_CHARS} chars`)}` : ""
133
+ return `[clipboard] read (${text.length} chars)${body}`
134
+ }
135
+ let target = null
136
+ if (typeof args.ref === "string" && args.ref !== "") target = await h.focusRef(args.ref)
137
+ for (const cmd of acceleratorSequence(op)) await h.call(cmd.method, cmd.params)
138
+ return `[clipboard] ${op}${target ? ` → ${target.label}` : ""}`
139
+ }
140
+
141
+ /** 剪贴板分发表(会话档并入总表——§5)。 */
142
+ export const CLIPBOARD_ACTIONS = { clipboard: actClipboard }