dsh-codex-style-computer-use 1.2.0-rc.3

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/README.zh.md +132 -0
  4. package/cordis.patch.yml +17 -0
  5. package/lib/artifacts.js +35 -0
  6. package/lib/attention-native.js +93 -0
  7. package/lib/attention-worker.cs +194 -0
  8. package/lib/attention.js +140 -0
  9. package/lib/audit.js +30 -0
  10. package/lib/batch.js +52 -0
  11. package/lib/bin/attention-worker.exe +0 -0
  12. package/lib/bin/attention-worker.exe.json +7 -0
  13. package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
  14. package/lib/core/bin/dsh-computer-use-worker.exe.json +7 -0
  15. package/lib/core/compile-worker.ps1 +58 -0
  16. package/lib/core/index.js +844 -0
  17. package/lib/core/native-runtime.js +97 -0
  18. package/lib/core/worker.cs +6375 -0
  19. package/lib/cycle.js +314 -0
  20. package/lib/exposure.js +91 -0
  21. package/lib/feedback.js +35 -0
  22. package/lib/image-output.js +92 -0
  23. package/lib/index.js +1715 -0
  24. package/lib/observations.js +78 -0
  25. package/lib/policy.js +109 -0
  26. package/lib/recovery.js +8 -0
  27. package/lib/skill-gate.js +68 -0
  28. package/lib/tools.js +717 -0
  29. package/mcp/package.json +12 -0
  30. package/mcp/src/index.js +335 -0
  31. package/package.json +88 -0
  32. package/scripts/build-native.mjs +7 -0
  33. package/scripts/smoke-native.mjs +38 -0
  34. package/scripts/verify-package.mjs +28 -0
  35. package/skills/computer-use/SKILL.md +74 -0
  36. package/skills/computer-use/references/lifecycle.md +180 -0
  37. package/skills/computer-use/references/maintenance.md +56 -0
  38. package/skills/computer-use/references/precision.md +297 -0
  39. package/skills/computer-use/references/progressive-control.md +30 -0
  40. package/skills/computer-use/references/shared-input.md +27 -0
