@thincoder/core 0.10.4 → 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.
Files changed (153) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +1 -1
  3. package/advisor/compaction.mjs +11 -6
  4. package/advisor/run.mjs +4 -4
  5. package/agent/assemble.mjs +1 -1
  6. package/agent/chat-call.mjs +1 -1
  7. package/agent/child-marks.mjs +2 -2
  8. package/agent/digest-account.mjs +5 -4
  9. package/agent/dispatch-gates.mjs +8 -0
  10. package/agent/dispatch.mjs +39 -18
  11. package/agent/family-tools.mjs +22 -11
  12. package/agent/helpers.mjs +4 -3
  13. package/agent/light-round.mjs +66 -0
  14. package/agent/post-turn.mjs +8 -4
  15. package/agent/run-stages.mjs +5 -3
  16. package/agent/run-start.mjs +3 -2
  17. package/agent/setup-reminders.mjs +1 -1
  18. package/agent/setup.mjs +18 -3
  19. package/agent/subagent-event.mjs +1 -1
  20. package/agent/suspension.mjs +8 -5
  21. package/agent/timers.mjs +56 -5
  22. package/agent/turn-loop.mjs +5 -3
  23. package/agent/write-gate.mjs +7 -7
  24. package/agent-tools/advisor-async.mjs +2 -2
  25. package/agent-tools/advisor-settle.mjs +2 -2
  26. package/agent-tools/advisor.mjs +1 -1
  27. package/agent-tools/async-discard.mjs +34 -0
  28. package/agent-tools/async-settle.mjs +3 -2
  29. package/agent-tools/audit-block.mjs +1 -1
  30. package/agent-tools/bash-async.mjs +1 -1
  31. package/agent-tools/batch-lifecycle.mjs +35 -14
  32. package/agent-tools/batch-paths.mjs +4 -4
  33. package/agent-tools/batch-skeleton.mjs +29 -11
  34. package/agent-tools/batch.mjs +28 -15
  35. package/agent-tools/browser-async.mjs +198 -0
  36. package/agent-tools/checkpoint.mjs +1 -1
  37. package/agent-tools/design-slots.mjs +232 -0
  38. package/agent-tools/digest-budget.mjs +1 -1
  39. package/agent-tools/manifest.mjs +193 -0
  40. package/agent-tools/parent-channel.mjs +7 -4
  41. package/agent-tools/review-facts.mjs +13 -13
  42. package/agent-tools/settings.mjs +1 -1
  43. package/agent-tools/skill.mjs +1 -1
  44. package/agent-tools/subagent-async.mjs +21 -12
  45. package/agent-tools/subagent-scheduler.mjs +8 -5
  46. package/agent-tools/subagent-spawn.mjs +29 -55
  47. package/agent-tools/subagent.mjs +21 -8
  48. package/agent-tools/timer.mjs +186 -12
  49. package/agent-tools/zero-write-watch.mjs +2 -2
  50. package/agent-tools.mjs +2 -1
  51. package/agent.mjs +1 -1
  52. package/attachments.mjs +1 -1
  53. package/browser/actions.mjs +163 -0
  54. package/browser/cdp.mjs +161 -0
  55. package/browser/clipboard.mjs +142 -0
  56. package/browser/input-actions.mjs +197 -0
  57. package/browser/input.mjs +256 -0
  58. package/browser/launch.mjs +143 -0
  59. package/browser/queue.mjs +171 -0
  60. package/browser/session.mjs +494 -0
  61. package/browser/snapshot.mjs +270 -0
  62. package/config-io.mjs +18 -13
  63. package/config-migrate.mjs +13 -29
  64. package/config-presets.mjs +33 -29
  65. package/config.mjs +68 -25
  66. package/context-degrade.mjs +1 -1
  67. package/context-echo.mjs +1 -1
  68. package/context-push.mjs +2 -2
  69. package/context.mjs +1 -1
  70. package/conventions.mjs +2 -2
  71. package/declaration.mjs +18 -2
  72. package/file-links.mjs +1 -1
  73. package/file-refs.mjs +1 -1
  74. package/history-window.mjs +1 -1
  75. package/i18n.mjs +2 -0
  76. package/ledger-cmd.mjs +62 -16
  77. package/ledger-db.mjs +6 -0
  78. package/ledger-surface.mjs +49 -5
  79. package/ledger-tools.mjs +16 -8
  80. package/ledger.mjs +9 -16
  81. package/log.mjs +1 -1
  82. package/manifest-discovery.mjs +1 -1
  83. package/manifest-schema.mjs +1 -1
  84. package/mcp/transport-stdio.mjs +1 -1
  85. package/mcp.mjs +1 -1
  86. package/memory/code-index.mjs +1 -1
  87. package/memory/code-search.mjs +1 -1
  88. package/memory/code-sync.mjs +1 -1
  89. package/memory/docs.mjs +6 -2
  90. package/memory/file-list.mjs +1 -1
  91. package/memory/file-walk.mjs +17 -5
  92. package/memory/maintain.mjs +410 -0
  93. package/memory/memory-tool.mjs +21 -6
  94. package/memory/schema.mjs +19 -7
  95. package/memory/sweep.mjs +8 -5
  96. package/memory/sync-tail.mjs +16 -10
  97. package/model-ref.mjs +16 -13
  98. package/model-specs.mjs +21 -9
  99. package/package.json +2 -1
  100. package/peer-instances.mjs +1 -1
  101. package/permission.mjs +33 -0
  102. package/process-probe.mjs +2 -2
  103. package/prompts/advisor-design.md +4 -2
  104. package/prompts/advisor-round1.md +1 -1
  105. package/prompts/advisor-round2.md +1 -1
  106. package/prompts/advisor-round3.md +1 -1
  107. package/prompts/common.md +36 -2
  108. package/prompts/consult-base.md +1 -1
  109. package/prompts/discipline-engineering.md +32 -15
  110. package/prompts/discipline-normal.md +46 -2
  111. package/prompts/persona-eng-designer.md +6 -6
  112. package/prompts/persona-engineering.md +14 -6
  113. package/prompts/persona-normal.md +2 -0
  114. package/prompts/subagent-base.md +3 -0
  115. package/provider/core.mjs +6 -0
  116. package/provider/errors.mjs +5 -3
  117. package/provider/google.mjs +9 -8
  118. package/provider/list-models.mjs +3 -2
  119. package/provider/sse.mjs +3 -0
  120. package/provider-flows.mjs +14 -15
  121. package/proxy-chunked.mjs +102 -0
  122. package/proxy-target.mjs +54 -0
  123. package/proxy-transport.mjs +298 -0
  124. package/proxy.mjs +17 -248
  125. package/session-gc.mjs +2 -2
  126. package/session-lifecycle.mjs +18 -14
  127. package/session-slot-claims.mjs +2 -2
  128. package/session-slots-manifest.mjs +1 -1
  129. package/session-slots.mjs +3 -3
  130. package/team-ledger.mjs +282 -0
  131. package/team.mjs +223 -0
  132. package/tool-docs/bash.md +1 -0
  133. package/tool-docs/batch.md +1 -1
  134. package/tool-docs/browser.md +48 -0
  135. package/tool-docs/ledger.md +3 -3
  136. package/tool-docs/manifest.md +16 -0
  137. package/tool-docs/memory.md +1 -0
  138. package/tool-docs/notify_parent.md +5 -0
  139. package/tool-docs/process.md +4 -4
  140. package/tool-docs/question.md +1 -0
  141. package/tool-docs/subagent.md +4 -3
  142. package/tool-docs/team_ledger.md +9 -0
  143. package/tool-docs/timer.md +6 -0
  144. package/tool-docs/wait_for.md +1 -0
  145. package/tools/browser.mjs +161 -0
  146. package/tools/git.mjs +1 -1
  147. package/tools/index.mjs +3 -0
  148. package/tools/ops.mjs +26 -10
  149. package/tools/repomap.mjs +1 -1
  150. package/tools/write-path.mjs +2 -2
  151. package/traces/trace-cleanup.mjs +1 -1
  152. package/traces/trace-store.mjs +1 -1
  153. package/vision-reader.mjs +10 -16
