dsh-codex-style-computer-use 1.3.1 → 2.1.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.
Files changed (46) hide show
  1. package/README.ar.md +234 -0
  2. package/README.de.md +234 -0
  3. package/README.es.md +264 -0
  4. package/README.fa.md +234 -0
  5. package/README.fr.md +243 -0
  6. package/README.md +72 -17
  7. package/README.pt.md +234 -0
  8. package/README.ru.md +259 -0
  9. package/README.zh.md +66 -16
  10. package/icon.svg +20 -4
  11. package/lib/audit.js +7 -0
  12. package/lib/batch.js +14 -19
  13. package/lib/bin/attention-worker.exe +0 -0
  14. package/lib/bin/attention-worker.exe.json +1 -1
  15. package/lib/compact-feedback.js +175 -0
  16. package/lib/continuation.js +1 -1
  17. package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
  18. package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
  19. package/lib/core/index.js +403 -136
  20. package/lib/core/native-runtime.js +17 -0
  21. package/lib/core/worker.cs +2160 -229
  22. package/lib/feedback-routes.json +11 -0
  23. package/lib/feedback.js +49 -16
  24. package/lib/image-output.js +87 -17
  25. package/lib/index.js +25 -7
  26. package/lib/key-command.js +37 -0
  27. package/lib/launch.js +67 -0
  28. package/lib/media-contract.js +43 -0
  29. package/lib/observations.js +267 -13
  30. package/lib/policy.js +29 -9
  31. package/lib/tool-contract.js +56 -0
  32. package/lib/tools.js +203 -52
  33. package/lib/verification-region.js +17 -0
  34. package/mcp/package.json +2 -1
  35. package/mcp/src/execution.js +8 -5
  36. package/mcp/src/index.js +16 -267
  37. package/mcp/src/tools.js +271 -0
  38. package/package.json +6 -1
  39. package/scripts/check-behavior.mjs +83 -0
  40. package/scripts/measure-tool-tokens.mjs +21 -0
  41. package/scripts/verify-package.mjs +9 -0
  42. package/skills/computer-use/SKILL.md +55 -43
  43. package/skills/computer-use/references/lifecycle.md +11 -7
  44. package/skills/computer-use/references/maintenance.md +9 -5
  45. package/skills/computer-use/references/precision.md +39 -14
  46. 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
- const env = { ...process.env, DSH_CU_HOST_PID: String(process.pid), DSH_CU_HOST_EXE: process.execPath || '' }
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 t = setTimeout(() => reject(new Error('worker ready timeout')), 10_000)
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
- reject(new Error(`worker could not be started: code=${e.code} syscall=${e.syscall} path=${e.path || this.workerExe}`))
184
- })
185
- this.once('ready', () => { clearTimeout(t); resolve() })
186
- this.once('dead', (code) => { clearTimeout(t); reject(new Error(`worker died at startup (code ${code})`)) })
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.cooperation = msg.cooperation || { phase: 'idle', active: false, revision: 0 }
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 context = input ? this.inputContext.getStore() : undefined
371
+ const priorGeneration = this.workerGeneration
372
+ const invalidAtStart = this.workerStateInvalid
373
+ const context = this.inputContext.getStore()
279
374
  const signal = context?.signal