@@ -0,0 +1,844 @@
1
+ // @dsh/computer-use-core — capability layer (stage-0)
2
+ //
3
+ // Owns the native worker lifecycle and exposes an ergonomic JS API over it.
4
+ // Both transports (MCP server, DSH Cordis plugin) are thin shells over this
5
+ // module. Zero npm dependencies by design: the plugin must survive hostile
6
+ // npm environments (offline install, hardened caches).
7
+ //
8
+ // Worker protocol: NDJSON over stdio — one {"op","args"} per line in,
9
+ // one {"id","ok","data"|"error"} per line out. First line out is the
10
+ // {"event":"ready"} announcement.
11
+
12
+ import { spawn, execFileSync } from 'node:child_process'
13
+ import { createHash } from 'node:crypto'
14
+ import { EventEmitter } from 'node:events'
15
+ import { AsyncLocalStorage } from 'node:async_hooks'
16
+ import { existsSync, readFileSync, statSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { prepareNativeBinary, workerReferences } from './native-runtime.js'
20
+
21
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
22
+
23
+ const DEFAULT_TIMEOUT_MS = 15_000
24
+ const CAPTURE_TIMEOUT_MS = 30_000
25
+ const INPUT_OPS = new Set(['click', 'shiftClick', 'move', 'drag', 'scroll', 'selectRange',
26
+ 'type', 'key', 'uiaAct', 'windowOp', 'activate', 'clipWrite', 'calibrate', 'selftest'])
27
+ // THE CORE'S ASK DEADLINE, AND IT IS A BARE LITERAL ON PURPOSE (merge-design.md §3, §3.2).
28
+ //
29
+ // It bounds ONE thing: a wedged worker inside `beginAsk`'s raise sequence (a hung UIA provider), or
30
+ // inside `endAsk`'s two file operations. It does NOT bound the wait for a human — there is nothing
31
+ // left to bound, because the worker no longer waits at all and the client-UI card has no countdown.
32
+ //
33
+ // It must never be DERIVED from the tool's timeoutMs. The defect this replaces was exactly that
34
+ // coupling: the old call was `this.call('ask', …, (opts.timeoutMs ?? 5000) + 10_000)`, so for every
35
+ // tool timeout below 65 s the CORE killed the worker BEFORE the tool's own deadline could fire. The
36
+ // measured incident (verify4, 15.58 s and the review's 15 s): `worker timeout: ask (15000ms) —
37
+ // worker killed` while the answer was still pending, leaving the STOP file behind with nobody left
38
+ // to wait for it (ENGAGE-ASK → ENGAGE-ADOPTED, no RELEASE). Ordering that must hold:
39
+ //
40
+ // tool deadline 3_600_000 ms > core ask deadline 15_000 ms > core endAsk deadline 5_000 ms
41
+ //
42
+ // Guard G3 asserts the ordering AND that neither number is computed from the other.
43
+ const ASK_DEADLINE_MS = 15_000
44
+ /** The release is two file operations; if it exceeds this the worker is wedged. */
45
+ const END_ASK_DEADLINE_MS = 5_000
46
+ export function workerCallError (message, code, op, requestId, outcome = 'unknown') {
47
+ return Object.assign(new Error(`${message} [${code}; outcome=${outcome}]. ${outcome === 'unknown' ? 'Inspect current state before retrying a write.' : 'No input was dispatched by this operation.'}`),
48
+ { code, op, requestId, outcome })
49
+ }
50
+
51
+ /** Interactive roles that receive Set-of-Marks labels. */
52
+ const SOM_ROLES = new Set([
53
+ 'Button', 'MenuItem', 'ListItem', 'TreeItem', 'DataItem', 'TabItem',
54
+ 'Hyperlink', 'CheckBox', 'RadioButton', 'ComboBox', 'Edit', 'Spinner',
55
+ 'Slider', 'Thumb', 'ScrollBar', 'Custom', 'Pane',
56
+ ])
57
+ const SOM_PATTERNS = /(Invoke|SelectionItem|Toggle|ExpandCollapse|Value|RangeValue|ScrollItem|GridItem)/
58
+
59
+ export class ComputerUse extends EventEmitter {
60
+ /**
61
+ * @param {object} [opts]
62
+ * @param {string} [opts.workerExe] explicit worker path; discovered otherwise
63
+ * @param {(msg: string) => void} [opts.log]
64
+ */
65
+ constructor (opts = {}) {
66
+ super()
67
+ this.workerExe = opts.workerExe || null
68
+ this.log = opts.log || (() => {})
69
+ this.proc = null
70
+ this.seq = 0
71
+ this.pending = new Map() // id -> {resolve, reject, timer}
72
+ this.ready = false
73
+ this.lastMarks = [] // [{id, x, y, w, h, name, role, center:[cx,cy]}]
74
+ this.lastRegion = null // capture region the marks refer to
75
+ this.lastScale = 1 // screenshot px per screen px (<1 when downscaled)
76
+ // safety knobs (sandraschi SAFETY.md four-piece: HITL + kill switch +
77
+ // rate limit + audit; see DESIGN §8)
78
+ this.dryRun = opts.dryRun ?? false // simulate actuations, log only
79
+ this.kickoffRequired = opts.kickoffRequired !== false // pure observation must not require focusing
80
+ this.rateLimit = opts.rateLimit ?? { max: 60, windowMs: 60_000 }
81
+ this.rateWindow = []
82
+ this.humanApproved = opts.autoApprove ?? false // gate for mutating ops
83
+ this.stopped = false // true while the human's brake is engaged (see 'panic' event)
84
+ this.exited = false // true once the human ended the session (see 'exit' event)
85
+ this.cooperation = { phase: 'idle', active: false, revision: 0 }
86
+ this.inputContext = new AsyncLocalStorage()
87
+ // Monotonic counter of UNSOLICITED state transitions (panic/ask/resume/exit). Anything that
88
+ // learns the machine's state asynchronously — above all the `ask` op, which can take seconds and
89
+ // reports how it ended — captures this before the call and re-checks it after: a reply that
90
+ // describes a state older than the newest event must never overwrite it (F2).
91
+ this.stateSeq = 0
92
+ this.lastMarks = [] // [{id, x, y, w, h, name, role, center:[cx,cy]}]
93
+ this.maps = new Map() // hwnd -> {marks, sig, ts, title} landmark cache
94
+ this.markSequence = 0
95
+ this.lastMarkFrame = null
96
+ this.onWorkerExit = this.onWorkerExit.bind(this)
97
+ }
98
+
99
+ // ---------------- worker plumbing ----------------
100
+ // single-flight: screenState fires 3 parallel calls (windows/cursor/uia) and
101
+ // naive guards used to spawn 3 workers, overwriting this.proc and leaking
102
+ // the rest — every caller must await the SAME in-flight spawn
103
+ ensureWorker () {
104
+ if (this.proc && this.ready) return Promise.resolve()
105
+ if (this._ensuring) return this._ensuring
106
+ const p = this.spawnWorker().finally(() => { if (this._ensuring === p) this._ensuring = null })
107
+ this._ensuring = p
108
+ return p
109
+ }
110
+
111
+ spawnWorker () {
112
+ if (!this.workerExe || !existsSync(this.workerExe)) {
113
+ // Prefer the verified prebuilt helper; source checkouts use Windows csc.
114
+ this.workerExe = compileWorkerSync(this.log)
115
+ }
116
+ // Host guard: this core runs INSIDE the DSH host process, so process.pid IS the host pid -
117
+ // but the window a human interacts with belongs to a DIFFERENT process of the same Electron
118
+ // app (main vs renderer/GPU), so the worker is also given the executable path, which
119
+ // recognises every process of the app at once. The worker refuses to drive any window of
120
+ // either; one-shot CLI runs (no env) keep the guard disabled.
121
+ const env = { ...process.env, DSH_CU_HOST_PID: String(process.pid), DSH_CU_HOST_EXE: process.execPath || '' }
122
+ this.log(`[core] host guard armed for pid ${process.pid} (${process.execPath})`)
123
+ this.proc = spawn(this.workerExe, [], { env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
124
+ // A CHILD THAT FAILS TO START MUST NAME ITSELF (2026-09-13). Every computer_* tool died with a
125
+ // bare `Error: spawn UNKNOWN` — no path, no syscall, no Windows code — because NOTHING listened
126
+ // for this child's 'error' event: an unhandled 'error' on an EventEmitter is thrown, so the rich
127
+ // error object (which names the file Windows refused) was discarded on the way out.
128
+ this.proc.on('error', (e) => {
129
+ let file = '?'
130
+ try { file = `${existsSync(this.workerExe)} exists, ${statSync(this.workerExe).size} bytes` } catch { file = 'stat failed' }
131
+ this.log(`[core] WORKER SPAWN FAILED: code=${e.code} errno=${e.errno} syscall=${e.syscall} ` +
132
+ `exe=${this.workerExe} (${file})`)
133
+ this.spawnError = e
134
+ })
135
+ this.proc.stdin.on('error', (e) => this.log(`[worker:stdin] ${e.message}`))
136
+ this.proc.stdout.setEncoding('utf8')
137
+ let buf = ''
138
+ this.proc.stdout.on('data', (chunk) => {
139
+ buf += chunk
140
+ let nl
141
+ while ((nl = buf.indexOf('\r\n')) >= 0) {
142
+ const line = buf.slice(0, nl).trim()
143
+ buf = buf.slice(nl + 1)
144
+ if (!line) continue
145
+ this.onLine(line)
146
+ }
147
+ })
148
+ this.proc.stderr.on('data', (d) => this.log(`[worker:stderr] ${d}`))
149
+ this.proc.on('exit', this.onWorkerExit)
150
+ return new Promise((resolve, reject) => {
151
+ const t = setTimeout(() => reject(new Error('worker ready timeout')), 10_000)
152
+ // surface the real reason instead of letting the raw emitter error escape
153
+ this.proc.once('error', (e) => {
154
+ clearTimeout(t)
155
+ reject(new Error(`worker could not be started: code=${e.code} syscall=${e.syscall} path=${e.path || this.workerExe}`))
156
+ })
157
+ this.once('ready', () => { clearTimeout(t); resolve() })
158
+ this.once('dead', (code) => { clearTimeout(t); reject(new Error(`worker died at startup (code ${code})`)) })
159
+ })
160
+ }
161
+
162
+ onWorkerExit (code) {
163
+ this.ready = false
164
+ this.proc = null
165
+ for (const [id, p] of this.pending) { clearTimeout(p.timer); p.cleanup?.(); p.reject(workerCallError('worker exited', 'worker-exited', p.op, id)) }
166
+ this.pending.clear()
167
+ this.emit('dead', code)
168
+ }
169
+
170
+ onLine (line) {
171
+ let msg
172
+ try { msg = JSON.parse(line) } catch { this.log(`[worker:badline] ${line.slice(0, 200)}`); return }
173
+ if (msg.event === 'ready') {
174
+ this.ready = true
175
+ // COLD-START STATE SYNC (cold-start bug, 2026-09-13): the worker has already adopted any STOP
176
+ // or EXITED record that predates this process, and it reports that state with `ready`. Without
177
+ // this the core started from "not stopped" while the machine was stopped — measured: core
178
+ // stopped=false/exited=false while native said engaged/stopped=true and the light was wrong.
179
+ // This is NOT a fake key press: no event is emitted, nothing is announced and nothing is
180
+ // released — the mirror simply starts out agreeing with the machine.
181
+ if (typeof msg.stopped === 'boolean') this.stopped = msg.stopped
182
+ if (typeof msg.exited === 'boolean') this.exited = msg.exited
183
+ this.cooperation = msg.cooperation || { phase: 'idle', active: false, revision: 0 }
184
+ this.emit('ready', msg)
185
+ return
186
+ }
187
+ if (msg.event === 'handoff') {
188
+ this.syncCooperation(msg.cooperation)
189
+ return
190
+ }
191
+ // Durable brake events are separate from automatic physical-input handoff.
192
+ // panic — ESC: the human ABORTED. The machine is stopped; wrap up briefly, then wait.
193
+ // resume — Ctrl+Alt+R: work may continue (a PAUSE ends; it never ends the cycle).
194
+ // ask — the agent asked a question: the machine is PAUSED inside a LIVE cycle for as long
195
+ // as the answer takes. It is NOT an exit, and the worker holds `_engaged`/STOP while
196
+ // the question is open — so the mirror is stopped=true, exited UNCHANGED. (A single
197
+ // shared branch used to make this `exited=true, stopped=false`: two lies at once, and
198
+ // both of those flags are read by the plugin's cycle gate.)
199
+ // exit — Ctrl+Alt+Q: the human ENDED the session. Stop the turn outright, stop streaming.
200
+ if (msg.event === 'panic' || msg.event === 'resume' || msg.event === 'exit' || msg.event === 'ask') {
201
+ if (msg.event === 'panic') this.stopped = true
202
+ else if (msg.event === 'resume') this.stopped = false
203
+ else if (msg.event === 'ask') this.stopped = true
204
+ else { this.stopped = false; this.exited = true }
205
+ // MY OWN ask is not an "external" state change (2026-09-13, Codex review): the worker notifies
206
+ // `ask` from EngageAsk, i.e. while this caller is inside its OWN `ask()` — between `beginAsk`
207
+ // and the answer — so counting it as a change would make the core discard its own reply and
208
+ // leave the mirror disagreeing with the machine. Only changes OTHER than the one this request
209
+ // caused may invalidate its reply. (The merge did not change this: `beginAsk` still raises the
210
+ // brake and still notifies once; what went away is the worker-side WAIT, not the event.)
211
+ if (msg.event === 'ask' && this._askOpen) this._askOpen.own++
212
+ this.stateSeq++
213
+ this.emit(msg.event, msg)
214
+ return
215
+ }
216
+ const p = this.pending.get(msg.id)
217
+ if (!p) return
218
+ this.pending.delete(msg.id)
219
+ clearTimeout(p.timer)
220
+ p.cleanup?.()
221
+ if (msg.data?.cooperation) this.syncCooperation(msg.data.cooperation)
222
+ if (msg.ok) p.resolve(msg.data)
223
+ else p.reject(workerCallError(msg.error || 'worker error', msg.code || 'worker-refusal-or-error', p.op, msg.id,
224
+ msg.outcome === 'not-dispatched' ? 'not-dispatched' : 'unknown'))
225
+ }
226
+
227
+ /**
228
+ * @param {string} op worker op
229
+ * @param {object} [args]
230
+ * @param {number} [timeoutMs] hard cap on this call
231
+ * @param {boolean} [killOnTimeout] kill a wedged worker when that cap expires (default true)
232
+ * @returns {Promise<any>} worker `data` payload
233
+ */
234
+ async call (op, args = {}, timeoutMs = DEFAULT_TIMEOUT_MS, killOnTimeout = true) {
235
+ const signal = INPUT_OPS.has(op) ? this.inputContext.getStore()?.signal : undefined
236
+ if (signal?.aborted) throw workerCallError('owning tool cancelled', 'INPUT_CANCELLED', op, null, 'not-dispatched')
237
+ try { await this.ensureWorker() }
238
+ catch (error) { throw workerCallError(error.message || String(error), 'worker-unavailable', op, null, 'not-dispatched') }
239
+ if (signal?.aborted) throw workerCallError('owning tool cancelled', 'INPUT_CANCELLED', op, null, 'not-dispatched')
240
+ const id = ++this.seq
241
+ // `__cycle` is this layer's SIGNATURE on a call made on the agent's behalf, and the worker
242
+ // opens (or keeps lit) a computer-use CYCLE only for signed calls. A one-shot
243
+ // `worker.exe --op click` from a shell script, or a deploy-time health probe, carries no
244
+ // signature — and so can never resurrect a session the human ended with Ctrl+Alt+Q. Stamped
245
+ // LAST so an op's own args can never forge it.
246
+ const signed = { ...args, __cycle: true }
247
+ signed.__kickoff = this.kickoffRequired
248
+ return new Promise((resolve, reject) => {
249
+ let written = false
250
+ const cleanup = () => signal?.removeEventListener('abort', abort)
251
+ const abort = () => {
252
+ if (!this.pending.delete(id)) return
253
+ clearTimeout(timer); cleanup()
254
+ if (written) void this.call('cancelInput', { requestId: id }, 2000, false).catch(() => {})
255
+ reject(workerCallError('owning tool cancelled; accepted input must be inspected before any retry',
256
+ 'INPUT_CANCELLED', op, id, written ? 'unknown' : 'not-dispatched'))
257
+ }
258
+ const timer = setTimeout(() => {
259
+ this.pending.delete(id)
260
+ cleanup()
261
+ // A CALL THAT RELEASES THE BRAKE MUST NOT BE KILLED FOR BEING SLOW. `proc.kill()` deletes no
262
+ // STOP file (there is no ProcessExit handler in worker.cs; `abort` is Environment.Exit(0) at
263
+ // worker.cs:3045), so killing here would end a question with the brake still up — the exact
264
+ // defect this design removes. Without the kill the worker is still alive, so the caller's
265
+ // retry, or the next op's SyncFromFile, can still finish the release.
266
+ //
267
+ // Everything else keeps today's behaviour: a timed-out op means the worker is wedged (a hung
268
+ // UIA/COM provider call blocks its single-threaded loop forever), so kill it and let the NEXT
269
+ // call respawn fresh instead of queueing behind a corpse; pending calls are rejected via
270
+ // onWorkerExit.
271
+ if (killOnTimeout) { try { this.proc && this.proc.kill() } catch { /* already gone */ } }
272
+ reject(workerCallError(`worker timeout: ${op} (${timeoutMs}ms)` +
273
+ (killOnTimeout
274
+ ? ' — worker killed, next call respawns'
275
+ : ' — worker left alive so the brake can still be released'),
276
+ 'worker-timeout', op, id))
277
+ }, timeoutMs)
278
+ this.pending.set(id, { resolve, reject, timer, op, cleanup, abort })
279
+ signal?.addEventListener('abort', abort, { once: true })
280
+ if (signal?.aborted) { abort(); return }
281
+ written = true
282
+ this.proc.stdin.write(JSON.stringify({ id, op, args: signed }) + '\r\n')
283
+ })
284
+ }
285
+
286
+ async kill () {
287
+ this.emit('input-cancel')
288
+ if (!this.proc) return
289
+ try { await this.call('abort', {}, 1000) } catch { /* best effort */ }
290
+ try { this.proc.kill() } catch { /* already gone */ }
291
+ }
292
+
293
+ // ---------------- screen observation ----------------
294
+ syncCooperation (state) {
295
+ if (!state || Number(state.revision) < Number(this.cooperation.revision)) return
296
+ this.cooperation = state
297
+ if (state.active) { this.maps.clear(); this.lastMarks = []; this.lastMarkFrame = null }
298
+ this.emit('handoff', state)
299
+ }
300
+
301
+ withInputContext (context, fn) { return this.inputContext.run({ signal: context?.signal }, fn) }
302
+
303
+ waitForHuman () {
304
+ const signal = this.inputContext.getStore()?.signal
305
+ return new Promise((resolve, reject) => {
306
+ const events = ['handoff', 'panic', 'ask', 'exit', 'dead', 'input-cancel']
307
+ const cleanup = () => { for (const event of events) this.off(event, listeners[event]); signal?.removeEventListener('abort', cancelled) }
308
+ const finish = (error) => { cleanup(); if (error) reject(error); else resolve() }
309
+ const cancelled = () => finish(workerCallError('waiting for the human was cancelled', 'INPUT_CANCELLED', null, null, 'not-dispatched'))
310
+ const check = () => {
311
+ if (signal?.aborted) return cancelled()
312
+ if (this.stopped || this.exited) return finish(workerCallError('manual pause/exit remains authoritative', 'ABORTED', null, null, 'not-dispatched'))
313
+ if (!this.cooperation.active) finish()
314
+ }
315
+ const listeners = Object.fromEntries(events.map(event => [event, event === 'dead' ?
316
+ () => finish(workerCallError('worker exited while waiting', 'worker-exited', null, null, 'not-dispatched')) :
317
+ event === 'input-cancel' ? cancelled : check]))
318
+ for (const event of events) this.on(event, listeners[event])
319
+ signal?.addEventListener('abort', cancelled, { once: true })
320
+ check()
321
+ })
322
+ }
323
+
324
+ async waitInputReady () {
325
+ if (!this.cooperation.active) return
326
+ await this.waitForHuman()
327
+ throw workerCallError('Human input ended and the three-second quiet interval completed. Observe the current target, then continue this task automatically. Previous coordinates/focus may have changed.',
328
+ 'HUMAN_REOBSERVE', null, null, 'not-dispatched')
329
+ }
330
+
331
+ cancelInputs () {
332
+ this.emit('input-cancel')
333
+ for (const pending of [...this.pending.values()]) if (INPUT_OPS.has(pending.op)) pending.abort?.()
334
+ }
335
+
336
+ /** Raw capture; returns { image: Buffer, mime, region, cursor, settled, settleMs, ts, scaledTo }. */
337
+ async screenshot (opts = {}) {
338
+ const data = await this.call('capture', {
339
+ x: opts.x, y: opts.y, width: opts.width, height: opts.height,
340
+ preset: opts.preset,
341
+ format: opts.format || 'jpeg',
342
+ quality: opts.quality ?? 75,
343
+ // ~1280 wide ≈ Anthropic WXGA guidance: ⌈w/28⌉×⌈h/28⌉ tokens ≈ 1600; bigger
344
+ // wastes vision tokens, smaller loses text legibility (DESIGN §6)
345
+ maxWidth: opts.maxWidth ?? 1280,
346
+ cursor: opts.cursor ?? false,
347
+ // timing: wait for pixel stability before grabbing, hard-capped so it can't stall
348
+ wait: opts.wait || 'auto',
349
+ waitCapMs: opts.waitCapMs,
350
+ stableMs: opts.stableMs,
351
+ }, CAPTURE_TIMEOUT_MS)
352
+ this.lastRegion = data.region
353
+ this.lastScale = data.scaledTo && data.region.width ? data.scaledTo / data.region.width : 1
354
+ return { ...data, image: Buffer.from(data.image, 'base64') }
355
+ }
356
+
357
+ /**
358
+ * Text-first screen state — the grounding channel that works even for
359
+ * models without native vision. Merges: window list + active window +
360
+ * shallow UIA tree of the active window + cursor. This is the default
361
+ * observation tool; screenshots are the *verification* channel.
362
+ */
363
+ async screenState (opts = {}) {
364
+ const [windows, cursor, uia] = await Promise.all([
365
+ this.call('windows', {}),
366
+ this.call('cursor', {}),
367
+ this.call('uia', { depth: opts.uiaDepth ?? 5, maxNodes: opts.uiaMaxNodes ?? 260 }).catch(() => null),
368
+ ])
369
+ const active = (windows.windows || []).find(w => w.active) || null
370
+ return {
371
+ active, windows: windows.windows, cursor,
372
+ uiaRoot: uia && uia.root, uiaFlat: (uia && uia.flat) || [],
373
+ uiaCount: uia && uia.count, uiaTruncated: !uia || uia.truncated !== false, ts: Date.now(),
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Set-of-Marks: label interactive elements M1..Mn on the current screen.
379
+ * Returns the annotated screenshot plus a text mark table. Clicking by
380
+ * mark ids refer to the same observed map as the text table.
381
+ */
382
+ async marks (opts = {}) {
383
+ const map = opts.landmarkMap || await this.landmarks({ hwnd: opts.hwnd, depth: opts.uiaDepth ?? 14, maxNodes: opts.uiaMaxNodes ?? 1500 })
384
+ await this.validateMarkFrame()
385
+ const state = { active: { hwnd: map.hwnd }, cursor: await this.call('cursor', {}) }
386
+ // capture FIRST so the visible region drives the ghost-mark filter:
387
+ // minimized windows live at (-32000, -32000) in UIA and must never receive
388
+ // mark ids (clicking them would throw the cursor off-screen)
389
+ const shot = await this.screenshot({ ...opts, quality: opts.quality ?? 80 })
390
+ await this.validateMarkFrame()
391
+ const region = shot.region
392
+ const onScreen = (r) => r.x + r.width >= region.x && r.x <= region.x + region.width
393
+ && r.y + r.height >= region.y && r.y <= region.y + region.height
394
+ const markList = map.marks.filter(m => onScreen({ x: m.x, y: m.y, width: m.w, height: m.h }))
395
+ let annotated = null
396
+ if (opts.annotated !== false && markList.length > 0) {
397
+ // marks are in screen px; map into screenshot px space (screenshot px = screen px × scale)
398
+ const scaled = markList.map(m => ({
399
+ id: m.id,
400
+ x: Math.round((m.x - shot.region.x) * this.lastScale),
401
+ y: Math.round((m.y - shot.region.y) * this.lastScale),
402
+ w: Math.round(m.w * this.lastScale), h: Math.round(m.h * this.lastScale),
403
+ }))
404
+ const res = await this.call('annotate', {
405
+ image: shot.image.toString('base64'), marks: scaled, quality: 80,
406
+ }, CAPTURE_TIMEOUT_MS)
407
+ annotated = Buffer.from(res.image, 'base64')
408
+ }
409
+ return { marks: markList, annotated, raw: opts.raw ? shot.image : undefined, state: { active: state.active, cursor: state.cursor }, ts: Date.now() }
410
+ }
411
+
412
+ // ---------------- actions (all audited) ----------------
413
+ async move (x, y) { return this.actuate('move', { x, y }) }
414
+
415
+ async click (target, opts = {}) {
416
+ const pt = await this.resolveTarget(target) // {x,y} | {mark:'M3'} | [x,y]
417
+ return this.actuate('click', {
418
+ x: pt.x, y: pt.y, button: opts.button || 'left', clicks: opts.clicks || 1,
419
+ settleMs: opts.settleMs ?? 40,
420
+ ...((pt.expectHwnd || opts.expectHwnd) ? { expectHwnd: pt.expectHwnd || opts.expectHwnd } : {}),
421
+ })
422
+ }
423
+
424
+ async drag (from, to, opts = {}) {
425
+ const a = await this.resolveTarget(from)
426
+ const b = await this.resolveTarget(to)
427
+ return this.actuate('drag', { fromX: a.x, fromY: a.y, toX: b.x, toY: b.y, ...opts })
428
+ }
429
+
430
+ async scroll (direction, lines, opts = {}) {
431
+ // BUG FIX (2026-09-12): the old signature took `opts` but the tool layer passed the
432
+ // point itself, so opts.at was always undefined (x/y silently dropped — the wheel
433
+ // always fired wherever the cursor happened to be) and a null argument crashed with
434
+ // "Cannot read properties of null (reading 'at')". Accept both shapes now.
435
+ const o = opts || {}
436
+ const at = o.at || (o.x !== undefined && o.y !== undefined ? o : null)
437
+ const pt = at ? await this.resolveTarget(at) : null
438
+ return this.actuate('scroll', { direction, lines, ...(pt ? { x: pt.x, y: pt.y } : {}) })
439
+ }
440
+
441
+ // Anchor-based selection: click the START, scroll freely, then shift+click the END.
442
+ // Replaces drag-select, whose end point depends on drag duration and auto-scroll speed.
443
+ async shiftClick (x, y, opts = {}) {
444
+ return this.actuate('shiftClick', { x, y, clicks: opts.clicks || 1, settleMs: opts.settleMs ?? 90,
445
+ ...(opts.expectHwnd ? { expectHwnd: opts.expectHwnd } : {}) })
446
+ }
447
+
448
+ async selectRange (a = {}) { return this.actuate('selectRange', a) }
449
+
450
+ // Low-level control action through the accessibility tree: press a button, SELECT A TAB,
451
+ // expand a combo, set a field's value — one COM call, no pointer travel, no screenshot.
452
+ async uiaAct (a = {}) { return this.actuate('uiaAct', a) }
453
+
454
+ // Screenshot timing: wait for the frame signature to stop changing / to change at all.
455
+ async waitStable (opts = {}) { return this.call('waitStable', opts) }
456
+ async waitChange (opts = {}) { return this.call('waitChange', opts) }
457
+
458
+ // Agent-activity indicator (blue border) — observations do not light it, actuations do.
459
+ async indicator (opts = {}) { return this.call('indicator', opts) }
460
+
461
+ /**
462
+ * The agent has STOPPED driving: cut the border instantly (no ease-out), exactly like the
463
+ * emergency brake. Called when the agent's turn ends, so a lingering blue border can never
464
+ * claim the machine is still under control after control actually stopped.
465
+ */
466
+ async calm () { return this.call('calm', {}) }
467
+
468
+ /// **Ask the human one question, machine paused — the ONE question path.**
469
+ ///
470
+ /// Only for the rare case where the agent is genuinely blocked on a decision it must not make
471
+ /// alone. This is a TRANSACTION in two worker ops and one client-UI wait:
472
+ ///
473
+ /// 1. `beginAsk` — the worker writes the STOP file, paints the fast-breathing red, raises the
474
+ /// host and puts the caret in the composer, then RETURNS AT ONCE. The worker's single-threaded
475
+ /// op loop is never occupied by the wait, so `abort`/`panic`/any other op is answered while a
476
+ /// question is open (the old one-shot `ask` op held the loop for the whole window).
477
+ /// 2. `opts.onAsk(info)` — the ANSWER CHANNEL, required. The plugin hands the question to the
478
+ /// client-UI card `ask_user_question` uses; it resolves with `{answered, keepPause, …}` and
479
+ /// has no countdown of its own. That is the "hang until the human answers" the human asked for.
480
+ /// 3. `endAsk` — issued from the `finally`, so EVERY ending releases: answered, dismissed,
481
+ /// aborted turn, tool deadline, `onAsk` throwing. `keepPause` travels from the human's own
482
+ /// choice on the card (①让你停 / ②放你走, D3) — never derived from how the call ended.
483
+ ///
484
+ /// @param {object} opts
485
+ /// @param {string} opts.question
486
+ /// @param {(info: {askId:number, hostRaised:boolean, caretPlaced:boolean}) => Promise<object>} opts.onAsk
487
+ /// resolves `{answered:boolean, keepPause?:boolean, selected?:string[], custom?:string, reason?:string}`
488
+ /// @returns {Promise<object>} `{...answer, question, askId, stopped, exited, release}`
489
+ async ask (opts = {}) {
490
+ if (typeof opts.onAsk !== 'function') {
491
+ throw new Error('ask() requires onAsk: the worker no longer waits for a human; the answer comes from the client UI')
492
+ }
493
+ // The ask reports state (it engages and releases a brake), so its replies are state reports, not
494
+ // just data, and the guard counts EXTERNAL state changes only (F2): the worker's own `ask`
495
+ // notification belongs to this very request (see onLine), so it must not invalidate anything.
496
+ // Anything else that arrived meanwhile — above all Ctrl+Alt+Q — makes a reply history, and
497
+ // history may not overwrite the present. The window is far longer now (a human reading a card,
498
+ // not a 60 s clock), so that discipline matters MORE, not less: it is applied to the release.
499
+ const before = this.stateSeq
500
+ const tracker = { own: 0 }
501
+ this._askOpen = tracker
502
+ let b
503
+ try {
504
+ // A throw HERE means nothing was engaged (the worker verified `Panic.Engaged`, or refused for
505
+ // NO-CYCLE) — so there is nothing to release and no `endAsk` is sent.
506
+ b = await this.call('beginAsk', { question: opts.question }, ASK_DEADLINE_MS)
507
+ } finally {
508
+ this._askOpen = null
509
+ }
510
+ const askId = b && b.askId
511
+ let answer = null
512
+ let rel = null
513
+ let via = 'ok'
514
+ try {
515
+ answer = await opts.onAsk({ askId, hostRaised: !!(b && b.hostRaised), caretPlaced: !!(b && b.caretPlaced) })
516
+ // `via` is an AUDIT LABEL ONLY — it decides nothing. The pause decision is `keepPause`, and it
517
+ // comes from the human: ①让你停 keeps the brake, and every other ending (②放你走, a typed custom
518
+ // answer, a dismissed card, an aborted turn, the tool deadline, a missing answer) releases it.
519
+ via = !answer ? 'ok'
520
+ : answer.answered ? 'answered'
521
+ : (answer.reason === 'cancelled' ? 'cancelled'
522
+ : (answer.reason === 'aborted' || answer.reason === 'tool-timeout') ? 'aborted' : (answer.reason || 'ok'))
523
+ } finally {
524
+ // SOMEONE must read the answer's decision — `|| false` would turn "the channel never said" into
525
+ // a release, which is the safe direction but would hide a broken channel. Require a boolean and
526
+ // shout when it is missing; a channel that cannot state the pause decision must not silently
527
+ // keep a brake up.
528
+ const keepPause = answer && typeof answer.keepPause === 'boolean' ? answer.keepPause : false
529
+ if (answer && typeof answer.keepPause !== 'boolean') {
530
+ this.log('[ask] the answer channel returned no keepPause boolean — releasing the brake (the safe direction): ' +
531
+ JSON.stringify({ answered: (answer && answer.answered) === true, reason: answer && answer.reason }))
532
+ }
533
+ // CAPTURE THE STATE THE RELEASE IS ALLOWED TO OVERWRITE (Codex-review discipline, kept from the
534
+ // old ask). `endAsk`'s own audit record is not a state change, so `before` does not move for a
535
+ // normal release; if a Ctrl+Alt+Q (or a new panic) lands while the release is in flight, the
536
+ // reply describes a machine that no longer exists and must not be applied on top of it.
537
+ const beforeRelease = this.stateSeq
538
+ try {
539
+ rel = await this.call('endAsk', { askId, via, keepPause }, END_ASK_DEADLINE_MS, /* killOnTimeout */ false)
540
+ } catch (e) {
541
+ // ONE retry: the first attempt may have timed out against a briefly busy worker. A second
542
+ // failure is not swallowed — the audit pair (ENGAGE-ASK with no ASK-RELEASE) is the signal,
543
+ // and the orphan sweep is the backstop. NEVER a kill here (killOnTimeout:false above): the
544
+ // dead worker's STOP file would outlive every waiter, which is the defect being removed.
545
+ try {
546
+ rel = await this.call('endAsk', { askId, via: via + '+retry', keepPause }, END_ASK_DEADLINE_MS, false)
547
+ } catch (e2) {
548
+ this.log(`[ask] endAsk failed twice: ${e2.message} — the brake may need the orphan sweep (the next worker call sweeps it, or the human's Ctrl+Alt+R)`)
549
+ }
550
+ }
551
+ if (rel) {
552
+ if (this.stateSeq === beforeRelease) {
553
+ this.stopped = !!rel.stopped
554
+ if (rel.exited) this.exited = true
555
+ } else {
556
+ this.log('[ask] a state change arrived while the ask brake was being released — the newer state stands')
557
+ }
558
+ }
559
+ const external = (this.stateSeq - before) - tracker.own
560
+ if (external > 0) this.log(`[ask] ${external} state change(s) arrived while the question was pending`)
561
+ // EMITTED FROM THE FINALLY, so a throwing/throttled `endAsk` still settles the plugin's
562
+ // `asking` gate: an ask that leaves the gate stuck on `asking` locks out the owner's idle and
563
+ // its turn/end and every other session (lifecycle bug, 2026-09-13). It is not a resume, it
564
+ // clears no brake and it reports only how the question actually ended.
565
+ this.emit('ask-ended', {
566
+ answered: !!(answer && answer.answered),
567
+ stopped: this.stopped,
568
+ exited: this.exited,
569
+ release: rel,
570
+ })
571
+ }
572
+ return { ...(answer || {}), question: opts.question, askId, stopped: this.stopped, exited: this.exited, release: rel }
573
+ }
574
+
575
+ /// **End the session from the agent side** — the programmatic twin of Ctrl+Alt+Q.
576
+ /// Only when the work genuinely cannot go on: it cancels the turn, and nothing re-opens the
577
+ /// session (not even Ctrl+Alt+R) until an agent drives this machine again.
578
+ async exit (why) {
579
+ return this.call('exit', { why: why || 'could not proceed' })
580
+ }
581
+
582
+ /**
583
+ * Where will synthetic keystrokes land? Returns the foreground window, whether it matched
584
+ * the intended target, whether it had to be re-asserted, and an integrity-level warning when
585
+ * Windows (UIPI) would silently discard our input. Text-only — no screenshot needed.
586
+ */
587
+ async focus (opts = {}) { return this.call('focus', opts) }
588
+
589
+ /** Sampled fingerprint plus window identity/geometry; a change invalidates cached marks. */
590
+ async frameSig (opts = {}) { return this.call('frameSig', opts) }
591
+
592
+ /**
593
+ * Landmark map of a window — the "look once, then reuse" mechanism.
594
+ *
595
+ * The first call on a window returns named interactive elements with snapshot ids and
596
+ * its centre (M1..Mn); a later call while the window still looks the same costs ONE cheap
597
+ * frame hash (no image transfer, annotation or model vision) and returns the same table with
598
+ * `reused:true`. So entering a window and reading it once is enough to keep working in it by
599
+ * NAME, without paying for another screenshot each step.
600
+ */
601
+ async landmarks (opts = {}) {
602
+ const step = opts.step ?? 16
603
+ const insetPct = opts.insetPct ?? 10
604
+ const fs = await this.frameSig({ hwnd: opts.hwnd, step, insetPct })
605
+ const hwnd = fs.hwnd
606
+ if (!hwnd || !fs.sig) throw staleMark('window snapshot unavailable')
607
+ const cached = this.maps.get(String(hwnd))
608
+ const key = JSON.stringify([opts.depth ?? 14, opts.maxNodes ?? 1500, opts.maxMarks ?? 80, opts.minSize ?? 8, opts.role, opts.nameContains])
609
+ if (cached && sameMarkFrame(cached.frame, fs) && cached.key === key && !opts.refresh) {
610
+ this.lastMarks = cached.marks
611
+ this.lastMarkFrame = { ...fs, step, insetPct }
612
+ return { hwnd, marks: cached.marks, count: cached.marks.length, reused: true, ageMs: Date.now() - cached.ts, sig: fs.sig }
613
+ }
614
+ const uia = await this.call('uia', {
615
+ hwnd: hwnd || undefined,
616
+ depth: opts.depth ?? 14,
617
+ maxNodes: opts.maxNodes ?? 1500,
618
+ role: opts.role,
619
+ nameContains: opts.nameContains,
620
+ })
621
+ const marks = buildMarks(uia.flat || [], opts.maxMarks ?? 80, opts.minSize ?? 8)
622
+ const after = await this.frameSig({ hwnd, step, insetPct })
623
+ if (!sameMarkFrame(fs, after)) {
624
+ const changed = ['hwnd', 'pid'].filter(k => fs[k] !== after[k])
625
+ for (const k of ['x', 'y', 'width', 'height']) if (fs.region?.[k] !== after.region?.[k]) changed.push(`region.${k}`)
626
+ if (fs.sig !== after.sig) changed.push('sampled-frame-signature')
627
+ throw staleMark('window changed while reading the element map (' + changed.join(', ') +
628
+ '); this comparison does not identify the animation, occluder or other cause')
629
+ }
630
+ for (const mark of marks) mark.id = `M${++this.markSequence}`
631
+ this.lastMarks = marks
632
+ this.lastMarkFrame = { ...after, step, insetPct }
633
+ this.maps.set(String(hwnd), { marks, frame: after, sig: after.sig, key, ts: Date.now() })
634
+ if (this.maps.size > 16) this.maps.delete(this.maps.keys().next().value)
635
+ return { hwnd, marks, count: marks.length, reused: false, sig: fs ? fs.sig : null, elements: (uia.flat || []).length }
636
+ }
637
+
638
+ // The human's emergency brake, reachable from the model side too (ESC / Ctrl+Alt+Q are
639
+ // handled inside the worker by a low-level keyboard hook).
640
+ async panic (why, temporary = false) { return this.call('panic', { why: why || 'manual', temporary }) }
641
+
642
+ async recover (pauseId) {
643
+ const before = this.stateSeq
644
+ const result = await this.call('recover', { pauseId })
645
+ if (result.recovered && this.stateSeq === before && !this.exited) this.stopped = false
646
+ else if (result.recovered) return { recovered: false, reason: 'a newer machine state superseded recovery' }
647
+ return result
648
+ }
649
+ // Opening a NEW CYCLE is the plugin's job, from the ONE place a real call reaches the worker (the
650
+ // plugin's ensureCycle) — never the human's: Ctrl+Alt+R releases a brake and does nothing else
651
+ // (the worker's Clear()), while this op is the one and only thing that un-latches Ctrl+Alt+Q.
652
+ //
653
+ // `expectExit` is the exit record the CALLER saw when it decided to open ("" = it saw none). The
654
+ // worker compares it against the record on disk inside the same lock it writes records in, and
655
+ // REFUSES to clear a newer one: an exit that lands while this op is being spawned or written is
656
+ // newer than the caller's decision, and clearing it is how a Ctrl+Alt+Q would be undone (#6).
657
+ async resume (expectExit) {
658
+ // The reply's MIRROR is guarded too (Codex review #2/#3): the worker's answer and a later
659
+ // unsolicited event can arrive in the SAME stdout chunk, and the Node layer processes the chunk's
660
+ // lines before this continuation runs — so `this.exited = false; this.stopped = false` used to
661
+ // overwrite a panic (or a new exit) that had already been observed. A state event that landed
662
+ // while this op was in flight is NEWER than the op's own result: the newer state stands.
663
+ const before = this.stateSeq
664
+ const r = await this.call('resume', typeof expectExit === 'string' ? { expectExit } : {})
665
+ if (this.stateSeq === before) {
666
+ this.exited = false
667
+ this.stopped = false
668
+ } else {
669
+ this.log('[resume] a state event arrived while the cycle opener was in flight — the newer state stands')
670
+ }
671
+ return r
672
+ }
673
+ async selfTest () { return this.actuate('selftest', {}) }
674
+ async calibrate (opts = {}) { return this.actuate('calibrate', opts) }
675
+
676
+ async key (combo, opts = {}) {
677
+ return this.actuate('key', { combo, holdMs: opts.holdMs ?? 60, ...(opts.expectHwnd ? { expectHwnd: opts.expectHwnd } : {}) })
678
+ }
679
+
680
+ async type (text, opts = {}) {
681
+ // refuse typing into password fields unless explicitly allowed — the model
682
+ // should ask the human, never handle credentials blind (DESIGN §8.3)
683
+ if (!opts.allowPassword) {
684
+ const f = await this.call('uiaFocused', {}).catch(() => null)
685
+ if (f && f.element && f.element.isPassword) {
686
+ throw new Error('target is a password field; set allowPassword:true after explicit human consent')
687
+ }
688
+ }
689
+ // paste is IME-proof and faster for CJK/long text; unicode works when the
690
+ // clipboard must not be touched. Auto rule: non-ASCII or >80 chars → paste.
691
+ const automatic = !opts.mode || opts.mode === 'auto'
692
+ const mode = automatic ? (/[\u0100-\uffff]/.test(text) || text.length > 80 ? 'paste' : 'unicode') : opts.mode
693
+ const args = {
694
+ text, mode, restoreClipboard: opts.restoreClipboard ?? true,
695
+ ...(opts.expectHwnd ? { expectHwnd: opts.expectHwnd } : {}),
696
+ }
697
+ try {
698
+ return await this.actuate('type', args)
699
+ } catch (error) {
700
+ // Only the pre-mutation format refusal proves that no text was sent. Never replay an
701
+ // interrupted/unknown paste, a concurrent human copy, or an explicitly requested paste.
702
+ if (!automatic || mode !== 'paste' || error.code !== 'CLIPBOARD_PRESERVATION_UNAVAILABLE' || error.outcome !== 'not-dispatched') throw error
703
+ const result = await this.type(text, { ...opts, mode: 'unicode' })
704
+ return { ...result, modeFallback: 'clipboard-preservation-unavailable' }
705
+ }
706
+ }
707
+
708
+ async clipRead () { return this.call('clipRead', {}) }
709
+ async clipWrite (text) { return this.actuate('clipWrite', { text }) }
710
+
711
+ async activateWindow (sel) { return this.actuate('activate', sel) }
712
+ async windowOp (sel, op, opts = {}) { return this.actuate('windowOp', { ...sel, op, ...opts }) }
713
+ /** Deterministic placement (no mouse): move/resize a window to an arbitrary rect. */
714
+ async windowMove (sel, rect) { return this.windowOp(sel, 'move', rect) }
715
+ async waitForIdle (opts = {}) {
716
+ return this.call('waitForIdle', { timeoutMs: opts.timeoutMs ?? 5000, stableMs: opts.stableMs ?? 350, pollMs: opts.pollMs ?? 120 })
717
+ }
718
+
719
+ /** Resolve {mark} | {x,y} | [x,y] | 'M12' into absolute screen px. */
720
+ async resolveTarget (target) {
721
+ if (typeof target === 'string') target = { mark: target }
722
+ if (Array.isArray(target)) return { x: target[0], y: target[1] }
723
+ if (target && typeof target.mark === 'string') {
724
+ const m = this.lastMarks.find(k => k.id.toLowerCase() === String(target.mark).toLowerCase())
725
+ if (!m) throw staleMark(`unknown or retired mark ${target.mark}`)
726
+ await this.validateMarkFrame()
727
+ return { x: m.cx, y: m.cy, mark: m, expectHwnd: this.lastMarkFrame.hwnd }
728
+ }
729
+ if (target && Number.isFinite(target.x) && Number.isFinite(target.y)) return target
730
+ throw new Error('target must be {mark}, {x,y}, or [x,y]')
731
+ }
732
+
733
+ async validateMarkFrame () {
734
+ const frame = this.lastMarkFrame
735
+ if (!frame) throw staleMark('no verified landmark snapshot')
736
+ let current
737
+ try { current = await this.frameSig({ hwnd: frame.hwnd, step: frame.step, insetPct: frame.insetPct }) }
738
+ catch { throw staleMark('window snapshot could not be checked') }
739
+ if (!sameMarkFrame(frame, current)) throw staleMark('window contents, position or identity changed')
740
+ }
741
+
742
+ /** Every state-changing op funnels through here for the audit trail. */
743
+ async actuate (op, args) {
744
+ await this.waitInputReady()
745
+ if (this.dryRun) {
746
+ const rec = { op, args: redact(args), ts: Date.now(), dryRun: true }
747
+ this.emit('action', rec)
748
+ return { dryRun: true, op }
749
+ }
750
+ const now = Date.now()
751
+ this.rateWindow = this.rateWindow.filter(t => now - t < this.rateLimit.windowMs)
752
+ if (this.rateWindow.length >= this.rateLimit.max) {
753
+ throw new Error(`rate limit: ${this.rateLimit.max} actuations/${this.rateLimit.windowMs}ms exceeded`)
754
+ }
755
+ this.rateWindow.push(now)
756
+ const rec = { op, args: redact(args), ts: Date.now() }
757
+ this.emit('action', rec)
758
+ try {
759
+ const data = await this.call(op, args)
760
+ rec.result = data
761
+ return data
762
+ } catch (err) {
763
+ if (err.code === 'HUMAN_YIELD') {
764
+ // The hook's event can arrive just after its operation refusal. One cheap local
765
+ // status read closes that race; waiting spends no model requests and never replays input.
766
+ await this.call('ping', {})
767
+ await this.waitForHuman()
768
+ err.code = 'HUMAN_REOBSERVE'
769
+ err.message += ' Human input is now idle for at least three seconds. Inspect the current target and continue automatically; do not repeat any partial write without a readback.'
770
+ }
771
+ rec.error = String(err.message || err)
772
+ throw err
773
+ } finally {
774
+ this.emit('action:settled', rec)
775
+ }
776
+ }
777
+ }
778
+
779
+ function staleMark (reason) {
780
+ return Object.assign(new Error(`STALE_MARK: ${reason}; no input sent. Refresh computer_marks and choose the target again, or use a current UIA selector.`),
781
+ { code: 'STALE_MARK', outcome: 'not-dispatched' })
782
+ }
783
+
784
+ function sameMarkFrame (a, b) {
785
+ return !!a?.sig && a.sig === b?.sig && a.hwnd === b.hwnd && a.pid === b.pid &&
786
+ ['x', 'y', 'width', 'height'].every(k => Number.isFinite(a.region?.[k]) && a.region[k] === b.region?.[k])
787
+ }
788
+
789
+ /** Keep secrets out of the audit log. */
790
+ function redact (args) {
791
+ if (args && typeof args.text === 'string' && args.text.length > 0) {
792
+ return { ...args, text: `<${args.text.length} chars>` }
793
+ }
794
+ return args
795
+ }
796
+
797
+ /**
798
+ * Turn a flat UIA dump into clickable marks — id, centre, role, name, in reading order
799
+ * (top-to-bottom then left-to-right). Shared by `marks()` (annotated screenshot) and
800
+ * `landmarks()` (cached, image-free map), so both produce identical ids for the same UI.
801
+ */
802
+ function buildMarks (flat, maxMarks = 60, minSize = 8) {
803
+ const seen = new Set()
804
+ const interactive = []
805
+ for (const e of flat) {
806
+ if (interactive.length >= maxMarks) break
807
+ const r = e.rect || {}
808
+ const name = (e.name || '').replace(/\s*\r\n\s*/g, ' | ').trim()
809
+ const key = `${e.role}|${name}|${r.x},${r.y}`
810
+ const clickable = SOM_ROLES.has(e.role) || SOM_PATTERNS.test((e.patterns || []).join(','))
811
+ if (name && !seen.has(key) && clickable && r.width >= minSize && r.height >= minSize) {
812
+ seen.add(key)
813
+ interactive.push({ ...e, name })
814
+ }
815
+ }
816
+ interactive.sort((a, b) => (a.rect.y - b.rect.y) || (a.rect.x - b.rect.x))
817
+ return interactive.map((m, i) => ({
818
+ id: `M${i + 1}`,
819
+ x: m.rect.x, y: m.rect.y, w: m.rect.width, h: m.rect.height,
820
+ cx: m.rect.x + Math.floor(m.rect.width / 2), cy: m.rect.y + Math.floor(m.rect.height / 2),
821
+ name: (m.name || '').slice(0, 90), role: m.role,
822
+ }))
823
+ }
824
+
825
+ function discoverWorker () {
826
+ const local = join(process.env.LOCALAPPDATA || '', 'dsh-computer-use', 'worker')
827
+ // A newer test binary is not a newer compatible implementation. Only the
828
+ // build keyed by this package's worker source can serve its wire protocol.
829
+ try {
830
+ const sha = createHash('sha256').update(readFileSync(join(__dirname, 'worker.cs'))).digest('hex').slice(0, 16).toUpperCase()
831
+ const exe = join(local, sha, 'dsh-computer-use-worker.exe')
832
+ if (!existsSync(exe)) return null
833
+ const out = execFileSync(exe, ['--op', 'ping'], { encoding: 'utf8', timeout: 4000, windowsHide: true })
834
+ return /"pong"\s*:\s*true/.test(out) ? exe : null
835
+ } catch { return null }
836
+ }
837
+
838
+ /** Use the prebuilt release worker, or Windows' own compiler in a source checkout. */
839
+ function compileWorkerSync (log) {
840
+ return prepareNativeBinary({ source: join(__dirname, 'worker.cs'), name: 'dsh-computer-use-worker.exe',
841
+ cache: 'worker', references: workerReferences() }, { log })
842
+ }
843
+
844
+ export { discoverWorker }