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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/README.zh.md +132 -0
- package/cordis.patch.yml +17 -0
- package/lib/artifacts.js +35 -0
- package/lib/attention-native.js +93 -0
- package/lib/attention-worker.cs +194 -0
- package/lib/attention.js +140 -0
- package/lib/audit.js +30 -0
- package/lib/batch.js +52 -0
- package/lib/bin/attention-worker.exe +0 -0
- package/lib/bin/attention-worker.exe.json +7 -0
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +7 -0
- package/lib/core/compile-worker.ps1 +58 -0
- package/lib/core/index.js +844 -0
- package/lib/core/native-runtime.js +97 -0
- package/lib/core/worker.cs +6375 -0
- package/lib/cycle.js +314 -0
- package/lib/exposure.js +91 -0
- package/lib/feedback.js +35 -0
- package/lib/image-output.js +92 -0
- package/lib/index.js +1715 -0
- package/lib/observations.js +78 -0
- package/lib/policy.js +109 -0
- package/lib/recovery.js +8 -0
- package/lib/skill-gate.js +68 -0
- package/lib/tools.js +717 -0
- package/mcp/package.json +12 -0
- package/mcp/src/index.js +335 -0
- package/package.json +88 -0
- package/scripts/build-native.mjs +7 -0
- package/scripts/smoke-native.mjs +38 -0
- package/scripts/verify-package.mjs +28 -0
- package/skills/computer-use/SKILL.md +74 -0
- package/skills/computer-use/references/lifecycle.md +180 -0
- package/skills/computer-use/references/maintenance.md +56 -0
- package/skills/computer-use/references/precision.md +297 -0
- package/skills/computer-use/references/progressive-control.md +30 -0
- 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 }
|