dsh-codex-style-computer-use 1.3.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -10
- package/README.zh.md +26 -9
- package/lib/audit.js +7 -0
- package/lib/batch.js +14 -19
- package/lib/bin/attention-worker.exe +0 -0
- package/lib/bin/attention-worker.exe.json +1 -1
- package/lib/compact-feedback.js +175 -0
- package/lib/continuation.js +1 -1
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
- package/lib/core/index.js +372 -136
- package/lib/core/native-runtime.js +17 -0
- package/lib/core/worker.cs +1779 -218
- package/lib/feedback-routes.json +11 -0
- package/lib/feedback.js +49 -16
- package/lib/image-output.js +59 -11
- package/lib/index.js +25 -7
- package/lib/key-command.js +37 -0
- package/lib/media-contract.js +43 -0
- package/lib/observations.js +267 -13
- package/lib/policy.js +7 -7
- package/lib/tool-contract.js +56 -0
- package/lib/tools.js +134 -49
- package/lib/verification-region.js +17 -0
- package/mcp/package.json +2 -1
- package/mcp/src/execution.js +8 -5
- package/mcp/src/index.js +16 -267
- package/mcp/src/tools.js +271 -0
- package/package.json +2 -1
- package/scripts/check-behavior.mjs +83 -0
- package/scripts/measure-tool-tokens.mjs +21 -0
- package/skills/computer-use/SKILL.md +20 -8
- package/skills/computer-use/references/lifecycle.md +11 -7
- package/skills/computer-use/references/maintenance.md +9 -5
- package/skills/computer-use/references/precision.md +39 -14
- package/skills/computer-use/references/progressive-control.md +10 -4
package/lib/core/index.js
CHANGED
|
@@ -16,12 +16,15 @@ import { AsyncLocalStorage } from 'node:async_hooks'
|
|
|
16
16
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { fileURLToPath } from 'node:url'
|
|
19
|
-
import { prepareNativeBinary, workerReferences } from './native-runtime.js'
|
|
19
|
+
import { prepareNativeBinary, workerReferences, nativeRuntimeIdentity } from './native-runtime.js'
|
|
20
|
+
import { parseKeyCommand } from '../key-command.js'
|
|
21
|
+
import { verificationRegion } from '../verification-region.js'
|
|
20
22
|
|
|
21
23
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
22
24
|
|
|
23
25
|
const DEFAULT_TIMEOUT_MS = 15_000
|
|
24
26
|
const CAPTURE_TIMEOUT_MS = 30_000
|
|
27
|
+
const INPUT_CLEANUP_GRACE_MS = 1500
|
|
25
28
|
// A UIA tree walk can block the resident's synchronous COM thread. Bound the
|
|
26
29
|
// caller's wait separately from recovery of a worker that never returns.
|
|
27
30
|
const UIA_READS = new Set(['uia', 'uiaFromPoint', 'uiaFocused'])
|
|
@@ -50,7 +53,7 @@ const ASK_DEADLINE_MS = 15_000
|
|
|
50
53
|
const END_ASK_DEADLINE_MS = 5_000
|
|
51
54
|
export function workerCallError (message, code, op, requestId, outcome = 'unknown') {
|
|
52
55
|
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.'}`),
|
|
53
|
-
{ code, op, requestId, outcome })
|
|
56
|
+
{ code, op, requestId, outcome, detail: message })
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** A handoff interrupts an input operation, never the authorized task. */
|
|
@@ -75,6 +78,19 @@ function humanReobserveError (error = {}) {
|
|
|
75
78
|
{ transient: true, taskStatus: 'continuing', continuation })
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
function workerReobserveError (workerGeneration, interrupted = {}) {
|
|
82
|
+
const outcome = interrupted.outcome === 'unknown' ? 'unknown' : 'not-dispatched'
|
|
83
|
+
const continuation = {
|
|
84
|
+
required: true, taskStatus: 'continuing', reason: 'worker-restarted',
|
|
85
|
+
nextAction: 'observe-current-target-then-continue-original-task',
|
|
86
|
+
replayInterruptedInput: false, outcome, workerGeneration,
|
|
87
|
+
interruptedOperation: interrupted.op || null, requestId: interrupted.requestId ?? null,
|
|
88
|
+
instruction: 'The native worker restarted and previous desktop evidence is invalid. Obtain fresh target evidence, inspect any interrupted operation, and continue the original authorized task. This is worker recovery, not a human handback or permission to replay a write.',
|
|
89
|
+
}
|
|
90
|
+
return Object.assign(workerCallError(continuation.instruction, 'WORKER_REOBSERVE', continuation.interruptedOperation, continuation.requestId, outcome),
|
|
91
|
+
{ transient: true, taskStatus: 'continuing', continuation })
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
/** Interactive roles that receive Set-of-Marks labels. */
|
|
79
95
|
const SOM_ROLES = new Set([
|
|
80
96
|
'Button', 'MenuItem', 'ListItem', 'TreeItem', 'DataItem', 'TabItem',
|
|
@@ -110,6 +126,13 @@ export class ComputerUse extends EventEmitter {
|
|
|
110
126
|
this.stopped = false // true while the human's brake is engaged (see 'panic' event)
|
|
111
127
|
this.exited = false // true once the human ended the session (see 'exit' event)
|
|
112
128
|
this.cooperation = { phase: 'idle', active: false, revision: 0 }
|
|
129
|
+
this.workerGeneration = null
|
|
130
|
+
this.nativeStateSeq = -1
|
|
131
|
+
this.workerStateInvalid = false
|
|
132
|
+
this.runtime = null
|
|
133
|
+
this.inputCleanupGraceMs = INPUT_CLEANUP_GRACE_MS
|
|
134
|
+
this.shutdownGraceMs = INPUT_CLEANUP_GRACE_MS
|
|
135
|
+
this.activeActions = new Map()
|
|
113
136
|
this.inputContext = new AsyncLocalStorage()
|
|
114
137
|
// Monotonic counter of UNSOLICITED state transitions (panic/ask/resume/exit). Anything that
|
|
115
138
|
// learns the machine's state asynchronously — above all the `ask` op, which can take seconds and
|
|
@@ -145,7 +168,10 @@ export class ComputerUse extends EventEmitter {
|
|
|
145
168
|
// app (main vs renderer/GPU), so the worker is also given the executable path, which
|
|
146
169
|
// recognises every process of the app at once. The worker refuses to drive any window of
|
|
147
170
|
// either; one-shot CLI runs (no env) keep the guard disabled.
|
|
148
|
-
|
|
171
|
+
this.spawnRuntime = nativeRuntimeIdentity(this.workerExe)
|
|
172
|
+
const env = { ...process.env, DSH_CU_HOST_PID: String(process.pid), DSH_CU_HOST_EXE: process.execPath || '',
|
|
173
|
+
DSH_CU_PACKAGE_VERSION: this.spawnRuntime.packageVersion, DSH_CU_BUILD_ID: this.spawnRuntime.buildId,
|
|
174
|
+
DSH_CU_SOURCE_HASH: this.spawnRuntime.sourceHash || '', DSH_CU_BINARY_HASH: this.spawnRuntime.binaryHash }
|
|
149
175
|
this.log(`[core] host guard armed for pid ${process.pid} (${process.execPath})`)
|
|
150
176
|
this.proc = spawn(this.workerExe, [], { env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
151
177
|
// A CHILD THAT FAILS TO START MUST NAME ITSELF (2026-09-13). Every computer_* tool died with a
|
|
@@ -176,22 +202,34 @@ export class ComputerUse extends EventEmitter {
|
|
|
176
202
|
this.proc.stderr.on('data', (d) => this.log(`[worker:stderr] ${d}`))
|
|
177
203
|
this.proc.on('exit', (code) => { if (this.proc === child) this.onWorkerExit(code) })
|
|
178
204
|
return new Promise((resolve, reject) => {
|
|
179
|
-
const
|
|
180
|
-
// surface the real reason instead of letting the raw emitter error escape
|
|
181
|
-
this.proc.once('error', (e) => {
|
|
205
|
+
const finish = (error) => {
|
|
182
206
|
clearTimeout(t)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
207
|
+
this.off('ready', ready); this.off('dead', dead); this.off('worker-incompatible', incompatible)
|
|
208
|
+
child.off('error', failed)
|
|
209
|
+
if (error) reject(error); else resolve()
|
|
210
|
+
}
|
|
211
|
+
const ready = () => finish()
|
|
212
|
+
const dead = code => finish(new Error(`worker died at startup (code ${code})`))
|
|
213
|
+
const incompatible = error => finish(error)
|
|
214
|
+
const failed = e => finish(new Error(`worker could not be started: code=${e.code} syscall=${e.syscall} path=${e.path || this.workerExe}`))
|
|
215
|
+
const t = setTimeout(() => {
|
|
216
|
+
finish(new Error('worker ready timeout'))
|
|
217
|
+
try { if (this.proc === child) child.kill() } catch { /* already gone */ }
|
|
218
|
+
}, 10_000)
|
|
219
|
+
// surface the real reason instead of letting the raw emitter error escape
|
|
220
|
+
child.once('error', failed)
|
|
221
|
+
this.once('ready', ready); this.once('dead', dead); this.once('worker-incompatible', incompatible)
|
|
187
222
|
})
|
|
188
223
|
}
|
|
189
224
|
|
|
190
225
|
onWorkerExit (code) {
|
|
191
226
|
this.ready = false
|
|
192
227
|
this.proc = null
|
|
228
|
+
this.workerStateInvalid = true
|
|
229
|
+
this.cooperation = { phase: 'unknown', active: false, revision: 0, valid: false, workerGeneration: this.workerGeneration }
|
|
230
|
+
this.maps.clear(); this.lastMarks = []; this.lastMarkFrame = null
|
|
193
231
|
this.finishUiaRead(false)
|
|
194
|
-
for (const [id, p] of this.pending) { clearTimeout(p.timer); p.cleanup?.(); p.reject(workerCallError('worker exited', 'worker-exited', p.op, id)) }
|
|
232
|
+
for (const [id, p] of this.pending) { clearTimeout(p.timer); p.cleanup?.(); p.reject(Object.assign(workerCallError('worker exited', 'worker-exited', p.op, id), { workerGeneration: p.workerGeneration, cleanup: { cleanupConfirmed: false, cleanupState: 'worker-exited' } })) }
|
|
195
233
|
this.pending.clear()
|
|
196
234
|
this.emit('dead', code)
|
|
197
235
|
}
|
|
@@ -200,6 +238,17 @@ export class ComputerUse extends EventEmitter {
|
|
|
200
238
|
let msg
|
|
201
239
|
try { msg = JSON.parse(line) } catch { this.log(`[worker:badline] ${line.slice(0, 200)}`); return }
|
|
202
240
|
if (msg.event === 'ready') {
|
|
241
|
+
// Production spawns must speak the ordered protocol. An old custom binary
|
|
242
|
+
// cannot safely participate in cancellation or generation-aware recovery.
|
|
243
|
+
if (this.spawnRuntime && (msg.protocolVersion !== 2 || typeof msg.workerGeneration !== 'string' ||
|
|
244
|
+
!msg.workerGeneration || !Number.isSafeInteger(msg.cooperation?.stateSeq) ||
|
|
245
|
+
msg.cooperation.workerGeneration !== msg.workerGeneration)) {
|
|
246
|
+
this.ready = false; this.workerStateInvalid = true
|
|
247
|
+
this.emit('worker-incompatible', workerCallError('native worker protocol is incompatible; reinstall or rebuild the current helper',
|
|
248
|
+
'WORKER_PROTOCOL_INCOMPATIBLE', null, null, 'not-dispatched'))
|
|
249
|
+
try { this.proc?.kill() } catch { /* already gone */ }
|
|
250
|
+
return
|
|
251
|
+
}
|
|
203
252
|
this.ready = true
|
|
204
253
|
// COLD-START STATE SYNC (cold-start bug, 2026-09-13): the worker has already adopted any STOP
|
|
205
254
|
// or EXITED record that predates this process, and it reports that state with `ready`. Without
|
|
@@ -209,10 +258,20 @@ export class ComputerUse extends EventEmitter {
|
|
|
209
258
|
// released — the mirror simply starts out agreeing with the machine.
|
|
210
259
|
if (typeof msg.stopped === 'boolean') this.stopped = msg.stopped
|
|
211
260
|
if (typeof msg.exited === 'boolean') this.exited = msg.exited
|
|
212
|
-
this.
|
|
261
|
+
this.workerGeneration = msg.workerGeneration || msg.runtime?.workerGeneration || msg.cooperation?.workerGeneration || null
|
|
262
|
+
this.nativeStateSeq = -1
|
|
263
|
+
this.runtime = this.spawnRuntime ? { ...this.spawnRuntime, ...msg.runtime, workerGeneration: this.workerGeneration } : msg.runtime || null
|
|
264
|
+
this.cooperation = { phase: 'idle', active: false, revision: 0 }
|
|
265
|
+
this.workerStateInvalid = false
|
|
266
|
+
if (msg.cooperation) this.syncCooperation(msg.cooperation, false)
|
|
267
|
+
this.maps.clear(); this.lastMarks = []; this.lastMarkFrame = null
|
|
213
268
|
this.emit('ready', msg)
|
|
214
269
|
return
|
|
215
270
|
}
|
|
271
|
+
// stdout from a replaced child is already filtered; generation also guards
|
|
272
|
+
// protocol-level delayed events and records restored by an observer.
|
|
273
|
+
const generation = msg.workerGeneration || msg.cooperation?.workerGeneration || msg.data?.cooperation?.workerGeneration
|
|
274
|
+
if (generation && this.workerGeneration && generation !== this.workerGeneration) return
|
|
216
275
|
if (msg.event === 'handoff') {
|
|
217
276
|
this.syncCooperation(msg.cooperation)
|
|
218
277
|
return
|
|
@@ -262,6 +321,7 @@ export class ComputerUse extends EventEmitter {
|
|
|
262
321
|
const error = workerCallError(msg.error || 'worker error', msg.code || 'worker-refusal-or-error', p.op, msg.id,
|
|
263
322
|
msg.outcome === 'not-dispatched' ? 'not-dispatched' : 'unknown')
|
|
264
323
|
if (msg.glowHealth) error.glowHealth = msg.glowHealth
|
|
324
|
+
for (const key of ['dragStarted', 'expectedCursor', 'actualCursor', 'cursorCheck']) if (msg[key] !== undefined) error[key] = msg[key]
|
|
265
325
|
p.reject(error.code === 'HUMAN_REOBSERVE' ? humanReobserveError(error) : error)
|
|
266
326
|
}
|
|
267
327
|
}
|
|
@@ -273,13 +333,51 @@ export class ComputerUse extends EventEmitter {
|
|
|
273
333
|
* @param {boolean} [killOnTimeout] kill a wedged worker when that cap expires (default true)
|
|
274
334
|
* @returns {Promise<any>} worker `data` payload
|
|
275
335
|
*/
|
|
336
|
+
// Cleanup runs under its own explicit authority. A cancelled user context must
|
|
337
|
+
// not block key release or the closing of a question; ordinary tools never use it.
|
|
338
|
+
callInternal (op, args = {}, timeoutMs = DEFAULT_TIMEOUT_MS, killOnTimeout = true, beforeDispatch) {
|
|
339
|
+
if (!['cancelInput', 'calm', 'endAsk', 'abort'].includes(op)) throw new Error('unsupported internal cleanup operation')
|
|
340
|
+
return this.inputContext.run({ internalCleanup: true }, () => this.call(op, args, timeoutMs, killOnTimeout, beforeDispatch))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async cancelRequest (requestId, child, reason, killOnFailure = true) {
|
|
344
|
+
const cleanup = { requestId, workerGeneration: this.workerGeneration, requested: true,
|
|
345
|
+
cleanupConfirmed: false, cleanupState: 'unconfirmed', workerKilled: false }
|
|
346
|
+
const sameWorker = () => {
|
|
347
|
+
if (this.proc !== child) throw Object.assign(new Error('original worker exited before cleanup'), { code: 'worker-replaced' })
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
sameWorker()
|
|
351
|
+
const ack = await this.callInternal('cancelInput', { requestId }, this.inputCleanupGraceMs, false, sameWorker)
|
|
352
|
+
if (ack?.requestId !== undefined && ack.requestId !== requestId) throw new Error('cancellation acknowledgement named another request')
|
|
353
|
+
if (ack?.workerGeneration && ack.workerGeneration !== cleanup.workerGeneration) throw new Error('cancellation acknowledgement named another worker')
|
|
354
|
+
cleanup.cancelled = ack?.cancelled === true
|
|
355
|
+
cleanup.cleanupConfirmed = ack?.cleanupConfirmed === true && ack?.ownedInputCount === 0
|
|
356
|
+
cleanup.cleanupState = ack?.cleanupState || (cleanup.cleanupConfirmed ? 'released' : 'unconfirmed')
|
|
357
|
+
if (Number.isInteger(ack?.ownedInputCount)) cleanup.ownedInputCount = ack.ownedInputCount
|
|
358
|
+
} catch (error) {
|
|
359
|
+
cleanup.cleanupState = this.proc !== child ? 'worker-exited' : 'ack-failed'
|
|
360
|
+
cleanup.errorCode = error.code || 'cleanup-error'
|
|
361
|
+
}
|
|
362
|
+
if (!cleanup.cleanupConfirmed && killOnFailure && this.proc === child) {
|
|
363
|
+
cleanup.killReason = reason
|
|
364
|
+
try { cleanup.workerKilled = child.kill() !== false } catch { cleanup.workerKilled = false }
|
|
365
|
+
}
|
|
366
|
+
return cleanup
|
|
367
|
+
}
|
|
368
|
+
|
|
276
369
|
async call (op, args = {}, timeoutMs = DEFAULT_TIMEOUT_MS, killOnTimeout = true, beforeDispatch) {
|
|
277
370
|
const input = isInputOp(op, args)
|
|
278
|
-
const
|
|
371
|
+
const priorGeneration = this.workerGeneration
|
|
372
|
+
const invalidAtStart = this.workerStateInvalid
|
|
373
|
+
const context = this.inputContext.getStore()
|
|
279
374
|
const signal = context?.signal
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
375
|
+
const check = () => {
|
|
376
|
+
if (signal?.aborted) throw workerCallError('owning tool cancelled', 'INPUT_CANCELLED', op, null, 'not-dispatched')
|
|
377
|
+
beforeDispatch?.()
|
|
378
|
+
context?.assertOwnership?.()
|
|
379
|
+
}
|
|
380
|
+
check()
|
|
283
381
|
try { await this.ensureWorker() }
|
|
284
382
|
catch (error) { throw workerCallError(error.message || String(error), 'worker-unavailable', op, null, 'not-dispatched') }
|
|
285
383
|
const child = this.proc
|
|
@@ -290,71 +388,73 @@ export class ComputerUse extends EventEmitter {
|
|
|
290
388
|
if (this.proc !== child) throw workerCallError('worker exited before dispatch', 'worker-exited', op, null, 'not-dispatched')
|
|
291
389
|
if (Date.now() >= queueDeadline) throw workerCallError('deadline expired waiting for the UIA read', 'worker-uia-busy', op, null, 'not-dispatched')
|
|
292
390
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
391
|
+
check()
|
|
392
|
+
if (input && (invalidAtStart || (priorGeneration && priorGeneration !== this.workerGeneration))) {
|
|
393
|
+
if (this.stopped || this.exited) throw workerCallError('manual pause/exit remains authoritative', 'ABORTED', op, null, 'not-dispatched')
|
|
394
|
+
throw workerReobserveError(this.workerGeneration, { op })
|
|
395
|
+
}
|
|
296
396
|
const id = ++this.seq
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
const signed = { ...args, __cycle: true }
|
|
303
|
-
signed.
|
|
304
|
-
// One warning per external call/batch, with a fresh identity for each context.
|
|
305
|
-
// Never let a caller-supplied argument reuse another tool's announcement.
|
|
306
|
-
if (input) signed.__inputSeries = this.inputContext.getStore()?.inputSeries || randomUUID()
|
|
397
|
+
const generation = this.workerGeneration
|
|
398
|
+
if (input && context?.actionRecord) Object.assign(context.actionRecord, {
|
|
399
|
+
requestId: id, workerGeneration: generation, runtime: this.runtime,
|
|
400
|
+
})
|
|
401
|
+
// __cycle is signed last. A caller cannot forge another input series or turn.
|
|
402
|
+
const signed = { ...args, __cycle: context?.internalCleanup !== true, __kickoff: this.kickoffRequired }
|
|
403
|
+
if (input) signed.__inputSeries = context?.inputSeries || randomUUID()
|
|
307
404
|
return new Promise((resolve, reject) => {
|
|
308
405
|
let written = false
|
|
309
406
|
const read = UIA_READS.has(op) && killOnTimeout ? (this.uiaRead = { id, child, timedOut: false }) : null
|
|
310
407
|
const cleanup = () => signal?.removeEventListener('abort', abort)
|
|
311
|
-
const
|
|
408
|
+
const deferReadRecovery = () => {
|
|
409
|
+
if (!read || this.uiaRead !== read) return
|
|
410
|
+
read.timedOut = true
|
|
411
|
+
this.emit('uia-read-settled', false)
|
|
412
|
+
const grace = Math.min(timeoutMs, DEFAULT_TIMEOUT_MS)
|
|
413
|
+
read.watchdog = setTimeout(() => {
|
|
414
|
+
if (this.proc !== child || this.uiaRead !== read) return
|
|
415
|
+
this.log(`[core] UIA read ${id} (${op}) still blocked after ${grace}ms recovery grace; stopping worker pid=${child.pid}`)
|
|
416
|
+
try { child.kill() } catch { /* already gone */ }
|
|
417
|
+
}, grace)
|
|
418
|
+
}
|
|
419
|
+
const fail = async (code, message, timeout) => {
|
|
312
420
|
if (!this.pending.delete(id)) return
|
|
313
421
|
clearTimeout(timer); cleanup()
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
422
|
+
const error = workerCallError(message, code, op, id, input && written ? 'unknown' : 'not-dispatched')
|
|
423
|
+
error.operationDispatched = written
|
|
424
|
+
error.workerGeneration = generation
|
|
425
|
+
if (input && written) {
|
|
426
|
+
// Stop the original request out of band, then allow a bounded cleanup
|
|
427
|
+
// acknowledgement before killing a worker that may still own a key down.
|
|
428
|
+
error.cleanup = await this.cancelRequest(id, child, timeout ? 'input-timeout' : 'caller-cancelled', killOnTimeout)
|
|
429
|
+
} else if (read) {
|
|
430
|
+
deferReadRecovery()
|
|
431
|
+
} else if (timeout && killOnTimeout) {
|
|
432
|
+
try { if (this.proc === child) child.kill() } catch { /* already gone */ }
|
|
433
|
+
}
|
|
434
|
+
reject(error)
|
|
435
|
+
}
|
|
436
|
+
const abort = () => {
|
|
437
|
+
// beginAsk is a receipt-producing transaction: after dispatch we need
|
|
438
|
+
// its askId to release the exact brake. ask() checks cancellation before
|
|
439
|
+
// showing the card and always closes that receipt in its finally.
|
|
440
|
+
if (op === 'beginAsk' && written) return
|
|
441
|
+
void fail('INPUT_CANCELLED', 'owning tool cancelled; inspect any accepted input before retrying', false)
|
|
317
442
|
}
|
|
318
443
|
const timer = setTimeout(() => {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
this.emit('uia-read-settled', false)
|
|
324
|
-
// Drop the response by id when it eventually arrives. A later reply for
|
|
325
|
-
// this id cancels only this watchdog, never a newer worker's deadline.
|
|
326
|
-
const grace = Math.min(timeoutMs, DEFAULT_TIMEOUT_MS)
|
|
327
|
-
read.watchdog = setTimeout(() => {
|
|
328
|
-
if (this.proc !== child || this.uiaRead !== read) return
|
|
329
|
-
this.log(`[core] UIA read ${id} (${op}) still blocked after ${grace}ms recovery grace; stopping worker pid=${child.pid}`)
|
|
330
|
-
try { child.kill() } catch { /* already gone */ }
|
|
331
|
-
}, grace)
|
|
332
|
-
reject(workerCallError(`UIA observation timed out: ${op} (${timeoutMs}ms); worker still finishing this read. Narrow the tree or use a point query once it returns.`,
|
|
333
|
-
'worker-timeout', op, id, 'not-dispatched'))
|
|
334
|
-
return
|
|
335
|
-
}
|
|
336
|
-
// A CALL THAT RELEASES THE BRAKE MUST NOT BE KILLED FOR BEING SLOW. `proc.kill()` deletes no
|
|
337
|
-
// STOP file (there is no ProcessExit handler in worker.cs; `abort` is Environment.Exit(0) at
|
|
338
|
-
// worker.cs:3045), so killing here would end a question with the brake still up — the exact
|
|
339
|
-
// defect this design removes. Without the kill the worker is still alive, so the caller's
|
|
340
|
-
// retry, or the next op's SyncFromFile, can still finish the release.
|
|
341
|
-
//
|
|
342
|
-
// Everything else keeps today's behaviour: a timed-out op means the worker is wedged (a hung
|
|
343
|
-
// UIA/COM provider call blocks its single-threaded loop forever), so kill it and let the NEXT
|
|
344
|
-
// call respawn fresh instead of queueing behind a corpse; pending calls are rejected via
|
|
345
|
-
// onWorkerExit.
|
|
346
|
-
if (killOnTimeout) { try { if (this.proc === child) child.kill() } catch { /* already gone */ } }
|
|
347
|
-
reject(workerCallError(`worker timeout: ${op} (${timeoutMs}ms)` +
|
|
348
|
-
(killOnTimeout
|
|
349
|
-
? ' — worker killed, next call respawns'
|
|
350
|
-
: ' — worker left alive so the brake can still be released'),
|
|
351
|
-
'worker-timeout', op, id))
|
|
444
|
+
const message = read
|
|
445
|
+
? `UIA observation timed out: ${op} (${timeoutMs}ms); worker still finishing this read. Narrow the tree or use a point query once it returns.`
|
|
446
|
+
: `worker timeout: ${op} (${timeoutMs}ms)` + (input ? ' — cancellation and owned-input cleanup requested before worker recovery' : killOnTimeout ? ' — worker recovery requested' : ' — worker left alive so cleanup can finish')
|
|
447
|
+
void fail('worker-timeout', message, true)
|
|
352
448
|
}, Math.max(0, queueDeadline - Date.now()))
|
|
353
|
-
this.pending.set(id, { resolve, reject, timer, op, input, cleanup, abort })
|
|
449
|
+
this.pending.set(id, { resolve, reject, timer, op, input, cleanup, abort, child, workerGeneration: generation })
|
|
354
450
|
signal?.addEventListener('abort', abort, { once: true })
|
|
355
451
|
if (signal?.aborted) { abort(); return }
|
|
356
|
-
|
|
357
|
-
|
|
452
|
+
try {
|
|
453
|
+
written = true // a synchronous pipe failure still leaves dispatch uncertain
|
|
454
|
+
child.stdin.write(JSON.stringify({ id, op, args: signed }) + '\r\n')
|
|
455
|
+
} catch (error) {
|
|
456
|
+
void fail('worker-write-failed', 'worker input pipe rejected the request: ' + error.message, false)
|
|
457
|
+
}
|
|
358
458
|
})
|
|
359
459
|
}
|
|
360
460
|
|
|
@@ -392,34 +492,101 @@ export class ComputerUse extends EventEmitter {
|
|
|
392
492
|
}
|
|
393
493
|
|
|
394
494
|
async kill () {
|
|
495
|
+
const ownAttempt = this.inputContext.getStore()?.actionRecord?.attemptId
|
|
496
|
+
const actions = [...this.activeActions.entries()].filter(([id]) => id !== ownAttempt).map(([, settled]) => settled)
|
|
395
497
|
this.emit('input-cancel')
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
498
|
+
const child = this.proc
|
|
499
|
+
const bounded = async promise => {
|
|
500
|
+
let timer
|
|
501
|
+
try { return await Promise.race([promise.then(() => true), new Promise(resolve => { timer = setTimeout(() => resolve(false), this.shutdownGraceMs) })]) }
|
|
502
|
+
finally { clearTimeout(timer) }
|
|
503
|
+
}
|
|
504
|
+
if (child) {
|
|
505
|
+
let exited, detach
|
|
506
|
+
const death = new Promise(resolve => {
|
|
507
|
+
exited = resolve
|
|
508
|
+
if (typeof child.once === 'function') {
|
|
509
|
+
child.once('exit', exited); detach = () => child.off('exit', exited)
|
|
510
|
+
} else {
|
|
511
|
+
this.once('dead', exited); detach = () => this.off('dead', exited)
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
try {
|
|
515
|
+
const sameWorker = () => { if (this.proc !== child) throw new Error('original worker already exited') }
|
|
516
|
+
try { await this.callInternal('abort', {}, 1000, false, sameWorker) } catch { /* best effort */ }
|
|
517
|
+
if (this.proc === child) {
|
|
518
|
+
try { child.kill() } catch { /* failure is reported as unknown below */ }
|
|
519
|
+
if (!await bounded(death)) {
|
|
520
|
+
// Termination itself is uncertain. Settle local callers honestly so
|
|
521
|
+
// disposal cannot silently discard their final audit records.
|
|
522
|
+
for (const [id, pending] of this.pending) if (pending.child === child) {
|
|
523
|
+
this.pending.delete(id); clearTimeout(pending.timer); pending.cleanup?.()
|
|
524
|
+
pending.reject(Object.assign(workerCallError('worker shutdown did not confirm process exit', 'worker-shutdown-unconfirmed', pending.op, id),
|
|
525
|
+
{ cleanup: { cleanupConfirmed: false, cleanupState: 'shutdown-unconfirmed' } }))
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} finally { detach() }
|
|
530
|
+
}
|
|
531
|
+
// In-flight actuate() finally blocks emit the persistent settlement. Waiting
|
|
532
|
+
// for process exit alone is too early; never wait for the action invoking us.
|
|
533
|
+
await bounded(Promise.all(actions))
|
|
399
534
|
}
|
|
400
535
|
|
|
401
536
|
// ---------------- screen observation ----------------
|
|
402
|
-
syncCooperation (state) {
|
|
403
|
-
if (!state ||
|
|
537
|
+
syncCooperation (state, notify = true) {
|
|
538
|
+
if (!state || this.workerStateInvalid) return false
|
|
539
|
+
if (state.workerGeneration && this.workerGeneration && state.workerGeneration !== this.workerGeneration) return false
|
|
540
|
+
if (Number.isSafeInteger(state.stateSeq)) {
|
|
541
|
+
if (state.stateSeq <= this.nativeStateSeq) return false
|
|
542
|
+
} else if (this.nativeStateSeq >= 0) return false // never downgrade an ordered worker
|
|
543
|
+
if (Number(state.revision) < Number(this.cooperation.revision)) return false
|
|
404
544
|
if (Number(state.revision) === Number(this.cooperation.revision) && Number.isFinite(state.inputEpoch) &&
|
|
405
|
-
state.inputEpoch < (this.cooperation.inputEpoch || 0)) return
|
|
545
|
+
state.inputEpoch < (this.cooperation.inputEpoch || 0)) return false
|
|
546
|
+
if (Number.isSafeInteger(state.stateSeq)) this.nativeStateSeq = state.stateSeq
|
|
406
547
|
const inputChanged = Number.isFinite(state.inputEpoch) && state.inputEpoch !== (this.cooperation.inputEpoch || 0)
|
|
407
548
|
this.cooperation = state
|
|
408
549
|
if (state.active || inputChanged) { this.maps.clear(); this.lastMarks = []; this.lastMarkFrame = null }
|
|
409
|
-
this.emit('handoff', state)
|
|
550
|
+
if (notify) this.emit('handoff', state)
|
|
551
|
+
return true
|
|
410
552
|
}
|
|
411
553
|
|
|
412
554
|
withInputContext (context, fn) {
|
|
413
|
-
return this.inputContext.run({ signal: context?.signal, assertOwnership: context?.assertOwnership,
|
|
555
|
+
return this.inputContext.run({ signal: context?.signal, assertOwnership: context?.assertOwnership,
|
|
556
|
+
cycleId: context?.cycleId ?? null, sessionKey: context?.sessionKey ?? null, agentKey: context?.agentKey ?? null,
|
|
557
|
+
inputSeries: randomUUID() }, fn)
|
|
414
558
|
}
|
|
415
559
|
|
|
416
560
|
waitForHuman () {
|
|
417
561
|
const signal = this.inputContext.getStore()?.signal
|
|
562
|
+
const started = performance.now()
|
|
563
|
+
const state = () => Object.fromEntries(['phase', 'active', 'revision', 'heldCount', 'lastKind', 'quietRemainingMs', 'inputEpoch']
|
|
564
|
+
.filter(key => this.cooperation[key] !== undefined).map(key => [key, this.cooperation[key]]))
|
|
565
|
+
const report = (phase, extra = {}) => {
|
|
566
|
+
const record = { ts: Date.now(), op: 'handoff-wait', phase, waitedMs: Math.round(performance.now() - started), cooperation: state(), ...extra }
|
|
567
|
+
try { this.emit('handoff-wait', record) } catch { /* diagnostics never own the wait */ }
|
|
568
|
+
return record
|
|
569
|
+
}
|
|
418
570
|
return new Promise((resolve, reject) => {
|
|
571
|
+
let finished = false
|
|
419
572
|
const events = ['handoff', 'panic', 'ask', 'exit', 'dead', 'input-cancel']
|
|
420
573
|
const cleanup = () => { for (const event of events) this.off(event, listeners[event]); signal?.removeEventListener('abort', cancelled) }
|
|
421
|
-
const finish = (error) => {
|
|
422
|
-
|
|
574
|
+
const finish = (error) => {
|
|
575
|
+
if (finished) return
|
|
576
|
+
finished = true
|
|
577
|
+
cleanup()
|
|
578
|
+
const record = report(error ? 'ended-with-error' : 'handback', error ? { code: error.code, cancelSource: error.cancelSource || null } : {})
|
|
579
|
+
if (error) { error.handoffWait = record; reject(error) } else resolve()
|
|
580
|
+
}
|
|
581
|
+
const cancelled = () => {
|
|
582
|
+
const source = signal?.aborted ? 'host-signal' : 'core-input-cancel'
|
|
583
|
+
const code = signal?.reason?.code || signal?.reason?.name
|
|
584
|
+
const reasonCode = typeof code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(code) ? code : 'unspecified'
|
|
585
|
+
const snapshot = state()
|
|
586
|
+
const error = workerCallError(`waiting for the human was cancelled; source=${source}; reasonCode=${reasonCode}; waitedMs=${Math.round(performance.now() - started)}; phase=${snapshot.phase}; heldCount=${snapshot.heldCount ?? 'unknown'}. Cancellation ended the local wait; it does not explain why handback was delayed`, 'INPUT_CANCELLED', null, null, 'not-dispatched')
|
|
587
|
+
error.cancelSource = source
|
|
588
|
+
finish(error)
|
|
589
|
+
}
|
|
423
590
|
const check = () => {
|
|
424
591
|
if (signal?.aborted) return cancelled()
|
|
425
592
|
if (this.stopped || this.exited) return finish(workerCallError('manual pause/exit remains authoritative', 'ABORTED', null, null, 'not-dispatched'))
|
|
@@ -430,11 +597,23 @@ export class ComputerUse extends EventEmitter {
|
|
|
430
597
|
event === 'input-cancel' ? cancelled : check]))
|
|
431
598
|
for (const event of events) this.on(event, listeners[event])
|
|
432
599
|
signal?.addEventListener('abort', cancelled, { once: true })
|
|
600
|
+
report('started')
|
|
433
601
|
check()
|
|
434
602
|
})
|
|
435
603
|
}
|
|
436
604
|
|
|
437
605
|
async waitInputReady () {
|
|
606
|
+
if (this.workerStateInvalid) {
|
|
607
|
+
const context = this.inputContext.getStore()
|
|
608
|
+
if (context?.signal?.aborted) throw workerCallError('owning tool cancelled', 'INPUT_CANCELLED', null, null, 'not-dispatched')
|
|
609
|
+
context?.assertOwnership?.()
|
|
610
|
+
await this.ensureWorker()
|
|
611
|
+
if (context?.signal?.aborted) throw workerCallError('owning tool cancelled', 'INPUT_CANCELLED', null, null, 'not-dispatched')
|
|
612
|
+
context?.assertOwnership?.()
|
|
613
|
+
if (this.workerStateInvalid) throw workerCallError('native worker has not supplied a fresh ready snapshot', 'WORKER_STATE_UNAVAILABLE', null, null, 'not-dispatched')
|
|
614
|
+
if (this.stopped || this.exited) throw workerCallError('manual pause/exit remains authoritative', 'ABORTED', null, null, 'not-dispatched')
|
|
615
|
+
throw workerReobserveError(this.workerGeneration)
|
|
616
|
+
}
|
|
438
617
|
if (!this.cooperation.active) return
|
|
439
618
|
await this.waitForHuman()
|
|
440
619
|
this.inputContext.getStore()?.assertOwnership?.()
|
|
@@ -482,8 +661,10 @@ export class ComputerUse extends EventEmitter {
|
|
|
482
661
|
const active = (windows.windows || []).find(w => w.active) || null
|
|
483
662
|
return {
|
|
484
663
|
active, windows: windows.windows, cursor,
|
|
664
|
+
cooperation: windows.cooperation, indicator: windows.indicator, virtualScreen: windows.virtualScreen,
|
|
665
|
+
runtime: this.runtime, workerGeneration: this.workerGeneration,
|
|
485
666
|
uiaRoot: uia && uia.root, uiaFlat: (uia && uia.flat) || [],
|
|
486
|
-
uiaCount: uia && uia.count, uiaTruncated: !uia || uia.truncated !== false, ts: Date.now(),
|
|
667
|
+
uiaCount: uia && uia.count, uiaTruncated: !uia || uia.truncated !== false, uiaBudget: uia?.budget, ts: Date.now(),
|
|
487
668
|
}
|
|
488
669
|
}
|
|
489
670
|
|
|
@@ -493,7 +674,8 @@ export class ComputerUse extends EventEmitter {
|
|
|
493
674
|
* mark ids refer to the same observed map as the text table.
|
|
494
675
|
*/
|
|
495
676
|
async marks (opts = {}) {
|
|
496
|
-
const map = opts.landmarkMap || await this.landmarks({ hwnd: opts.hwnd, depth: opts.uiaDepth ??
|
|
677
|
+
const map = opts.landmarkMap || await this.landmarks({ hwnd: opts.hwnd, depth: opts.uiaDepth ?? 6,
|
|
678
|
+
maxNodes: opts.uiaMaxNodes ?? 300, maxMarks: opts.maxMarks })
|
|
497
679
|
await this.validateMarkFrame()
|
|
498
680
|
const state = { active: { hwnd: map.hwnd }, cursor: await this.call('cursor', {}) }
|
|
499
681
|
// capture FIRST so the visible region drives the ghost-mark filter:
|
|
@@ -519,17 +701,24 @@ export class ComputerUse extends EventEmitter {
|
|
|
519
701
|
}, CAPTURE_TIMEOUT_MS)
|
|
520
702
|
annotated = Buffer.from(res.image, 'base64')
|
|
521
703
|
}
|
|
522
|
-
return { marks: markList, annotated, raw: opts.raw ? shot.image : undefined,
|
|
704
|
+
return { marks: markList, annotated, raw: opts.raw || !annotated ? shot.image : undefined,
|
|
705
|
+
mime: annotated ? 'image/jpeg' : shot.mime, rawMime: shot.mime,
|
|
706
|
+
region: shot.region, imageSize: shot.imageSize, capturedAt: shot.capturedAt,
|
|
707
|
+
cursorInImage: shot.cursorInImage, scaledTo: shot.scaledTo,
|
|
708
|
+
settled: shot.settled, settleMs: shot.settleMs,
|
|
709
|
+
state: { active: state.active, cursor: state.cursor }, ts: shot.ts ?? Date.now() }
|
|
523
710
|
}
|
|
524
711
|
|
|
525
712
|
// ---------------- actions (all audited) ----------------
|
|
526
713
|
async move (x, y) { return this.actuate('move', { x, y }) }
|
|
527
714
|
|
|
528
715
|
async click (target, opts = {}) {
|
|
716
|
+
const witness = verificationRegion(opts.verificationRegion)
|
|
529
717
|
const pt = await this.resolveTarget(target) // {x,y} | {mark:'M3'} | [x,y]
|
|
530
718
|
return this.actuate('click', {
|
|
531
719
|
x: pt.x, y: pt.y, button: opts.button || 'left', clicks: opts.clicks || 1,
|
|
532
720
|
settleMs: opts.settleMs ?? 40,
|
|
721
|
+
...(witness ? { verificationRegion: witness } : {}),
|
|
533
722
|
...((pt.expectHwnd || opts.expectHwnd) ? { expectHwnd: pt.expectHwnd || opts.expectHwnd } : {}),
|
|
534
723
|
})
|
|
535
724
|
}
|
|
@@ -537,7 +726,8 @@ export class ComputerUse extends EventEmitter {
|
|
|
537
726
|
async drag (from, to, opts = {}) {
|
|
538
727
|
const a = await this.resolveTarget(from)
|
|
539
728
|
const b = await this.resolveTarget(to)
|
|
540
|
-
return this.actuate('drag', { fromX: a.x, fromY: a.y, toX: b.x, toY: b.y, ...opts
|
|
729
|
+
return this.actuate('drag', { fromX: a.x, fromY: a.y, toX: b.x, toY: b.y, ...opts,
|
|
730
|
+
...((a.expectHwnd || opts.expectHwnd) ? { expectHwnd: a.expectHwnd || opts.expectHwnd } : {}) })
|
|
541
731
|
}
|
|
542
732
|
|
|
543
733
|
async scroll (direction, lines, opts = {}) {
|
|
@@ -554,7 +744,9 @@ export class ComputerUse extends EventEmitter {
|
|
|
554
744
|
// Anchor-based selection: click the START, scroll freely, then shift+click the END.
|
|
555
745
|
// Replaces drag-select, whose end point depends on drag duration and auto-scroll speed.
|
|
556
746
|
async shiftClick (x, y, opts = {}) {
|
|
747
|
+
const witness = verificationRegion(opts.verificationRegion)
|
|
557
748
|
return this.actuate('shiftClick', { x, y, clicks: opts.clicks || 1, settleMs: opts.settleMs ?? 90,
|
|
749
|
+
...(witness ? { verificationRegion: witness } : {}),
|
|
558
750
|
...(opts.expectHwnd ? { expectHwnd: opts.expectHwnd } : {}) })
|
|
559
751
|
}
|
|
560
752
|
|
|
@@ -576,7 +768,7 @@ export class ComputerUse extends EventEmitter {
|
|
|
576
768
|
* emergency brake. Called when the agent's turn ends, so a lingering blue border can never
|
|
577
769
|
* claim the machine is still under control after control actually stopped.
|
|
578
770
|
*/
|
|
579
|
-
async calm ({ beforeDispatch } = {}) { return this.
|
|
771
|
+
async calm ({ beforeDispatch } = {}) { return this.callInternal('calm', {}, DEFAULT_TIMEOUT_MS, true, beforeDispatch) }
|
|
580
772
|
|
|
581
773
|
/// **Ask the human one question, machine paused — the ONE question path.**
|
|
582
774
|
///
|
|
@@ -621,10 +813,17 @@ export class ComputerUse extends EventEmitter {
|
|
|
621
813
|
this._askOpen = null
|
|
622
814
|
}
|
|
623
815
|
const askId = b && b.askId
|
|
816
|
+
const askWorker = this.proc
|
|
817
|
+
const sameAskWorker = () => {
|
|
818
|
+
if (this.proc !== askWorker) throw Object.assign(new Error('question worker exited; its askId cannot release a replacement worker'), { code: 'worker-replaced' })
|
|
819
|
+
}
|
|
624
820
|
let answer = null
|
|
625
821
|
let rel = null
|
|
626
822
|
let via = 'ok'
|
|
627
823
|
try {
|
|
824
|
+
const context = this.inputContext.getStore()
|
|
825
|
+
if (context?.signal?.aborted) throw workerCallError('owning tool cancelled after the ask brake was engaged', 'INPUT_CANCELLED', 'beginAsk', null, 'unknown')
|
|
826
|
+
context?.assertOwnership?.()
|
|
628
827
|
answer = await opts.onAsk({ askId, hostRaised: !!(b && b.hostRaised), caretPlaced: !!(b && b.caretPlaced) })
|
|
629
828
|
// `via` is an AUDIT LABEL ONLY — it decides nothing. The pause decision is `keepPause`, and it
|
|
630
829
|
// comes from the human: ①让你停 keeps the brake, and every other ending (②放你走, a typed custom
|
|
@@ -649,14 +848,14 @@ export class ComputerUse extends EventEmitter {
|
|
|
649
848
|
// reply describes a machine that no longer exists and must not be applied on top of it.
|
|
650
849
|
const beforeRelease = this.stateSeq
|
|
651
850
|
try {
|
|
652
|
-
rel = await this.
|
|
851
|
+
rel = await this.callInternal('endAsk', { askId, via, keepPause }, END_ASK_DEADLINE_MS, /* killOnTimeout */ false, sameAskWorker)
|
|
653
852
|
} catch (e) {
|
|
654
853
|
// ONE retry: the first attempt may have timed out against a briefly busy worker. A second
|
|
655
854
|
// failure is not swallowed — the audit pair (ENGAGE-ASK with no ASK-RELEASE) is the signal,
|
|
656
855
|
// and the orphan sweep is the backstop. NEVER a kill here (killOnTimeout:false above): the
|
|
657
856
|
// dead worker's STOP file would outlive every waiter, which is the defect being removed.
|
|
658
857
|
try {
|
|
659
|
-
rel = await this.
|
|
858
|
+
rel = await this.callInternal('endAsk', { askId, via: via + '+retry', keepPause }, END_ASK_DEADLINE_MS, false, sameAskWorker)
|
|
660
859
|
} catch (e2) {
|
|
661
860
|
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)`)
|
|
662
861
|
}
|
|
@@ -726,7 +925,7 @@ export class ComputerUse extends EventEmitter {
|
|
|
726
925
|
const hwnd = fs.hwnd
|
|
727
926
|
if (!hwnd || (!fs.sig && !uiaOnly)) throw staleMark('window snapshot unavailable')
|
|
728
927
|
const cached = this.maps.get(String(hwnd))
|
|
729
|
-
const key = JSON.stringify([opts.depth ??
|
|
928
|
+
const key = JSON.stringify([opts.depth ?? 6, opts.maxNodes ?? 300, opts.maxMarks ?? 80, opts.minSize ?? 8, opts.role, opts.nameContains, uiaOnly])
|
|
730
929
|
if (cached && sameMarkFrame(cached.frame, fs) && cached.key === key && !opts.refresh) {
|
|
731
930
|
this.lastMarks = cached.marks
|
|
732
931
|
this.lastMarkFrame = { ...fs, step, insetPct }
|
|
@@ -734,8 +933,8 @@ export class ComputerUse extends EventEmitter {
|
|
|
734
933
|
}
|
|
735
934
|
const uia = await this.call('uia', {
|
|
736
935
|
hwnd: hwnd || undefined,
|
|
737
|
-
depth: opts.depth ??
|
|
738
|
-
maxNodes: opts.maxNodes ??
|
|
936
|
+
depth: opts.depth ?? 6,
|
|
937
|
+
maxNodes: opts.maxNodes ?? 300,
|
|
739
938
|
role: opts.role,
|
|
740
939
|
nameContains: opts.nameContains,
|
|
741
940
|
})
|
|
@@ -796,7 +995,7 @@ export class ComputerUse extends EventEmitter {
|
|
|
796
995
|
async calibrate (opts = {}) { return this.actuate('calibrate', opts) }
|
|
797
996
|
|
|
798
997
|
async key (combo, opts = {}) {
|
|
799
|
-
return this.actuate('key', { combo, holdMs: opts.holdMs ?? 60, ...(opts.expectHwnd ? { expectHwnd: opts.expectHwnd } : {}) })
|
|
998
|
+
return this.actuate('key', { combo: parseKeyCommand(combo).combo, holdMs: opts.holdMs ?? 60, ...(opts.expectHwnd ? { expectHwnd: opts.expectHwnd } : {}) })
|
|
800
999
|
}
|
|
801
1000
|
|
|
802
1001
|
async type (text, opts = {}) {
|
|
@@ -865,52 +1064,73 @@ export class ComputerUse extends EventEmitter {
|
|
|
865
1064
|
|
|
866
1065
|
/** Every state-changing op funnels through here for the audit trail. */
|
|
867
1066
|
async actuate (op, args) {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
this.
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
try {
|
|
883
|
-
const data = await this.call(op, args)
|
|
884
|
-
rec.result = data
|
|
885
|
-
return data
|
|
886
|
-
} catch (err) {
|
|
887
|
-
if (err.code === 'HUMAN_YIELD') {
|
|
888
|
-
// The hook's event can arrive just after its operation refusal. One cheap local
|
|
889
|
-
// status read closes that race; waiting spends no model requests and never replays input.
|
|
890
|
-
try {
|
|
891
|
-
await this.call('ping', {})
|
|
892
|
-
await this.waitForHuman()
|
|
893
|
-
this.inputContext.getStore()?.assertOwnership?.()
|
|
894
|
-
err = humanReobserveError({ ...err, op })
|
|
895
|
-
} catch (waitError) {
|
|
896
|
-
// Cancelling the local wait cannot erase input already accepted before handoff.
|
|
897
|
-
err = workerCallError(waitError.message || String(waitError), waitError.code || 'handoff-wait-failed',
|
|
898
|
-
op, err.requestId ?? null, err.outcome === 'not-dispatched' ? 'not-dispatched' : 'unknown')
|
|
1067
|
+
const parent = this.inputContext.getStore() || {}
|
|
1068
|
+
const rec = { attemptId: randomUUID(), requestId: null, op, args: redact(args),
|
|
1069
|
+
phase: 'started', ts: Date.now(), cycleId: parent.cycleId ?? null,
|
|
1070
|
+
sessionKey: parent.sessionKey ?? null, agentKey: parent.agentKey ?? null,
|
|
1071
|
+
workerGeneration: this.workerGeneration, runtime: this.runtime }
|
|
1072
|
+
this.emit('action', { ...rec })
|
|
1073
|
+
let settleAction
|
|
1074
|
+
this.activeActions.set(rec.attemptId, new Promise(resolve => { settleAction = resolve }))
|
|
1075
|
+
return this.inputContext.run({ ...parent, inputSeries: parent.inputSeries || randomUUID(), actionRecord: rec }, async () => {
|
|
1076
|
+
try {
|
|
1077
|
+
await this.waitInputReady()
|
|
1078
|
+
if (this.dryRun) {
|
|
1079
|
+
rec.dryRun = true; rec.outcome = 'not-dispatched'
|
|
1080
|
+
return { dryRun: true, op }
|
|
899
1081
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1082
|
+
const now = Date.now()
|
|
1083
|
+
this.rateWindow = this.rateWindow.filter(t => now - t < this.rateLimit.windowMs)
|
|
1084
|
+
if (this.rateWindow.length >= this.rateLimit.max) {
|
|
1085
|
+
throw workerCallError(`rate limit: ${this.rateLimit.max} actuations/${this.rateLimit.windowMs}ms exceeded`,
|
|
1086
|
+
'RATE_LIMITED', op, null, 'not-dispatched')
|
|
1087
|
+
}
|
|
1088
|
+
this.rateWindow.push(now)
|
|
1089
|
+
const data = await this.call(op, args)
|
|
1090
|
+
rec.result = auditReceipt(data); rec.outcome = 'delivered'
|
|
1091
|
+
return data
|
|
1092
|
+
} catch (initial) {
|
|
1093
|
+
let err = initial
|
|
1094
|
+
if (err.code === 'HUMAN_YIELD') {
|
|
1095
|
+
try {
|
|
1096
|
+
await this.call('ping', {})
|
|
1097
|
+
if (rec.workerGeneration && rec.workerGeneration !== this.workerGeneration) {
|
|
1098
|
+
throw workerReobserveError(this.workerGeneration, { ...err, op })
|
|
1099
|
+
}
|
|
1100
|
+
await this.waitForHuman()
|
|
1101
|
+
this.inputContext.getStore()?.assertOwnership?.()
|
|
1102
|
+
err = humanReobserveError({ ...err, op })
|
|
1103
|
+
} catch (waitError) {
|
|
1104
|
+
err = waitError.code === 'WORKER_REOBSERVE' ? waitError : Object.assign(workerCallError(waitError.detail || waitError.message || String(waitError), waitError.code || 'handoff-wait-failed',
|
|
1105
|
+
op, err.requestId ?? null, err.outcome === 'not-dispatched' ? 'not-dispatched' : 'unknown'),
|
|
1106
|
+
{ ...(waitError.handoffWait ? { handoffWait: waitError.handoffWait } : {}),
|
|
1107
|
+
...(waitError.cancelSource ? { cancelSource: waitError.cancelSource } : {}),
|
|
1108
|
+
...(waitError.cleanup ? { cleanup: waitError.cleanup } : {}) })
|
|
1109
|
+
}
|
|
906
1110
|
}
|
|
1111
|
+
if (err.code === 'HUMAN_REOBSERVE' || err.code === 'WORKER_REOBSERVE') {
|
|
1112
|
+
try { this.inputContext.getStore()?.assertOwnership?.() }
|
|
1113
|
+
catch (ownerError) {
|
|
1114
|
+
err = workerCallError(ownerError.message, ownerError.code || 'COMPUTER_OWNER_CHANGED', op,
|
|
1115
|
+
err.requestId ?? null, err.outcome)
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// UIA/native errors may echo names, values or typed text. The caller gets
|
|
1119
|
+
// the original exception; the persistent audit records a bounded code.
|
|
1120
|
+
rec.code = auditCode(err.code); rec.error = rec.code
|
|
1121
|
+
rec.outcome = err.outcome || 'unknown'
|
|
1122
|
+
if (err.requestId != null) rec.requestId = err.requestId
|
|
1123
|
+
if (err.cleanup) rec.cleanup = err.cleanup
|
|
1124
|
+
if (err.handoffWait) rec.handoffWait = err.handoffWait
|
|
1125
|
+
throw err
|
|
1126
|
+
} finally {
|
|
1127
|
+
rec.phase = 'settled'; rec.settledAt = Date.now(); rec.elapsedMs = rec.settledAt - rec.ts
|
|
1128
|
+
try { this.emit('action:settled', { ...rec }) }
|
|
1129
|
+
finally { this.activeActions.delete(rec.attemptId); settleAction() }
|
|
907
1130
|
}
|
|
908
|
-
|
|
909
|
-
throw err
|
|
910
|
-
} finally {
|
|
911
|
-
this.emit('action:settled', rec)
|
|
912
|
-
}
|
|
1131
|
+
})
|
|
913
1132
|
}
|
|
1133
|
+
|
|
914
1134
|
}
|
|
915
1135
|
|
|
916
1136
|
function staleMark (reason) {
|
|
@@ -930,10 +1150,25 @@ function sameMarkFrame (a, b) {
|
|
|
930
1150
|
|
|
931
1151
|
/** Keep secrets out of the audit log. */
|
|
932
1152
|
function redact (args) {
|
|
933
|
-
if (args
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1153
|
+
if (!args || typeof args !== 'object') return {}
|
|
1154
|
+
const safeStrings = new Set(['combo', 'button', 'direction', 'action', 'mode', 'preset', 'property'])
|
|
1155
|
+
return Object.fromEntries(Object.entries(args).map(([key, value]) => [key,
|
|
1156
|
+
typeof value === 'string' ? safeStrings.has(key) ? value.slice(0, 128) : `<${value.length} chars>` :
|
|
1157
|
+
typeof value === 'number' || typeof value === 'boolean' || value === null ? value : '<structured value>']))
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function auditCode (code) {
|
|
1161
|
+
return typeof code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(code) ? code : 'action-error'
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Explicit receipt allowlist: never persist screenshots, focus/UIA objects,
|
|
1165
|
+
// clipboard content, passwords, arbitrary strings or provider result trees.
|
|
1166
|
+
function auditReceipt (data) {
|
|
1167
|
+
if (!data || typeof data !== 'object') return {}
|
|
1168
|
+
const keys = ['ok', 'delivered', 'count', 'chars', 'typed', 'clicked', 'moved', 'settled',
|
|
1169
|
+
'elapsedMs', 'durationMs', 'hwnd', 'x', 'y', 'width', 'height', 'dryRun']
|
|
1170
|
+
return Object.fromEntries(keys.filter(key => typeof data[key] === 'boolean' ||
|
|
1171
|
+
(typeof data[key] === 'number' && Number.isFinite(data[key]))).map(key => [key, data[key]]))
|
|
937
1172
|
}
|
|
938
1173
|
|
|
939
1174
|
/**
|
|
@@ -946,6 +1181,7 @@ function buildMarks (flat, maxMarks = 60, minSize = 8) {
|
|
|
946
1181
|
const interactive = []
|
|
947
1182
|
for (const e of flat) {
|
|
948
1183
|
if (interactive.length >= maxMarks) break
|
|
1184
|
+
if (e.isOffscreen === true || e.intersectsVirtualScreen === false) continue
|
|
949
1185
|
const r = e.rect || {}
|
|
950
1186
|
const name = (e.name || '').replace(/\s*\r\n\s*/g, ' | ').trim()
|
|
951
1187
|
const key = `${e.role}|${name}|${r.x},${r.y}`
|