280
- if (signal?.aborted) throw workerCallError('owning tool cancelled', 'INPUT_CANCELLED', op, null, 'not-dispatched')
281
- beforeDispatch?.()
282
- context?.assertOwnership?.()
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
- if (signal?.aborted) throw workerCallError('owning tool cancelled', 'INPUT_CANCELLED', op, null, 'not-dispatched')
294
- beforeDispatch?.()
295
- context?.assertOwnership?.()
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
- // `__cycle` is this layer's SIGNATURE on a call made on the agent's behalf, and the worker
298
- // opens (or keeps lit) a computer-use CYCLE only for signed calls. A one-shot
299
- // `worker.exe --op click` from a shell script, or a deploy-time health probe, carries no
300
- // signature — and so can never resurrect a session the human ended with Ctrl+Alt+Q. Stamped
301
- // LAST so an op's own args can never forge it.
302
- const signed = { ...args, __cycle: true }
303
- signed.__kickoff = this.kickoffRequired
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 abort = () => {
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
- if (written) void this.call('cancelInput', { requestId: id }, 2000, false).catch(() => {})
315
- reject(workerCallError('owning tool cancelled; accepted input must be inspected before any retry',
316
- 'INPUT_CANCELLED', op, id, written ? 'unknown' : 'not-dispatched'))
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
- this.pending.delete(id)
320
- cleanup()
321
- if (read && this.uiaRead === read) {
322
- read.timedOut = true
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
- written = true
357
- child.stdin.write(JSON.stringify({ id, op, args: signed }) + '\r\n')
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
- if (!this.proc) return
397
- try { await this.call('abort', {}, 1000) } catch { /* best effort */ }
398
- try { this.proc.kill() } catch { /* already gone */ }
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 || Number(state.revision) < Number(this.cooperation.revision)) return
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, inputSeries: randomUUID() }, fn)
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) => { cleanup(); if (error) reject(error); else resolve() }
422
- const cancelled = () => finish(workerCallError('waiting for the human was cancelled', 'INPUT_CANCELLED', null, null, 'not-dispatched'))
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 ?? 14, maxNodes: opts.uiaMaxNodes ?? 1500 })
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,29 @@ 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, state: { active: state.active, cursor: state.cursor }, ts: Date.now() }
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 } : {}),
722
+ // Embedded renderers (a WeChat article panel, an Electron child) belong to a DIFFERENT
723
+ // process than the window that hosts them, so the occlusion check reads them as "covered"
724
+ // even though the pixel is inside the app the caller activated. The worker already
725
+ // implements the escape hatch; forward it, or a whole class of real apps is unclickable.
726
+ ...(opts.allowCovered ? { allowCovered: true } : {}),
533
727
  ...((pt.expectHwnd || opts.expectHwnd) ? { expectHwnd: pt.expectHwnd || opts.expectHwnd } : {}),
534
728
  })
535
729
  }