@@ -0,0 +1,494 @@
1
+ /**
2
+ * browser/session.mjs — 会话单例:惰性开启 ∥ 串行队列 ∥ 生命周期 ∥ 页原语 ∥ 安全助手 ∥ 分发表 ∥ 会话句柄。
3
+ * 设计权威 = `docs/core/design/BROWSER-TOOL.md` §2.4(会话模型)∥ §2.5(数据流)∥ §2.9–§2.10(动作预算 /
4
+ * 会话健康)∥ §3(安全面)∥ §5(拆分决定)。
5
+ *
6
+ * 单会话/进程(KD-5):同进程所有 agent 共一份浏览器;动作串行(同一队列——批并行调用天然排队)。
7
+ * 硬超时(§2.9):每动作入队即记预算 deadline(队列等待计入——有意),到点由 `queue.mjs` 动作控制器中止
8
+ * (错误句携卡点步名);一切 CDP 命令经本档 `cdpCall` 单点接线(步名 = 方法名 ∥ 中止守卫 ∥ abort 后快速失败)。
9
+ * 会话健康(§2.10):进程事件 ∥ 连接事件 ∥ 入口探针三源;外部关闭 ⇒ `failSession` 单点(拒在飞 ⇒ 态复位
10
+ * ⇒ 释放 profile 锁 ⇒ 置重开注记),下一次动作自愈重开(首回执尾行注记——置位一次,发出即清)。
11
+ * `headless` = 会话开启参数(KD-4:异值报错,不热切);空闲 15 分钟自动关(KD-8)+ 进程退出杀树兜底。
12
+ * 动作实施层 = `actions.mjs`(基线八)+ `input-actions.mjs`(新七)+ `clipboard.mjs`——经会话句柄取能力,
13
+ * 不 import 本档(DAG 单向:`session → {actions, input-actions, clipboard} → {input, snapshot, cdp}`;`session → queue`,§5)。
14
+ *
15
+ * 注入缝(N-BT6):`_deps.openBrowser` ∥ `_deps.killBrowser` ∥ `_deps.saveShot`——缺省 null ⇒
16
+ * 回落真实现(`??`);用例注入后 `finally` 还原。
17
+ */
18
+ import { mkdirSync, writeFileSync } from "node:fs"
19
+ import { join } from "node:path"
20
+ import { ACTIONS } from "./actions.mjs"
21
+ import { CLIPBOARD_ACTIONS } from "./clipboard.mjs"
22
+ import { INPUT_ACTIONS } from "./input-actions.mjs"
23
+ import { DEFAULT_CALL_TIMEOUT_MS, connectCdp, newTabWebSocketUrl } from "./cdp.mjs"
24
+ import { acquireProfileLock, browserPaths, killBrowser, launchBrowser, releaseProfileLock, resolveBrowser } from "./launch.mjs"
25
+ import { createActionQueue } from "./queue.mjs"
26
+ import {
27
+ COMPACT_ROWS, DEFAULT_MAX, MAX_ELEMENTS, assignRefs, createRefTable, focusExpression, normalizeSnapshot,
28
+ pageInfoExpression, renderSnapshot, snapshotExpression,
29
+ } from "./snapshot.mjs"
30
+
31
+ export const IDLE_MS = 15 * 60 * 1000
32
+ const READY_TIMEOUT_MS = 10_000
33
+ const NAV_SETTLE_MS = 1_000
34
+ const GRACEFUL_CLOSE_MS = 3_000
35
+ const CLOSE_CALL_MS = 3_000 // `Browser.close` 显式帽(§2.9 层二)
36
+ const ENABLE_CALL_MS = 10_000 // `Page/Runtime/Network.enable` 显式帽(§2.9 层二)
37
+ const PROBE_TIMEOUT_MS = 2_000 // 健康探针心跳帽(§2.9 层二 / §2.10 源③)
38
+ const PROBE_FRESH_MS = 5_000 // 距上次成功 CDP 活动 >5s 才做心跳(§2.10 源③)
39
+ const CALL_CAP_SLACK_MS = 1_000 // 动作内调用兜底帽对剩余预算的余量(预算先行:控制器先于兜底帽裁决)
40
+ const REOPEN_NOTE = "[note: the previous browser session was closed externally — a fresh session was opened (page state lost; profile/login kept)]"
41
+
42
+ /** 测试替身缝(缺省 null ⇒ 回落真实现)。 */
43
+ export const _deps = { openBrowser: null, killBrowser: null, saveShot: null }
44
+
45
+ /** 在飞动作的控制器(§2.9——单动作单控制器;串行队列 ⇒ 同刻至多一个)。 */
46
+ let currentController = null
47
+
48
+ const queue = createActionQueue()
49
+
50
+ const sleep = (ms) => (currentController ? currentController.sleep(ms) : new Promise((r) => setTimeout(r, ms)))
51
+
52
+ /** 控制器接线(无控制器 ⇒ 直通):步名帧 + 中止守卫(§2.9)。 */
53
+ function runFrame(name, work) {
54
+ return currentController ? currentController.run(name, work) : work()
55
+ }
56
+
57
+ const state = {
58
+ cdp: null,
59
+ child: null,
60
+ lockPath: null,
61
+ headless: null,
62
+ table: createRefTable(),
63
+ lastUrl: null,
64
+ lastTitle: null,
65
+ lastActivityAt: 0,
66
+ idleTimer: null,
67
+ reopenNote: false,
68
+ net: { inflight: new Set(), lastEventAt: 0 },
69
+ }
70
+
71
+ // ── 生命周期 ────────────────────────────────────────────────────────────────
72
+
73
+ /** 真实现:发现 → profile 锁 → spawn → `/json/new` → WebSocket → 三域 enable(§2.4 开启序)。
74
+ * 开启段中途中止 ⇒ 连接复位 + 杀树兜底(含「launch 未返回」窗口:结算即补杀)+ 释放 profile 锁;置重开注记在 ensureSession。 */
75
+ async function openBrowserReal({ headless }) {
76
+ const exe = resolveBrowser()
77
+ const paths = browserPaths()
78
+ acquireProfileLock(paths.lock)
79
+ let child = null, conn = null, launching = null
80
+ try {
81
+ const launched = await runFrame("launch browser", () => (launching = launchBrowser({ exe, headless, profile: paths.profile })))
82
+ child = launched.child
83
+ const wsUrl = await runFrame("connect CDP", () => newTabWebSocketUrl(launched.port))
84
+ conn = await runFrame("connect CDP", () => connectCdp(wsUrl))
85
+ for (const domain of ["Page.enable", "Runtime.enable", "Network.enable"]) {
86
+ await runFrame(domain, () => conn.call(domain, {}, { timeoutMs: ENABLE_CALL_MS }))
87
+ }
88
+ return { cdp: conn, child, headless, lock: paths.lock }
89
+ } catch (e) {
90
+ try { conn?.close() } catch { /* 已断 */ }
91
+ // 中止落在「launch 未返回」窗口 ⇒ child 未赋值:自启仍会结算 ⇒ 结算即补杀(§2.9 开启段中止——防孤儿)
92
+ if (child) { try { (_deps.killBrowser ?? killBrowser)(child) } catch { /* 已退出 */ } }
93
+ else if (launching) launching.then((l) => { try { (_deps.killBrowser ?? killBrowser)(l.child) } catch { /* 已退出 */ } }, () => { /* 自启自败(其内已杀树) */ })
94
+ releaseProfileLock(paths.lock)
95
+ throw e
96
+ }
97
+ }
98
+
99
+ /** 会话态复位(关闭 ∥ 外部关闭共用单点)。 */
100
+ function resetState() {
101
+ disarmIdle()
102
+ state.cdp = null
103
+ state.child = null
104
+ state.lockPath = null
105
+ state.headless = null
106
+ state.lastUrl = null
107
+ state.lastTitle = null
108
+ state.lastActivityAt = 0
109
+ state.table = createRefTable()
110
+ state.net = { inflight: new Set(), lastEventAt: 0 }
111
+ }
112
+
113
+ function armIdle() {
114
+ if (state.idleTimer) clearTimeout(state.idleTimer)
115
+ state.idleTimer = setTimeout(() => {
116
+ // 走串行队列:空闲关不与在途动作交错(下一动作排在关后——同 profile 不会双开)
117
+ queue.chain(() => closeSession()).catch(() => { /* 空闲关失败不逸出 */ })
118
+ }, IDLE_MS)
119
+ state.idleTimer.unref?.() // 不为一台浏览器吊住宿主进程
120
+ }
121
+
122
+ function disarmIdle() {
123
+ if (state.idleTimer) { clearTimeout(state.idleTimer); state.idleTimer = null }
124
+ }
125
+
126
+ let exitHooked = false
127
+ function registerExitHook() {
128
+ if (exitHooked) return
129
+ exitHooked = true
130
+ process.once("exit", () => { if (state.child) (_deps.killBrowser ?? killBrowser)(state.child) })
131
+ }
132
+
133
+ function headlessOf(args) {
134
+ return typeof args?.headless === "boolean" ? args.headless : undefined
135
+ }
136
+
137
+ // ── 会话健康(§2.10——外部关闭可感知 + 自愈)────────────────────────────────
138
+
139
+ /** 外部关闭显式失败句(§2.10):在飞动作与入口探针同一句式。 */
140
+ function externalCloseError(cause) {
141
+ const error = new Error(`the browser session was closed externally (${cause}) — run the action again to reopen a fresh session`)
142
+ error.sessionLost = true
143
+ return error
144
+ }
145
+
146
+ /** 处置单点(§2.10):拒在飞(同一因由实例)⇒ 态复位 ⇒ 释放 profile 锁(子进程仍活 ⇒ 杀树兜底)⇒ 置重开注记。
147
+ * 不调 `Browser.close`、不等自退(连接 / 进程已死)。 */
148
+ function failSession(cause) {
149
+ const error = externalCloseError(cause)
150
+ currentController?.abort("session-lost", error)
151
+ const { cdp, child, lockPath } = state
152
+ resetState()
153
+ if (cdp) { try { cdp.close() } catch { /* 已断 */ } }
154
+ if (child) { try { (_deps.killBrowser ?? killBrowser)(child) } catch { /* 已退出 */ } }
155
+ if (lockPath) releaseProfileLock(lockPath)
156
+ state.reopenNote = true
157
+ return error
158
+ }
159
+
160
+ /** 健康接线(§2.10 源①②):子进程 exit/error ∥ WS 断连 ⇒ 即时失败化。 */
161
+ function attachSessionHealth(cdp) {
162
+ const child = state.child
163
+ if (child && typeof child.on === "function") {
164
+ const gone = () => { if (state.child === child) failSession("the browser process exited") }
165
+ child.on("exit", gone)
166
+ child.on("error", gone)
167
+ }
168
+ cdp.onDisconnect?.(() => { if (state.cdp === cdp) failSession("the connection to the browser dropped") })
169
+ }
170
+
171
+ /** 入口探针(§2.10 源③):子进程存活 ∧ 连接未关(恒检——零成本);距上次成功活动 >5s ⇒ 心跳 `Browser.getVersion`(2s 帽)。 */
172
+ async function probeSession() {
173
+ const child = state.child
174
+ // 非真实子进程(无事件面的替身)⇒ 存活不可判——跳过该检(真实现恒有 ChildProcess 事件面)
175
+ if (child && typeof child.on === "function" && (child.exitCode !== null || child.signalCode != null)) {
176
+ throw failSession("the browser process exited")
177
+ }
178
+ if (state.cdp?.closed) throw failSession("the connection to the browser dropped")
179
+ if (Date.now() - state.lastActivityAt < PROBE_FRESH_MS) return
180
+ try {
181
+ await cdpCall("Browser.getVersion", {}, { timeoutMs: PROBE_TIMEOUT_MS })
182
+ } catch (e) {
183
+ if (e?.browserAbort || e?.sessionLost) throw e // 预算中止 / 会话已失 ⇒ 原样上报(fresh 判据已由它裁决)
184
+ throw failSession("the browser is not responding")
185
+ }
186
+ }
187
+
188
+ /** 会话开启(惰性 + headless 一致性判据 + 入口探针);开启段中止 ⇒ 置重开注记(半开态已由
189
+ * `openBrowserReal` 复位)——下一次动作干净重开,不中毒(§2.9 开启段中止)。 */
190
+ async function ensureSession(wanted) {
191
+ if (state.cdp) {
192
+ if (wanted !== undefined && wanted !== state.headless) {
193
+ throw new Error(`session is already running (headless=${state.headless}) — close it first to switch mode`)
194
+ }
195
+ await probeSession()
196
+ return
197
+ }
198
+ let opened
199
+ try {
200
+ opened = await runFrame("launch browser", () => (_deps.openBrowser ?? openBrowserReal)({ headless: wanted === undefined ? true : wanted }))
201
+ } catch (e) {
202
+ if (e?.browserAbort) state.reopenNote = true
203
+ throw e
204
+ }
205
+ state.cdp = opened.cdp
206
+ state.child = opened.child ?? null
207
+ state.lockPath = opened.lock ?? null
208
+ state.headless = opened.headless === undefined ? (wanted ?? true) : opened.headless
209
+ state.table = createRefTable()
210
+ state.net = { inflight: new Set(), lastEventAt: Date.now() }
211
+ state.lastActivityAt = Date.now()
212
+ const net = state.net
213
+ opened.cdp.on("Network.requestWillBeSent", (p) => { net.inflight.add(p.requestId); net.lastEventAt = Date.now() })
214
+ opened.cdp.on("Network.loadingFinished", (p) => { net.inflight.delete(p.requestId); net.lastEventAt = Date.now() })
215
+ opened.cdp.on("Network.loadingFailed", (p) => { net.inflight.delete(p.requestId); net.lastEventAt = Date.now() })
216
+ attachSessionHealth(opened.cdp)
217
+ registerExitHook()
218
+ armIdle()
219
+ }
220
+
221
+ /** 等子进程自退(优雅关后)。 */
222
+ async function waitForExit(child, maxMs) {
223
+ const deadline = Date.now() + maxMs
224
+ while (child.exitCode == null && Date.now() < deadline) await sleep(100)
225
+ }
226
+
227
+ /** 关闭会话(幂等);返回是否有会话被关。
228
+ * 先 `Browser.close` 优雅关(浏览器自行落盘 profile——Cookie/localStorage 持久化是 F-BT6 的落盘前提;
229
+ * 实测:直杀树不落盘 ⇒ 二段会话拿不到 Cookie),再等自退,未退才走杀树兜底。
230
+ * 中止(预算 / 取消)⇒ 跳过优雅等待、直接杀树兜底,清理完成后以中止因由上报(§2.9 abort 语义)。 */
231
+ export async function closeSession() {
232
+ const { cdp, child, lockPath } = state
233
+ resetState()
234
+ let aborted = null
235
+ if (cdp) {
236
+ try { await runFrame("Browser.close", () => cdp.call("Browser.close", {}, { timeoutMs: CLOSE_CALL_MS })) }
237
+ catch (e) { if (e?.browserAbort) aborted = e /* 已断 ∥ 不支持 ∥ 中止 ⇒ 转杀树兜底 */ }
238
+ try { cdp.close() } catch { /* 已断 */ }
239
+ }
240
+ if (child) {
241
+ if (!aborted) {
242
+ try { await runFrame("Browser.close", () => waitForExit(child, GRACEFUL_CLOSE_MS)) }
243
+ catch (e) { if (e?.browserAbort) aborted = e }
244
+ }
245
+ if (aborted || child.exitCode == null) {
246
+ try { (_deps.killBrowser ?? killBrowser)(child) } catch { /* 已退出 */ }
247
+ }
248
+ }
249
+ if (lockPath) releaseProfileLock(lockPath)
250
+ if (aborted) throw aborted
251
+ return Boolean(cdp)
252
+ }
253
+
254
+ /** 审批请示面用的「最近页 URL」(§3.1 文案;无会话 ⇒ null)。 */
255
+ export function lastPageUrl() {
256
+ return state.lastUrl
257
+ }
258
+
259
+ // ── 页原语 ──────────────────────────────────────────────────────────────────
260
+
261
+ /** 一切 CDP 命令的单点(§2.9 层一):步名帧(方法名)∥ 中止守卫 ∥ abort 后快速失败;成功 ⇒ 记活动时刻。
262
+ * 缺省帽(层三 15s)在动作预算内让位于控制器(预算先行——层一优先:兜底帽 = 剩余预算 + 1s 余量);
263
+ * 动作外路径按缺省帽兜底。 */
264
+ function cdpCall(method, params = {}, { timeoutMs } = {}) {
265
+ const cdp = state.cdp
266
+ if (!cdp) throw new Error("no browser session — run navigate first")
267
+ const ctl = currentController
268
+ ctl?.failIfAborted()
269
+ const cap = timeoutMs ?? (ctl ? Math.max(DEFAULT_CALL_TIMEOUT_MS, ctl.remaining() + CALL_CAP_SLACK_MS) : DEFAULT_CALL_TIMEOUT_MS)
270
+ const work = ctl ? ctl.run(method, () => cdp.call(method, params, { timeoutMs: cap })) : cdp.call(method, params, { timeoutMs: cap })
271
+ return Promise.resolve(work).then(
272
+ (result) => { state.lastActivityAt = Date.now(); return result },
273
+ (e) => { if (ctl?.aborted) throw ctl.error; throw e },
274
+ )
275
+ }
276
+
277
+ async function evalRaw(expression) {
278
+ // CDP 形:`call()` 解到命令的 result(`{result: RemoteObject}`)——页面值在 `result.result.value`
279
+ const r = await cdpCall("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })
280
+ if (r?.exceptionDetails) throw pageError(exceptionText(r.exceptionDetails)) // 页面侧异常 ⇒ 同失败回执面(带页摘)
281
+ return r?.result?.value
282
+ }
283
+
284
+ function exceptionText(details) {
285
+ const raw = details?.exception?.description ?? details?.text ?? "unknown page exception"
286
+ return String(raw).split("\n")[0]
287
+ }
288
+
289
+ async function pageInfo() {
290
+ const v = await evalRaw(pageInfoExpression())
291
+ const info = { url: String(v?.url ?? ""), title: String(v?.title ?? ""), readyState: String(v?.readyState ?? "") }
292
+ state.lastUrl = info.url
293
+ state.lastTitle = info.title
294
+ return info
295
+ }
296
+
297
+ async function waitForReady(maxMs = READY_TIMEOUT_MS) {
298
+ return runFrame("waitForReady", async () => {
299
+ const deadline = Date.now() + maxMs
300
+ for (;;) {
301
+ const info = await pageInfo()
302
+ if (info.readyState === "complete" || Date.now() >= deadline) return info
303
+ await sleep(100)
304
+ }
305
+ })
306
+ }
307
+
308
+ async function waitForUrlChange(before, maxMs = NAV_SETTLE_MS) {
309
+ return runFrame("wait poll", async () => {
310
+ const deadline = Date.now() + maxMs
311
+ for (;;) {
312
+ const info = await pageInfo()
313
+ if (info.url !== before) return info.url
314
+ if (Date.now() >= deadline) return null
315
+ await sleep(100)
316
+ }
317
+ })
318
+ }
319
+
320
+ /** 取快照 + 分配引用 + 渲染(会话内单一快照点)。 */
321
+ async function takeSnapshot({ selector = null, max = DEFAULT_MAX } = {}) {
322
+ const wanted = Number(max)
323
+ const capped = Math.max(1, Math.min(Number.isFinite(wanted) ? Math.floor(wanted) : DEFAULT_MAX, MAX_ELEMENTS))
324
+ const snap = normalizeSnapshot(await evalRaw(snapshotExpression({ selector, max: capped })))
325
+ if (snap.missing) {
326
+ throw pageError(`snapshot selector "${selector}" matched nothing — pass a CSS selector present on the page`)
327
+ }
328
+ const rows = assignRefs(state.table, snap.elements)
329
+ state.lastUrl = snap.url
330
+ state.lastTitle = snap.title
331
+ return {
332
+ url: snap.url,
333
+ title: snap.title,
334
+ total: snap.total,
335
+ rows,
336
+ text: renderSnapshot({ url: snap.url, title: snap.title, rows, total: snap.total }),
337
+ }
338
+ }
339
+
340
+ /** ref → 引用表条目(未在表 ⇒ 同 stale 句式——§2.3 失效口径)。 */
341
+ function resolveRef(ref) {
342
+ const entry = state.table.byRef.get(String(ref))
343
+ if (!entry) throw pageError(`ref ${ref} is stale (was "unknown") — run snapshot again`)
344
+ return entry
345
+ }
346
+
347
+ /** ref → 聚焦(§2.7):表查 + `focusExpression`;不可聚焦 ⇒ 明示拒(不静默错投)。 */
348
+ async function focusRef(ref) {
349
+ const entry = resolveRef(ref)
350
+ const f = await evalRaw(focusExpression(entry.selector, entry.tag))
351
+ if (!f?.found || f.tag) throw pageError(`ref ${ref} is stale (was "${entry.name}") — run snapshot again`)
352
+ if (f.focused === false) throw pageError(`ref ${ref} "${entry.name}" is not focusable — keys would go to another element`)
353
+ return { ref, name: entry.name, label: `${ref} "${entry.name}"` }
354
+ }
355
+
356
+ // ── 安全面 ──────────────────────────────────────────────────────────────────
357
+
358
+ /** 域允许清单匹配(§3.2):全等(大小写不敏感)∥ `*.` 前缀 = 子域通配。 */
359
+ export function hostAllowed(host, list) {
360
+ const h = String(host).toLowerCase()
361
+ return list.some((entry) => {
362
+ const pattern = String(entry ?? "").trim().toLowerCase()
363
+ if (!pattern) return false
364
+ if (pattern.startsWith("*.")) return h.endsWith(`.${pattern.slice(2)}`)
365
+ return h === pattern
366
+ })
367
+ }
368
+
369
+ function allowDomainsOf(ctx) {
370
+ const list = ctx?.agent?.config?.browser?.allowDomains
371
+ return Array.isArray(list) ? list : []
372
+ }
373
+
374
+ /** 域强检(navigate 目标 ∥ click/evaluate/type 当前页——§3.2 两点)。 */
375
+ function assertHostAllowed(url, ctx) {
376
+ const list = allowDomainsOf(ctx)
377
+ if (list.length === 0) return
378
+ let host = ""
379
+ try { host = new URL(url).hostname } catch { return }
380
+ if (!hostAllowed(host, list)) {
381
+ throw new Error(`blocked by browser.allowDomains — host "${host}" not allowed`)
382
+ }
383
+ }
384
+
385
+ /** 失败回执(§2.2):`Error: <因由>` + 页摘 + 紧凑清单(≤30 行——页不可读时仅错误行)。 */
386
+ async function failureReceipt(message) {
387
+ const parts = [`Error: ${message}`]
388
+ if (state.cdp) {
389
+ try { parts.push((await takeSnapshot({ max: COMPACT_ROWS })).text) } catch { /* 页不可读 ⇒ 仅错误行 */ }
390
+ }
391
+ return parts.join("\n")
392
+ }
393
+
394
+ function pageError(message) {
395
+ const err = new Error(message)
396
+ err.pageReceipt = true
397
+ return err
398
+ }
399
+
400
+ /** 真落盘(screenshot 缝的缺省实现):`~/.thincoder/browser/shots/shot-<ISO>.png`。 */
401
+ async function saveShotToDisk(buffer) {
402
+ const dir = browserPaths().shots
403
+ mkdirSync(dir, { recursive: true })
404
+ const path = join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}.png`)
405
+ writeFileSync(path, buffer)
406
+ return { path, bytes: buffer.length }
407
+ }
408
+
409
+ // ── 会话句柄(动作模块的能力面——§5:动作模块不 import 本档)──────────────────
410
+
411
+ /** 等一条 CDP 事件(一次性;超时 / 中止 ⇒ 拒——html5 拖拽拦截链用)。 */
412
+ function once(method, timeoutMs = 10_000) {
413
+ const ctl = currentController
414
+ ctl?.failIfAborted()
415
+ return new Promise((resolve, reject) => {
416
+ if (!state.cdp) { reject(new Error("no browser session")); return }
417
+ let timer = null
418
+ let off = null
419
+ let offAbort = null
420
+ let done = false
421
+ const finish = (fn, value) => {
422
+ if (done) return
423
+ done = true
424
+ if (timer) clearTimeout(timer)
425
+ off?.()
426
+ offAbort?.()
427
+ fn(value)
428
+ }
429
+ off = state.cdp.on(method, (params) => finish(resolve, params))
430
+ offAbort = ctl?.onAbort(() => finish(reject, ctl.error))
431
+ timer = setTimeout(() => finish(reject, new Error(`timed out waiting for ${method}`)), timeoutMs)
432
+ timer.unref?.()
433
+ })
434
+ }
435
+
436
+ const sessionHandle = {
437
+ ensureSession: (args) => ensureSession(headlessOf(args)),
438
+ closeSession,
439
+ connection: () => state.cdp,
440
+ call: (method, params) => cdpCall(method, params),
441
+ once: (method, timeoutMs) => runFrame(method, () => once(method, timeoutMs)),
442
+ evalRaw,
443
+ pageInfo,
444
+ waitForReady: (maxMs) => waitForReady(maxMs),
445
+ waitForUrlChange: (before, maxMs) => waitForUrlChange(before, maxMs),
446
+ takeSnapshot,
447
+ resolveRef,
448
+ focusRef,
449
+ assertHostAllowed,
450
+ failureReceipt,
451
+ pageError,
452
+ step: (name, fn) => runFrame(name, fn),
453
+ sleep,
454
+ saveShot: (buffer) => (_deps.saveShot ?? saveShotToDisk)(buffer),
455
+ networkState: () => state.net,
456
+ }
457
+
458
+ // ── 分发表 ∥ 入口 ────────────────────────────────────────────────────────────
459
+
460
+ const ACTION_TABLE = { ...ACTIONS, ...INPUT_ACTIONS, ...CLIPBOARD_ACTIONS }
461
+
462
+ async function dispatchAction(action, args, ctx) {
463
+ const handler = ACTION_TABLE[action]
464
+ if (typeof handler !== "function") throw new Error(`unknown browser action "${action}"`)
465
+ return handler(args, ctx, sessionHandle)
466
+ }
467
+
468
+ /** 自愈重开注记(§2.10):重开后第一条回执尾行注记——置位一次,发出即清。 */
469
+ function sealNote(receipt) {
470
+ if (!state.reopenNote || !state.cdp) return receipt
471
+ state.reopenNote = false
472
+ return `${receipt}\n${REOPEN_NOTE}`
473
+ }
474
+
475
+ async function runSerial(action, args, ctx, ctl) {
476
+ ctl.failIfAborted() // 排队期已到点 ⇒ 动作从未起执行(无展开)
477
+ try {
478
+ return sealNote(await dispatchAction(action, args, ctx))
479
+ } catch (e) {
480
+ if (e?.pageReceipt) return sealNote(await failureReceipt(e.message))
481
+ throw e
482
+ } finally {
483
+ if (state.cdp) armIdle() // 任何动作重置空闲计时(KD-8)
484
+ }
485
+ }
486
+
487
+ /** 动作入口:串行队列 + 单时钟动作预算(§2.9——deadline 自入队起算;批并行调用按调用序排队)。
488
+ * `opts.signal`(批 browser-async-fix 最小增补——后台任务取消柄):abort ⇒ 控制器 `cancel()`(queued 丢队 ∥ running 展开);缺省 null 旧径零变。 */
489
+ export function runAction(action, args, ctx, { signal = null } = {}) {
490
+ return queue.enqueue(action, args, (ctl) => {
491
+ currentController = ctl
492
+ return runSerial(action, args, ctx, ctl).finally(() => { currentController = null })
493
+ }, signal)
494
+ }