dsh-codex-style-computer-use 1.3.0 → 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.
Files changed (41) hide show
  1. package/README.md +53 -22
  2. package/README.zh.md +46 -21
  3. package/icon.svg +29 -0
  4. package/lib/audit.js +7 -0
  5. package/lib/batch.js +14 -19
  6. package/lib/bin/attention-worker.exe +0 -0
  7. package/lib/bin/attention-worker.exe.json +1 -1
  8. package/lib/compact-feedback.js +175 -0
  9. package/lib/continuation.js +1 -1
  10. package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
  11. package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
  12. package/lib/core/compile-worker.ps1 +5 -1
  13. package/lib/core/index.js +452 -129
  14. package/lib/core/native-runtime.js +23 -1
  15. package/lib/core/winmd/Windows.winmd +0 -0
  16. package/lib/core/worker.cs +2415 -356
  17. package/lib/feedback-routes.json +11 -0
  18. package/lib/feedback.js +49 -16
  19. package/lib/image-output.js +59 -11
  20. package/lib/index.js +42 -7
  21. package/lib/key-command.js +37 -0
  22. package/lib/media-contract.js +43 -0
  23. package/lib/observations.js +267 -13
  24. package/lib/policy.js +30 -10
  25. package/lib/tool-contract.js +56 -0
  26. package/lib/tools.js +174 -51
  27. package/lib/verification-region.js +17 -0
  28. package/mcp/package.json +2 -1
  29. package/mcp/src/execution.js +8 -5
  30. package/mcp/src/index.js +16 -267
  31. package/mcp/src/tools.js +271 -0
  32. package/package.json +5 -2
  33. package/scripts/check-behavior.mjs +83 -0
  34. package/scripts/measure-tool-tokens.mjs +21 -0
  35. package/scripts/render-promo.py +81 -0
  36. package/skills/computer-use/SKILL.md +35 -8
  37. package/skills/computer-use/references/lifecycle.md +11 -7
  38. package/skills/computer-use/references/maintenance.md +9 -5
  39. package/skills/computer-use/references/precision.md +39 -14
  40. package/skills/computer-use/references/progressive-control.md +10 -4
  41. package/skills/computer-use/references/zero-uia-apps.md +54 -0
package/lib/tools.js CHANGED
@@ -17,10 +17,12 @@
17
17
 
18
18
  import { runBatch, actionRefusal } from './batch.js'
19
19
  import { saveOwnedShot } from './artifacts.js'
20
- import { attachScreenshot, renderScreenshot, selectBatchScreenshot } from './image-output.js'
20
+ import { attachScreenshot, renderScreenshot, selectBatchScreenshot, screenshotMetadata } from './image-output.js'
21
21
  import { ObservationStore } from './observations.js'
22
- import { feedbackMode, feedbackActions, feedbackVerification } from './feedback.js'
22
+ import { feedbackPolicy, feedbackActions, feedbackVerification, selectFeedbackDetail } from './feedback.js'
23
+ import { collectCompactState, limitCompactFeedback, feedbackViewport } from './compact-feedback.js'
23
24
  import { handoffResult } from './continuation.js'
25
+ import { join } from 'node:path'
24
26
 
25
27
  const P = (type, description, extra = {}) => ({ type, description, ...extra })
26
28
  const cooperativeTools = new Set(['computer_click', 'computer_move', 'computer_drag', 'computer_scroll',
@@ -29,6 +31,11 @@ const usesInput = (name, args = {}) => name === 'computer_clip' ? args.text !==
29
31
  name === 'computer_batch' ? (args.actions || []).some(step => usesInput(step.tool, step.args)) :
30
32
  cooperativeTools.has(name)
31
33
 
34
+ export const isCycleStateOnly = (name, args = {}) => name === 'computer_ctrl'
35
+ ? ['hold', 'free', 'release', 'clear_cache'].includes(args?.action)
36
+ : name === 'computer_batch' && Array.isArray(args?.actions) && args.actions.length > 0 &&
37
+ args.actions.every(step => isCycleStateOnly(step?.tool, step?.args))
38
+
32
39
  /** "M3" | "x,y" | "x y" -> {mark:'M3'} | {x,y}. One parameter instead of four. */
33
40
  const pt = (v) => {
34
41
  if (v === undefined || v === null) return null
@@ -49,7 +56,18 @@ const rect = (v) => {
49
56
  }
50
57
 
51
58
  export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, defineTool, hooks, ensureCycle, register }) {
52
- const observations = new ObservationStore()
59
+ const observations = new ObservationStore({ archiveDir: typeof dataDir?.root === 'string' ? join(dataDir.root, 'observation-cache') : undefined })
60
+ let observationEpoch
61
+ const syncObservationEpoch = () => {
62
+ const epoch = JSON.stringify([cu.workerGeneration ?? null, cu.cooperation?.revision ?? null, cu.stateSeq ?? 0])
63
+ if (observationEpoch !== undefined && observationEpoch !== epoch) observations.invalidate('worker-or-human-state-changed')
64
+ observationEpoch = epoch
65
+ }
66
+ const archiveFeedback = (exec, raw) => {
67
+ syncObservationEpoch()
68
+ const saved = observations.publish(exec, 'state', ['post-action', raw.point?.x, raw.point?.y], raw, undefined, { archive: true })
69
+ return { snapshot: saved.observation, archive: saved.archive }
70
+ }
53
71
  const saveShot = (buf, ext = 'jpg') => {
54
72
  const p = snapshotPath(dataDir).replace(/\.jpg$/, `.${ext}`)
55
73
  return saveOwnedShot(dataDir, p, buf)
@@ -64,9 +82,8 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
64
82
  maxWidth: r && r.width <= 900 ? r.width : 1280,
65
83
  })
66
84
  const path = saveShot(shot.image, shot.mime === 'image/png' ? 'png' : 'jpg')
67
- const res = { path, mime: shot.mime, region: shot.region }
68
- if (typeof shot.settled === 'boolean') res.settled = shot.settled
69
- return attachScreenshot(ctx, exec, res, shot.image)
85
+ const res = { path, mime: shot.mime, region: shot.region, ...screenshotMetadata(shot) }
86
+ return attachScreenshot(ctx, exec, res, shot.image, { rules: config.feedbackRules })
70
87
  }