@@ -537,7 +731,8 @@ export class ComputerUse extends EventEmitter {
537
731
  async drag (from, to, opts = {}) {
538
732
  const a = await this.resolveTarget(from)
539
733
  const b = await this.resolveTarget(to)
540
- return this.actuate('drag', { fromX: a.x, fromY: a.y, toX: b.x, toY: b.y, ...opts })
734
+ return this.actuate('drag', { fromX: a.x, fromY: a.y, toX: b.x, toY: b.y, ...opts,
735
+ ...((a.expectHwnd || opts.expectHwnd) ? { expectHwnd: a.expectHwnd || opts.expectHwnd } : {}) })
541
736
  }
542
737
 
543
738
  async scroll (direction, lines, opts = {}) {
@@ -554,7 +749,9 @@ export class ComputerUse extends EventEmitter {
554
749
  // Anchor-based selection: click the START, scroll freely, then shift+click the END.
555
750
  // Replaces drag-select, whose end point depends on drag duration and auto-scroll speed.
556
751
  async shiftClick (x, y, opts = {}) {
752
+ const witness = verificationRegion(opts.verificationRegion)
557
753
  return this.actuate('shiftClick', { x, y, clicks: opts.clicks || 1, settleMs: opts.settleMs ?? 90,
754
+ ...(witness ? { verificationRegion: witness } : {}),
558
755
  ...(opts.expectHwnd ? { expectHwnd: opts.expectHwnd } : {}) })
559
756
  }
560
757
 
@@ -576,7 +773,7 @@ export class ComputerUse extends EventEmitter {
576
773
  * emergency brake. Called when the agent's turn ends, so a lingering blue border can never
577
774
  * claim the machine is still under control after control actually stopped.
578
775
  */
579
- async calm ({ beforeDispatch } = {}) { return this.call('calm', {}, DEFAULT_TIMEOUT_MS, true, beforeDispatch) }
776
+ async calm ({ beforeDispatch } = {}) { return this.callInternal('calm', {}, DEFAULT_TIMEOUT_MS, true, beforeDispatch) }
580
777
 
581
778
  /// **Ask the human one question, machine paused — the ONE question path.**
582
779
  ///
@@ -621,10 +818,17 @@ export class ComputerUse extends EventEmitter {
621
818
  this._askOpen = null
622
819
  }
623
820
  const askId = b && b.askId
821
+ const askWorker = this.proc
822
+ const sameAskWorker = () => {
823
+ if (this.proc !== askWorker) throw Object.assign(new Error('question worker exited; its askId cannot release a replacement worker'), { code: 'worker-replaced' })
824
+ }
624
825
  let answer = null
625
826
  let rel = null
626
827
  let via = 'ok'
627
828
  try {
829
+ const context = this.inputContext.getStore()
830
+ if (context?.signal?.aborted) throw workerCallError('owning tool cancelled after the ask brake was engaged', 'INPUT_CANCELLED', 'beginAsk', null, 'unknown')
831
+ context?.assertOwnership?.()
628
832
  answer = await opts.onAsk({ askId, hostRaised: !!(b && b.hostRaised), caretPlaced: !!(b && b.caretPlaced) })
629
833
  // `via` is an AUDIT LABEL ONLY — it decides nothing. The pause decision is `keepPause`, and it
630
834
  // comes from the human: ①让你停 keeps the brake, and every other ending (②放你走, a typed custom
@@ -649,14 +853,14 @@ export class ComputerUse extends EventEmitter {
649
853
  // reply describes a machine that no longer exists and must not be applied on top of it.
650
854
  const beforeRelease = this.stateSeq
651
855
  try {
652
- rel = await this.call('endAsk', { askId, via, keepPause }, END_ASK_DEADLINE_MS, /* killOnTimeout */ false)
856
+ rel = await this.callInternal('endAsk', { askId, via, keepPause }, END_ASK_DEADLINE_MS, /* killOnTimeout */ false, sameAskWorker)
653
857
  } catch (e) {
654
858
  // ONE retry: the first attempt may have timed out against a briefly busy worker. A second
655
859
  // failure is not swallowed — the audit pair (ENGAGE-ASK with no ASK-RELEASE) is the signal,
656
860
  // and the orphan sweep is the backstop. NEVER a kill here (killOnTimeout:false above): the
657
861
  // dead worker's STOP file would outlive every waiter, which is the defect being removed.
658
862
  try {
659
- rel = await this.call('endAsk', { askId, via: via + '+retry', keepPause }, END_ASK_DEADLINE_MS, false)
863
+ rel = await this.callInternal('endAsk', { askId, via: via + '+retry', keepPause }, END_ASK_DEADLINE_MS, false, sameAskWorker)
660
864
  } catch (e2) {
661
865
  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
866
  }
@@ -726,7 +930,7 @@ export class ComputerUse extends EventEmitter {
726
930
  const hwnd = fs.hwnd
727
931
  if (!hwnd || (!fs.sig && !uiaOnly)) throw staleMark('window snapshot unavailable')
728
932
  const cached = this.maps.get(String(hwnd))
729
- const key = JSON.stringify([opts.depth ?? 14, opts.maxNodes ?? 1500, opts.maxMarks ?? 80, opts.minSize ?? 8, opts.role, opts.nameContains, uiaOnly])
933
+ const key = JSON.stringify([opts.depth ?? 6, opts.maxNodes ?? 300, opts.maxMarks ?? 80, opts.minSize ?? 8, opts.role, opts.nameContains, uiaOnly])
730
934
  if (cached && sameMarkFrame(cached.frame, fs) && cached.key === key && !opts.refresh) {
731
935
  this.lastMarks = cached.marks
732
936
  this.lastMarkFrame = { ...fs, step, insetPct }
@@ -734,8 +938,8 @@ export class ComputerUse extends EventEmitter {
734
938
  }
735
939
  const uia = await this.call('uia', {
736
940
  hwnd: hwnd || undefined,
737
- depth: opts.depth ?? 14,
738
- maxNodes: opts.maxNodes ?? 1500,
941
+ depth: opts.depth ?? 6,
942
+ maxNodes: opts.maxNodes ?? 300,
739
943
  role: opts.role,
740
944
  nameContains: opts.nameContains,
741
945
  })
@@ -796,7 +1000,7 @@ export class ComputerUse extends EventEmitter {
796
1000
  async calibrate (opts = {}) { return this.actuate('calibrate', opts) }
797
1001
 
798
1002
  async key (combo, opts = {}) {
799
- return this.actuate('key', { combo, holdMs: opts.holdMs ?? 60, ...(opts.expectHwnd ? { expectHwnd: opts.expectHwnd } : {}) })
1003
+ return this.actuate('key', { combo: parseKeyCommand(combo).combo, holdMs: opts.holdMs ?? 60, ...(opts.expectHwnd ? { expectHwnd: opts.expectHwnd } : {}) })
800
1004
  }
801
1005
 
802
1006
  async type (text, opts = {}) {
@@ -832,6 +1036,32 @@ export class ComputerUse extends EventEmitter {
832
1036
  async ocr (region) { return this.call('ocr', region ? { ...region } : {}) }
833
1037
  async clipWrite (text) { return this.actuate('clipWrite', { text }) }
834
1038
 
1039
+ /** Open an app/URL/folder/document through ShellExecute, as a real actuation: the halo,
1040
+ * the brake checks, the audit trail and the rate window all come from actuate() — which
1041
+ * is exactly why this is an op and not a Node-side spawn (see worker.cs Launch). */
1042
+ async launch (target, opts = {}) {
1043
+ // `args` is an ARRAY of argument values (quoted once, in the worker). Passing a ready-made
1044
+ // command line would make the launcher parse our boundaries differently than the caller meant.
1045
+ return this.actuate('launch', {
1046
+ target,
1047
+ ...(opts.args !== undefined ? { args: opts.args } : {}),
1048
+ ...(opts.tailMs !== undefined ? { tailMs: opts.tailMs } : {}),
1049
+ })
1050
+ }
1051
+
1052
+ /** Put keyboard focus on a FIELD and prove it: clicks (x,y), then reports the UIA focused
1053
+ * element and whether a caret is provable there (OS caret, else a two-frame blink check).
1054
+ * Refuse to type when `verified` is false — typing follows the focused control, not the
1055
+ * window, and a document that kept focus silently swallows the text. */
1056
+ async focusField (opts = {}) {
1057
+ return this.actuate('focusField', {
1058
+ x: opts.x,
1059
+ y: opts.y,
1060
+ ...(opts.hwnd !== undefined ? { hwnd: opts.hwnd } : {}),
1061
+ ...(opts.verifyCaret !== undefined ? { verifyCaret: opts.verifyCaret } : {}),
1062
+ })
1063
+ }
1064
+
835
1065
  async activateWindow (sel) { return this.actuate('activate', sel) }
836
1066
  async windowOp (sel, op, opts = {}) { return this.actuate('windowOp', { ...sel, op, ...opts }) }
837
1067
  /** Deterministic placement (no mouse): move/resize a window to an arbitrary rect. */
@@ -865,52 +1095,73 @@ export class ComputerUse extends EventEmitter {
865
1095
 
866
1096
  /** Every state-changing op funnels through here for the audit trail. */
867
1097
  async actuate (op, args) {
868
- await this.waitInputReady()
869
- if (this.dryRun) {
870
- const rec = { op, args: redact(args), ts: Date.now(), dryRun: true }
871
- this.emit('action', rec)
872
- return { dryRun: true, op }
873
- }
874
- const now = Date.now()
875
- this.rateWindow = this.rateWindow.filter(t => now - t < this.rateLimit.windowMs)
876
- if (this.rateWindow.length >= this.rateLimit.max) {
877
- throw new Error(`rate limit: ${this.rateLimit.max} actuations/${this.rateLimit.windowMs}ms exceeded`)
878
- }
879
- this.rateWindow.push(now)
880
- const rec = { op, args: redact(args), ts: Date.now() }
881
- this.emit('action', rec)
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')
1098
+ const parent = this.inputContext.getStore() || {}
1099
+ const rec = { attemptId: randomUUID(), requestId: null, op, args: redact(args),
1100
+ phase: 'started', ts: Date.now(), cycleId: parent.cycleId ?? null,
1101
+ sessionKey: parent.sessionKey ?? null, agentKey: parent.agentKey ?? null,
1102
+ workerGeneration: this.workerGeneration, runtime: this.runtime }
1103
+ this.emit('action', { ...rec })
1104
+ let settleAction
1105
+ this.activeActions.set(rec.attemptId, new Promise(resolve => { settleAction = resolve }))
1106
+ return this.inputContext.run({ ...parent, inputSeries: parent.inputSeries || randomUUID(), actionRecord: rec }, async () => {
1107
+ try {
1108
+ await this.waitInputReady()
1109
+ if (this.dryRun) {
1110
+ rec.dryRun = true; rec.outcome = 'not-dispatched'
1111
+ return { dryRun: true, op }
899
1112
  }
900
- }
901
- if (err.code === 'HUMAN_REOBSERVE') {
902
- try { this.inputContext.getStore()?.assertOwnership?.() }
903
- catch (ownerError) {
904
- err = workerCallError(ownerError.message, ownerError.code || 'COMPUTER_OWNER_CHANGED', op,
905
- err.requestId ?? null, err.outcome)
1113
+ const now = Date.now()
1114
+ this.rateWindow = this.rateWindow.filter(t => now - t < this.rateLimit.windowMs)
1115
+ if (this.rateWindow.length >= this.rateLimit.max) {
1116
+ throw workerCallError(`rate limit: ${this.rateLimit.max} actuations/${this.rateLimit.windowMs}ms exceeded`,
1117
+ 'RATE_LIMITED', op, null, 'not-dispatched')
1118
+ }
1119
+ this.rateWindow.push(now)
1120
+ const data = await this.call(op, args)
1121
+ rec.result = auditReceipt(data); rec.outcome = 'delivered'
1122
+ return data
1123
+ } catch (initial) {
1124
+ let err = initial
1125
+ if (err.code === 'HUMAN_YIELD') {
1126
+ try {
1127
+ await this.call('ping', {})
1128
+ if (rec.workerGeneration && rec.workerGeneration !== this.workerGeneration) {
1129
+ throw workerReobserveError(this.workerGeneration, { ...err, op })
1130
+ }
1131
+ await this.waitForHuman()
1132
+ this.inputContext.getStore()?.assertOwnership?.()
1133
+ err = humanReobserveError({ ...err, op })
1134
+ } catch (waitError) {
1135
+ err = waitError.code === 'WORKER_REOBSERVE' ? waitError : Object.assign(workerCallError(waitError.detail || waitError.message || String(waitError), waitError.code || 'handoff-wait-failed',
1136
+ op, err.requestId ?? null, err.outcome === 'not-dispatched' ? 'not-dispatched' : 'unknown'),
1137
+ { ...(waitError.handoffWait ? { handoffWait: waitError.handoffWait } : {}),
1138
+ ...(waitError.cancelSource ? { cancelSource: waitError.cancelSource } : {}),
1139
+ ...(waitError.cleanup ? { cleanup: waitError.cleanup } : {}) })
1140
+ }
906
1141
  }
1142
+ if (err.code === 'HUMAN_REOBSERVE' || err.code === 'WORKER_REOBSERVE') {
1143
+ try { this.inputContext.getStore()?.assertOwnership?.() }
1144
+ catch (ownerError) {
1145
+ err = workerCallError(ownerError.message, ownerError.code || 'COMPUTER_OWNER_CHANGED', op,
1146
+ err.requestId ?? null, err.outcome)
1147
+ }
1148
+ }
1149
+ // UIA/native errors may echo names, values or typed text. The caller gets
1150
+ // the original exception; the persistent audit records a bounded code.
1151
+ rec.code = auditCode(err.code); rec.error = rec.code
1152
+ rec.outcome = err.outcome || 'unknown'
1153
+ if (err.requestId != null) rec.requestId = err.requestId
1154
+ if (err.cleanup) rec.cleanup = err.cleanup
1155
+ if (err.handoffWait) rec.handoffWait = err.handoffWait
1156
+ throw err
1157
+ } finally {
1158
+ rec.phase = 'settled'; rec.settledAt = Date.now(); rec.elapsedMs = rec.settledAt - rec.ts
1159
+ try { this.emit('action:settled', { ...rec }) }
1160
+ finally { this.activeActions.delete(rec.attemptId); settleAction() }
907
1161
  }
908
- rec.error = String(err.message || err)
909
- throw err
910
- } finally {
911
- this.emit('action:settled', rec)
912
- }
1162
+ })
913
1163
  }
1164
+
914
1165
  }
915
1166
 
916
1167
  function staleMark (reason) {
@@ -930,10 +1181,25 @@ function sameMarkFrame (a, b) {
930
1181
 
931
1182
  /** Keep secrets out of the audit log. */
932
1183
  function redact (args) {
933
- if (args && typeof args.text === 'string' && args.text.length > 0) {
934
- return { ...args, text: `<${args.text.length} chars>` }
935
- }
936
- return args
1184
+ if (!args || typeof args !== 'object') return {}
1185
+ const safeStrings = new Set(['combo', 'button', 'direction', 'action', 'mode', 'preset', 'property'])
1186
+ return Object.fromEntries(Object.entries(args).map(([key, value]) => [key,
1187
+ typeof value === 'string' ? safeStrings.has(key) ? value.slice(0, 128) : `<${value.length} chars>` :
1188
+ typeof value === 'number' || typeof value === 'boolean' || value === null ? value : '<structured value>']))
1189
+ }
1190
+
1191
+ function auditCode (code) {
1192
+ return typeof code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(code) ? code : 'action-error'
1193
+ }
1194
+
1195
+ // Explicit receipt allowlist: never persist screenshots, focus/UIA objects,
1196
+ // clipboard content, passwords, arbitrary strings or provider result trees.
1197
+ function auditReceipt (data) {
1198
+ if (!data || typeof data !== 'object') return {}
1199
+ const keys = ['ok', 'delivered', 'count', 'chars', 'typed', 'clicked', 'moved', 'settled',
1200
+ 'elapsedMs', 'durationMs', 'hwnd', 'x', 'y', 'width', 'height', 'dryRun']
1201
+ return Object.fromEntries(keys.filter(key => typeof data[key] === 'boolean' ||
1202
+ (typeof data[key] === 'number' && Number.isFinite(data[key]))).map(key => [key, data[key]]))
937
1203
  }
938
1204
 
939
1205
  /**
@@ -946,6 +1212,7 @@ function buildMarks (flat, maxMarks = 60, minSize = 8) {
946
1212
  const interactive = []
947
1213
  for (const e of flat) {
948
1214
  if (interactive.length >= maxMarks) break
1215
+ if (e.isOffscreen === true || e.intersectsVirtualScreen === false) continue
949
1216
  const r = e.rect || {}
950
1217
  const name = (e.name || '').replace(/\s*\r\n\s*/g, ' | ').trim()
951
1218
  const key = `${e.role}|${name}|${r.x},${r.y}`