71
88
 
72
89
  const clickTarget = async (args) => {
@@ -81,26 +98,30 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
81
98
  }
82
99
 
83
100
  const queryUia = async (args, exec) => {
84
- if (args.snapshot) return observations.get(exec, args.snapshot, 'uia')
101
+ if (args.snapshot) return observations.get(exec, args.snapshot, 'uia', { offset: args.offset, limit: args.limit })
85
102
  const p = pt(args.at)
86
103
  if (p && p.x !== undefined) return cu.call('uiaFromPoint', { x: p.x, y: p.y })
87
104
  // Identity witnesses help an intended action, but are expensive for a broad inventory.
88
105
  const targets = !!args.hwnd && (args.targets ?? !!(args.id || args.name))
89
- const res = await cu.call('uia', { hwnd: args.hwnd, depth: args.depth ?? 16, maxNodes: 3000,
106
+ const res = await cu.call('uia', { hwnd: args.hwnd, depth: args.depth ?? 6, maxNodes: args.maxNodes ?? 120,
107
+ budgetMs: args.budgetMs ?? 1200, anchor: args.anchor,
90
108
  query: true, targets, role: args.role, nameContains: args.name, automationId: args.id, limit: args.limit ?? 80 })
91
- return observations.publish(exec, 'uia', [args.hwnd ?? null, args.depth ?? 16, args.role ?? null, args.name ?? null, args.id ?? null, args.limit ?? 80, targets],
92
- { count: res.count, elements: res.flat || [], scanned: res.scanned, truncated: res.truncated, hwnd: res.hwnd ?? args.hwnd, pid: res.pid }, args.since)
109
+ syncObservationEpoch()
110
+ return observations.publish(exec, 'uia', [args.hwnd ?? null, args.depth ?? 6, args.maxNodes ?? 120, args.budgetMs ?? 1200, args.anchor ?? null, args.role ?? null, args.name ?? null, args.id ?? null, args.limit ?? 80, targets],
111
+ { count: res.count, elements: res.flat || [], scanned: res.scanned, truncated: res.truncated, budget: res.budget, hwnd: res.hwnd ?? args.hwnd, pid: res.pid }, args.since)
93
112
  }
94
113
  const actUia = (args, exec) => {
95
114
  if (args.target && !observations.ownsTarget(exec, args.target)) {
96
115
  throw Object.assign(new Error('TARGET_NOT_OBSERVED: obtain a fresh computer_uia target in this Agent before acting'), { code: 'TARGET_NOT_OBSERVED', outcome: 'not-dispatched' })
97
116
  }
98
117
  return cu.uiaAct({ hwnd: args.hwnd, nameContains: args.name, automationId: args.id, target: args.target, allowRebind: args.rebind === true,
99
- role: args.role, action: args.action, value: args.value, requireUnique: true, depth: args.depth ?? 24, maxNodes: 3500 })
118
+ role: args.role, action: args.action, value: args.value, requireUnique: true,
119
+ depth: args.depth ?? 6, maxNodes: 300, anchor: args.anchor, budgetMs: args.budgetMs ?? 1200 })
100
120
  }
101
121
  const observeState = async (args, exec) => {
102
- if (args.snapshot) return observations.get(exec, args.snapshot, 'state')
122
+ if (args.snapshot) return observations.get(exec, args.snapshot, 'state', { offset: args.offset, limit: args.limit })
103
123
  const result = args.windows ? await cu.call('windows', {}) : await cu.screenState({ uiaDepth: args.depth })
124
+ syncObservationEpoch()
104
125
  return observations.publish(exec, 'state', [!!args.windows, args.depth ?? 6], result, args.since)
105
126
  }
106
127
 
@@ -128,28 +149,46 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
128
149
  if (exec?.signal?.aborted || cu.stopped || cu.exited || cu.cooperation?.active) return true
129
150
  try { return !!hooks?.askBlocked?.('computer_shot') } catch { return true }
130
151
  }
131
- const captureFeedback = async (exec, mode) => {
152
+ const captureFeedback = async (exec, policy, context = {}) => {
132
153
  // Route resolution is async: the human can brake while it is pending.
133
154
  if (feedbackBlocked(exec)) return { observationSkipped: 'cancelled-or-paused' }
134
- const observation = mode === 'state' ? await observeState({}, exec) : await takeScreenshot({}, exec)
135
- return { observation, feedback: { kind: mode }, verification: feedbackVerification }
155
+ const compact = policy.detail === 'compact'
156
+ const checkAllowed = () => {
157
+ if (feedbackBlocked(exec)) throw new Error('compact observation cancelled-or-paused')
158
+ hooks?.assertOwnership?.(exec)
159
+ }
160
+ const observation = policy.kind === 'state'
161
+ ? compact ? await collectCompactState(cu, { ...context, checkAllowed,
162
+ archive: raw => hooks?.archiveFeedback ? hooks.archiveFeedback(exec, raw) : archiveFeedback(exec, raw) }) : await observeState({}, exec)
163
+ : await takeScreenshot(compact ? { region: feedbackViewport(context.args, context.receipt).region } : {}, exec)
164
+ const envelope = { observation, feedback: policy, verification: feedbackVerification }
165
+ if (compact && policy.kind === 'state' && observation.inlineEvidence) {
166
+ // Resource fallback deliberately favors recoverable evidence over the
167
+ // compact byte cap. This is the already-read local result, never a retry.
168
+ envelope.feedback = { ...policy, detail: 'full', reason: 'cache unavailable; complete local evidence returned inline' }
169
+ return envelope
170
+ }
171
+ // Images retain their existing attachment/coordinate contract. The compact
172
+ // text budget bounds the extra local UIA envelope, never the native receipt.
173
+ return compact && policy.kind === 'state' ? limitCompactFeedback(envelope) : envelope
136
174
  }
137
175
 
138
176
  const renderFeedback = (original, args, value) => {
139
177
  if (!value?.feedback && !value?.observationError && !value?.observationSkipped) return original(args, value)
140
- const { observation, feedback, ...receipt } = value
178
+ const { observation, feedback, budget, ...receipt } = value
141
179
  return [
142
180
  ...original(args, receipt),
143
181
  ...(observation ? feedback?.kind === 'image' ? renderScreenshot(observation)
144
- : [{ type: 'text', text: JSON.stringify({ feedback, observation }) }] : []),
182
+ : [{ type: 'text', text: JSON.stringify({ feedback, observation, ...(budget ? { budget } : {}) }) }] : []),
145
183
  ]
146
184
  }
147
185
 
148
186
  const reg = (def) => {
149
187
  const inner = def.execute
150
- const hasFeedback = feedbackActions.has(def.name) && config.actionFeedback !== false
188
+ const hasFeedback = feedbackActions.has(def.name)
189
+ if (hasFeedback || def.name === 'computer_batch') def.parameters.feedback = P('string', 'compact (default) | full | none', { enum: ['compact', 'full', 'none'] })
151
190
  if (hasFeedback) {
152
- def.description += ' Returns a route-specific checkpoint.'
191
+ def.description += ' Returns a route-specific checkpoint; input success does not verify the task effect.'
153
192
  const original = def.output.render
154
193
  def.output.render = (args, value) => renderFeedback(original, args, value)
155
194
  // Input plus the existing bounded screenshot/state call share one deadline.
@@ -159,7 +198,10 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
159
198
  // human wait may be long; the owning tool's cancellation still releases it immediately.
160
199
  if (cooperativeTools.has(def.name)) def.timeoutMs = Math.max(def.timeoutMs, 3_600_000)
161
200
  def.execute = async (args, exec) => {
201
+ if (def.name === 'computer_ctrl' && args?.action === 'clear_cache')
202
+ return lossless({ ...observations.clear(exec), desktopInput: false })
162
203
  const releaseOnly = def.name === 'computer_ctrl' && args?.action === 'release'
204
+ const stateOnly = isCycleStateOnly(def.name, args)
163
205
  // hooks drive the "the agent stopped driving" signal: the activity border is cut the
164
206
  // instant the last computer-use call of a turn finishes.
165
207
  if (hooks && hooks.onStart) { try { hooks.onStart(exec) } catch { /* never break a tool */ } }
@@ -203,7 +245,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
203
245
  err.dshCycleRefusal = true
204
246
  throw err
205
247
  }
206
- if (ensureCycle && !releaseOnly) {
248
+ if (ensureCycle && !stateOnly) {
207
249
  let opened = null
208
250
  try { opened = await ensureCycle(exec) } catch (e) {
209
251
  // An OWNERSHIP refusal is a VERDICT, not a hiccup: another session owns this machine
@@ -244,27 +286,36 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
244
286
  // registration-only probe does exactly that), and the earlier `hooks.onDispatch` form threw
245
287
  // `Cannot read properties of undefined` straight into the dispatch path.
246
288
  const invoke = async () => {
289
+ syncObservationEpoch()
247
290
  if (usesInput(def.name, args) && cu.waitInputReady) await cu.waitInputReady()
248
291
  if (!releaseOnly && hooks?.assertOwnership) hooks.assertOwnership(exec)
249
292
  if (hooks && hooks.onDispatch) { try { hooks.onDispatch(exec) } catch { /* never break a dispatch for a log */ } }
250
293
  return inner(args, exec)
251
294
  }
252
- const inputContext = { ...exec, assertOwnership: () => hooks?.assertOwnership?.(exec) }
253
- const out = cu.withInputContext ? await cu.withInputContext(inputContext, invoke) : await invoke()
254
- if (out === undefined || out === null) return out
255
- let observed = out
256
- if (hasFeedback && !actionRefusal({ tool: def.name, args }, out) && !feedbackBlocked(exec)) {
257
- try {
258
- const mode = await feedbackMode(ctx, exec, true)
259
- if (mode) observed = { ...out, ...await captureFeedback(exec, mode) }
260
- } catch (error) {
261
- // The input already returned. A failed optional observation must never
262
- // hide that receipt or turn it into an apparent failed/retryable write.
263
- observed = { ...out, observationError: String(error.message || error), verification: feedbackVerification }
295
+ const inputContext = { ...exec, ...hooks?.contextOf?.(exec), assertOwnership: () => hooks?.assertOwnership?.(exec) }
296
+ const executeWithFeedback = async () => {
297
+ const detail = hasFeedback ? selectFeedbackDetail(args?.feedback,
298
+ config.actionFeedback === false ? 'none' : config.actionFeedbackDetail ?? 'compact') : null
299
+ const out = await invoke()
300
+ if (out === undefined || out === null) return out
301
+ let observed = out
302
+ if (hasFeedback && detail !== 'none' && !actionRefusal({ tool: def.name, args }, out) && !feedbackBlocked(exec)) {
303
+ try {
304
+ const policy = await feedbackPolicy(ctx, exec, true, { rules: config.feedbackRules, detail })
305
+ if (policy.kind) {
306
+ hooks?.assertOwnership?.(exec)
307
+ const captured = await captureFeedback(exec, policy, { args, receipt: out, tool: def.name })
308
+ observed = { ...out, ...captured, verification: out.verification ?? captured.verification }
309
+ }
310
+ } catch (error) {
311
+ // Optional feedback cannot erase an input receipt or invite replay.
312
+ observed = { ...out, observationError: String(error.message || error), verification: out.verification ?? feedbackVerification }
313
+ }
264
314
  }
315
+ const safe = lossless(observed)
316
+ return safe === undefined ? null : safe
265
317
  }
266
- const safe = lossless(observed)
267
- return safe === undefined ? null : safe
318
+ return cu.withInputContext ? await cu.withInputContext(inputContext, executeWithFeedback) : await executeWithFeedback()
268
319
  } catch (error) {
269
320
  // The native/local wait has already finished. Return an explicit continuation
270
321
  // checkpoint through normal tool output so the host does not end the task as an error.
@@ -310,6 +361,34 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
310
361
  execute: takeScreenshot,
311
362
  })
312
363
 
364
+ reg({
365
+ name: 'computer_ocr',
366
+ description: "Read TEXT with coordinates from a screen region (inbox Windows OCR). Returns lines/words with absolute-pixel boxes — locate controls BY NAME in zero-UIA apps instead of spending a screenshot; words' x,y are click-ready centers offset by w/2,h/2. Crop tight (16M px cap); zh/en mixed supported.",
367
+ parameters: {
368
+ region: P('string', 'crop "x,y,w,h" (default: whole screen)'),
369
+ },
370
+ output: out((a, v) => {
371
+ if (!v || !v.lines) return [{ type: 'text', text: 'no text' }]
372
+ const head = `ocr ${v.language} region=${v.region ? v.region.join(',') : ''} lines=${v.lines.length}`
373
+ const body = v.lines.map(l => {
374
+ const w0 = l.words[0]
375
+ return `${w0 ? `@${w0.x},${w0.y} ` : ''}${l.text}`
376
+ }).join('\n')
377
+ return [{ type: 'text', text: head + '\n' + body }]
378
+ }),
379
+ isConcurrencySafe: () => false,
380
+ timeoutMs: 30_000,
381
+ async execute (args) {
382
+ let region
383
+ if (args.region) {
384
+ const parts = String(args.region).split(',').map(Number)
385
+ if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) throw new Error('region must be "x,y,w,h"')
386
+ region = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
387
+ }
388
+ return cu.ocr(region)
389
+ },
390
+ })
391
+
313
392
  reg({
314
393
  name: 'computer_state',
315
394
  description: "Window/UIA state; start with windows:true, then activate the target. since returns fresh changes against that observation; omit for full. snapshot retrieves a retained full observation for this Agent.",
@@ -318,6 +397,8 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
318
397
  depth: P('number', 'tree depth (default 6)'),
319
398
  since: P('string', 'base observation ID for changes; invalid base returns full'),
320
399
  snapshot: P('string', 'retrieve this full observation without capturing again'),
400
+ offset: P('number', 'snapshot only: first row, zero-based'),
401
+ limit: P('number', 'snapshot only: rows per array, 1–80'),
321
402
  },
322
403
  output: outObservation(),
323
404
  isConcurrencySafe: () => false,
@@ -327,11 +408,12 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
327
408
 
328
409
  reg({
329
410
  name: 'computer_marks',
330
- description: "Cached UIA map with snapshot IDs. Reuse while unchanged; mark actions check window identity, geometry and sampled pixels. STALE_MARK requires refresh and reselecting the intended element. Refreshed IDs replace old IDs.",
411
+ description: "Cached UIA map with snapshot IDs. Reuse while unchanged; mark actions check window identity, geometry and sampled pixels. STALE_MARK requires refresh and reselecting the intended element. Refreshed IDs replace old IDs. uiaOnly pins identity by window+geometry when an animated neighbour keeps invalidating the pixel sample (the click-time occlusion check still applies).",
331
412
  parameters: {
332
413
  hwnd: P('number', 'window (default: active)'),
333
414
  shot: P('boolean', 'also return a marked-up screenshot path'),
334
415
  refresh: P('boolean', 'rebuild instead of reusing the cache'),
416
+ uiaOnly: P('boolean', 'skip the sampled-pixel comparison; identity = window+geometry only'),
335
417
  },
336
418
  output: out((a, v) => {
337
419
  if (!v || !v.marks) return [{ type: 'text', text: 'no marks' }]
@@ -341,7 +423,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
341
423
  isConcurrencySafe: () => false,
342
424
  timeoutMs: 45_000,
343
425
  async execute (args) {
344
- const res = await cu.landmarks({ hwnd: args.hwnd, refresh: args.refresh })
426
+ const res = await cu.landmarks({ hwnd: args.hwnd, refresh: args.refresh, uiaOnly: args.uiaOnly })
345
427
  const res2 = {
346
428
  count: res.count, reused: !!res.reused, hwnd: res.hwnd,
347
429
  marks: res.marks.map(m => ({ id: m.id, role: m.role, name: m.name, center: [m.cx, m.cy] })),
@@ -359,15 +441,19 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
359
441
  description: "Query hwnd plus role/name/exact id; returns controls and patterns. Scoped name/id queries also return target handles. since gives fresh changes; omit for full. snapshot retrieves a retained full result. at hit-tests. truncated:true is partial evidence.",
360
442
  parameters: {
361
443
  at: P('string', 'hit test at "x,y"'),
362
- hwnd: P('number', 'window; omit to search desktop'),
444
+ hwnd: P('number', 'required window scope for a tree query; at needs no tree'),
363
445
  role: P('string', 'Button | Edit | Hyperlink | TabItem | ...'),
364
446
  name: P('string', 'name substring'),
365
447
  id: P('string', 'exact AutomationId from UIA'),
366
448
  targets: P('boolean', 'recovery handles; default on for hwnd + name/id, off for broad queries'),
367
449
  limit: P('number', 'maximum matches, 1–80 (default 80)'),
368
- depth: P('number', 'tree depth, 1–24 (default 16)'),
450
+ depth: P('number', 'tree depth, default 6; broader traversal needs anchor'),
451
+ anchor: P('string', 'observed "x,y" inside hwnd; search only this element subtree'),
452
+ maxNodes: P('number', 'scanned node cap, default 120; above 300 needs anchor'),
453
+ budgetMs: P('number', 'soft traversal budget 100–5000 ms (default 1200); one provider call can exceed it'),
369
454
  since: P('string', 'base observation ID; repeat the same query'),
370
455
  snapshot: P('string', 'retrieve a retained full observation ID'),
456
+ offset: P('number', 'snapshot only: first row, zero-based; limit bounds the page'),
371
457
  },
372
458
  output: outObservation(),
373
459
  isConcurrencySafe: () => false,
@@ -379,13 +465,15 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
379
465
  name: 'computer_uia_act',
380
466
  description: "Act through UIA. Prefer an observed target handle; rebind:true permits unique identity/semantic recovery inside the same window. Or use hwnd + exact id. Ambiguous, changed or incomplete evidence refuses; verify readback.",
381
467
  parameters: {
382
- hwnd: P('number', 'window (default: desktop)'),
468
+ hwnd: P('number', 'required window scope unless using an observed target'),
383
469
  name: P('string', 'element name substring'),
384
470
  id: P('string', 'exact AutomationId from UIA; scope with hwnd'),
385
471
  role: P('string', 'TabItem | Button | ListItem | ComboBox | Edit | MenuItem'),
386
472
  action: P('string', 'invoke (default) | select | expand | collapse | toggle | setValue | focus | scrollIntoView'),
387
473
  value: P('string', 'value for setValue'),
388
- depth: P('number', 'tree depth, 1–24 (default 24)'),
474
+ depth: P('number', 'tree depth, default 6; broader traversal needs anchor'),
475
+ anchor: P('string', 'observed "x,y" inside hwnd; narrow selector search'),
476
+ budgetMs: P('number', 'soft traversal budget 100–5000 ms (default 1200)'),
389
477
  target: P('string', 'opaque target from computer_uia; do not combine with selectors'),
390
478
  rebind: P('boolean', 'allow unique recovery if the observed native element was replaced'),
391
479
  },
@@ -407,6 +495,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
407
495
  button: P('string', 'left (default) | right | middle'),
408
496
  clicks: P('number', '1 | 2 (double) | 3 (triple)'),
409
497
  shift: P('boolean', 'hold SHIFT (range selection)'),
498
+ verificationRegion: P('string', 'expected effect region "x,y,w,h" in screen pixels; may differ from click point; a change alone does not prove task success'),
410
499
  },
411
500
  output: outJSON(),
412
501
  isConcurrencySafe: () => false,
@@ -416,7 +505,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
416
505
 
417
506
  reg({
418
507
  name: 'computer_move',
419
- description: "Move without clicking to reveal hover controls.",
508
+ description: "Hover without clicking; returns Windows standard cursor evidence. Browser col-resize/row-resize or custom cursors may be unrecognized; unknown proves neither a handle hit nor a miss.",
420
509
  parameters: { at: P('string', '"x,y"', { required: true }) },
421
510
  output: outJSON(),
422
511
  isConcurrencySafe: () => false,
@@ -430,16 +519,18 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
430
519
 
431
520
  reg({
432
521
  name: 'computer_drag',
433
- description: "Drag between measured points. Prefer computer_select for text ranges.",
522
+ description: "Drag measured screen points. Optional expectCursor checks a recognized Windows standard shape before button-down; browser/custom cursors may be unverifiable. Ground those handles independently. cursorCheck says matched or not-requested; ok is delivery, not layout success. Verify application geometry. Prefer computer_select for text.",
434
523
  parameters: {
435
524
  from: P('string', '"M3" or "x,y"', { required: true }),
436
525
  to: P('string', '"M7" or "x,y"', { required: true }),
526
+ expectCursor: P('string', 'Optional standard shape: resize | resize-ew | resize-ns | resize-nesw | resize-nwse | move | hand. Unknown refuses this check; omit only with independently verified target geometry/hit-test.'),
527
+ hwnd: P('number', 'expected window at the drag start'),
437
528
  },
438
529
  output: outJSON(),
439
530
  isConcurrencySafe: () => false,
440
531
  timeoutMs: 15_000,
441
532
  async execute (args) {
442
- return cu.drag(await cu.resolveTarget(pt(args.from)), await cu.resolveTarget(pt(args.to)))
533
+ return cu.drag(await cu.resolveTarget(pt(args.from)), await cu.resolveTarget(pt(args.to)), { expectCursor: args.expectCursor, expectHwnd: args.hwnd })
443
534
  },
444
535
  })
445
536
 
@@ -608,15 +699,32 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
608
699
  isConcurrencySafe: () => false,
609
700
  timeoutMs: 120_000,
610
701
  async execute (args, exec) {
611
- let automatic, feedback
612
- const result = await runBatch({ steps: args.actions, handlers: HANDLERS, signal: exec && exec.signal,
702
+ let automatic, feedback, lastContext = {}
703
+ const detail = selectFeedbackDetail(args.feedback,
704
+ config.actionFeedback === false ? 'none' : config.actionFeedbackDetail ?? 'compact')
705
+ // Batch still observes once at its final checkpoint. Capture the actual
706
+ // result coordinates without re-resolving potentially stale mark ids.
707
+ const feedbackHandlers = Object.fromEntries(Object.entries(HANDLERS).map(([name, handler]) => [name,
708
+ async (input, context) => {
709
+ const receipt = await handler(input, context)
710
+ lastContext = { args: input, receipt, tool: name }
711
+ return receipt
712
+ }]))
713
+ const result = await runBatch({ steps: args.actions, handlers: feedbackHandlers, signal: exec && exec.signal,
613
714
  stopped: () => cu.stopped || cu.exited, shot: args.shot, exec,
614
715
  assertOwnership: () => hooks?.assertOwnership?.(exec),
615
716
  screenshot: async () => {
616
- const mode = args.shot === 'always' ? 'image' : await feedbackMode(ctx, exec, config.actionFeedback !== false)
717
+ const policy = args.shot === 'always' ? { kind: 'image', detail: 'full', reason: 'explicit batch screenshot' }
718
+ : await feedbackPolicy(ctx, exec, config.actionFeedback !== false || args.feedback !== undefined, { rules: config.feedbackRules, detail })
617
719
  if (feedbackBlocked(exec)) { feedback = { observationSkipped: 'cancelled-or-paused' }; return null }
618
- if (mode === 'state') { feedback = await captureFeedback(exec, mode); return null }
619
- automatic = await takeScreenshot({}, exec)
720
+ if (!policy.kind) { feedback = { feedback: policy, observationSkipped: policy.reason || 'disabled' }; return null }
721
+ hooks?.assertOwnership?.(exec)
722
+ if (policy.kind === 'state') { feedback = await captureFeedback(exec, policy, lastContext); return null }
723
+ const captured = await captureFeedback(exec, policy, lastContext)
724
+ const { observation, ...details } = captured
725
+ automatic = observation
726
+ feedback = details
727
+ if (!automatic) return null
620
728
  return automatic.path
621
729
  } })
622
730
  return { ...selectBatchScreenshot(result, feedback?.observation ?? automatic, feedback?.feedback?.kind), ...feedback }
@@ -626,9 +734,9 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
626
734
  // ---------------- meta ----------------
627
735
  reg({
628
736
  name: 'computer_ctrl',
629
- description: 'After final desktop verification, call release immediately to free control and monitoring; continue the conversation normally. A later required desktop call acquires a fresh cycle. stop raises a manual brake, released by human Ctrl+Alt+R; recover releases a verified temporary diagnostic pause. exit aborts the session. selftest/calibrate diagnose input; indicator controls the border; acknowledge accepts the skill receipt.',
737
+ description: 'hold requests stable page/pointer state between actions (breathing blue); free allows human use while observing (cyan). Neither blocks human takeover. After final verification, release ends desktop control while the conversation continues. stop raises a manual brake, released by human Ctrl+Alt+R; recover checks a temporary diagnostic pause; exit aborts. selftest/calibrate diagnose input; indicator controls the border; acknowledge accepts the skill receipt.',
630
738
  parameters: {
631
- action: P('string', 'release | selftest | calibrate | stop | exit | indicator | acknowledge | recover', { required: true }),
739
+ action: P('string', 'hold | free | release | clear_cache | selftest | calibrate | stop | exit | indicator | acknowledge | recover', { required: true }),
632
740
  why: P('string', 'reason recorded with a stop'),
633
741
  temporary: P('boolean', 'stop: explicitly agent-owned diagnostic pause, eligible for checked recovery; ordinary waits use computer_wait'),
634
742
  pauseId: P('string', 'recover: credential returned by an explicit temporary stop'),
@@ -642,6 +750,9 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
642
750
  timeoutMs: 30_000,
643
751
  async execute (args, exec) {
644
752
  switch (args.action) {
753
+ case 'clear_cache': return { ...observations.clear(exec), desktopInput: false }
754
+ case 'hold': return cu.indicator({ hold: true })
755
+ case 'free': return cu.indicator({ hold: false })
645
756
  case 'release': return hooks.releaseCycle(exec, args.why)
646
757
  case 'stop': return args.temporary ? hooks.temporaryStop(exec, args.why) : cu.panic(args.why)
647
758
  case 'recover': return hooks.recover(exec, args.pauseId)
@@ -677,7 +788,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
677
788
  const HANDLERS = {
678
789
  computer_click: clickTarget,
679
790
  computer_move: (a) => { const p = pt(a.at); return cu.move(p.x, p.y) },
680
- computer_drag: async (a) => cu.drag(await cu.resolveTarget(pt(a.from)), await cu.resolveTarget(pt(a.to))),
791
+ computer_drag: async (a) => cu.drag(await cu.resolveTarget(pt(a.from)), await cu.resolveTarget(pt(a.to)), { expectCursor: a.expectCursor, expectHwnd: a.hwnd }),
681
792
  computer_scroll: (a) => cu.scroll(a.dir, a.lines || 3, pt(a.at) ? { at: pt(a.at) } : {}),
682
793
  computer_select: async (a) => {
683
794
  const f = await cu.resolveTarget(pt(a.from)); const t = await cu.resolveTarget(pt(a.to))
@@ -695,6 +806,15 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
695
806
  // take a screenshot: "unknown tool computer_shot" (reported 2026-09-12). The map must cover
696
807
  // the WHOLE surface — a caller should never have to know which subset happens to be wired.
697
808
  computer_shot: takeScreenshot,
809
+ computer_ocr: async (a) => {
810
+ let region
811
+ if (a.region) {
812
+ const parts = String(a.region).split(',').map(Number)
813
+ if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) throw new Error('region must be "x,y,w,h"')
814
+ region = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
815
+ }
816
+ return cu.ocr(region)
817
+ },
698
818
  computer_state: observeState,
699
819
  computer_marks: async (a) => {
700
820
  const res = await cu.landmarks({ hwnd: a.hwnd, refresh: a.refresh })
@@ -711,6 +831,9 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
711
831
  computer_uia: queryUia,
712
832
  computer_ctrl: (a, exec) => {
713
833
  switch (a.action) {
834
+ case 'clear_cache': return { ...observations.clear(exec), desktopInput: false }
835
+ case 'hold': return cu.indicator({ hold: true })
836
+ case 'free': return cu.indicator({ hold: false })
714
837
  case 'release': return hooks.releaseCycle(exec, a.why)
715
838
  case 'stop': return a.temporary ? hooks.temporaryStop(exec, a.why) : cu.panic(a.why)
716
839
  case 'recover': return hooks.recover(exec, a.pauseId)
@@ -0,0 +1,17 @@
1
+ // A witness rectangle describes where the caller expects an effect, independently
2
+ // of the input point. Validation happens before focus, pointer or button effects.
3
+ export function verificationRegion (value) {
4
+ if (value === undefined) return undefined
5
+ const tokens = typeof value === 'string' ? value.trim().split(/[,\s]+/) : null
6
+ const parts = tokens ? (tokens.some(token => !token) ? [] : tokens.map(Number))
7
+ : value && typeof value === 'object' && !Array.isArray(value)
8
+ ? [value.x, value.y, value.width, value.height] : []
9
+ const [x, y, width, height] = parts
10
+ if (parts.length !== 4 || !parts.every(n => Number.isInteger(n) && n > -2147483648 && n <= 2147483647) ||
11
+ width <= 0 || height <= 0 || width * height > 16777216 ||
12
+ x + width > 2147483647 || y + height > 2147483647) {
13
+ throw Object.assign(new Error('verificationRegion must be "x,y,width,height" in integer screen pixels with positive size (maximum 16M pixels). No input was dispatched.'),
14
+ { code: 'INVALID_VERIFICATION_REGION', outcome: 'not-dispatched' })
15
+ }
16
+ return { x, y, width, height }
17
+ }
package/mcp/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@dsh/computer-use-mcp",
3
3
  "version": "0.1.0",
4
- "description": "MCP stdio server exposing dsh-computer-use to any MCP client (Claude Desktop, Codex, ...). Zero npm dependencies.",
4
+ "private": true,
5
+ "description": "Embedded MCP stdio adapter for the root dsh-computer-use package; not a standalone package. Zero npm dependencies.",
5
6
  "type": "module",
6
7
  "main": "src/index.js",
7
8
  "bin": { "dsh-computer-use-mcp": "src/index.js" },
@@ -1,5 +1,7 @@
1
1
  // Serialize whole MCP tool calls, including batches and human handoff waits.
2
2
  // The native desktop lease also excludes other worker processes.
3
+ import { handoffResult } from '../../lib/continuation.js'
4
+
3
5
  export function createToolQueue () {
4
6
  let tail = Promise.resolve()
5
7
  return (run, signal) => {
@@ -14,9 +16,10 @@ export function createToolQueue () {
14
16
  }
15
17
 
16
18
  export function continuationResult (error, describe) {
17
- if (error?.code !== 'HUMAN_REOBSERVE') return null
18
- return {
19
- status: 'interrupted', code: error.code, taskStatus: 'continuing', transient: true,
20
- outcome: error.outcome || 'unknown', continuation: error.continuation || describe(error),
21
- }
19
+ const shared = handoffResult(error)
20
+ if (shared) return shared
21
+ // Compatibility for a bare human-handoff error from an older core. Worker
22
+ // recovery must retain its own description, never invent a human handback.
23
+ if (error?.code !== 'HUMAN_REOBSERVE' || !describe) return null
24
+ return handoffResult({ ...error, continuation: describe(error) })
22
25
  }