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/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // tools.js — defineTool registrations for dsh-computer-use.
2
2
  //
3
- // 18 TOOLS. The previous 28 were merged, not deleted; every capability still exists and the
3
+ // 20 TOOLS. The previous 28 were merged, not deleted; every capability still exists and the
4
4
  // behavioural detail lives in the skill (loaded only for computer-use work, while these
5
5
  // definitions are paid for on EVERY request).
6
6
  //
@@ -17,18 +17,27 @@
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 { validateLaunchArgs, validateLaunchTarget } from './launch.js'
26
+ import { join } from 'node:path'
24
27
 
25
28
  const P = (type, description, extra = {}) => ({ type, description, ...extra })
26
29
  const cooperativeTools = new Set(['computer_click', 'computer_move', 'computer_drag', 'computer_scroll',
27
- 'computer_select', 'computer_key', 'computer_type', 'computer_window', 'computer_uia_act', 'computer_batch', 'computer_clip'])
30
+ 'computer_select', 'computer_key', 'computer_type', 'computer_window', 'computer_uia_act', 'computer_batch', 'computer_clip',
31
+ 'computer_focus'])
28
32
  const usesInput = (name, args = {}) => name === 'computer_clip' ? args.text !== undefined :
29
33
  name === 'computer_batch' ? (args.actions || []).some(step => usesInput(step.tool, step.args)) :
30
34
  cooperativeTools.has(name)
31
35
 
36
+ export const isCycleStateOnly = (name, args = {}) => name === 'computer_ctrl'
37
+ ? ['hold', 'free', 'release', 'clear_cache'].includes(args?.action)
38
+ : name === 'computer_batch' && Array.isArray(args?.actions) && args.actions.length > 0 &&
39
+ args.actions.every(step => isCycleStateOnly(step?.tool, step?.args))
40
+
32
41
  /** "M3" | "x,y" | "x y" -> {mark:'M3'} | {x,y}. One parameter instead of four. */
33
42
  const pt = (v) => {
34
43
  if (v === undefined || v === null) return null
@@ -49,14 +58,38 @@ const rect = (v) => {
49
58
  }
50
59
 
51
60
  export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, defineTool, hooks, ensureCycle, register }) {
52
- const observations = new ObservationStore()
61
+ const observations = new ObservationStore({ archiveDir: typeof dataDir?.root === 'string' ? join(dataDir.root, 'observation-cache') : undefined })
62
+ let observationEpoch
63
+ const syncObservationEpoch = () => {
64
+ const epoch = JSON.stringify([cu.workerGeneration ?? null, cu.cooperation?.revision ?? null, cu.stateSeq ?? 0])
65
+ if (observationEpoch !== undefined && observationEpoch !== epoch) observations.invalidate('worker-or-human-state-changed')
66
+ observationEpoch = epoch
67
+ }
68
+ const archiveFeedback = (exec, raw) => {
69
+ syncObservationEpoch()
70
+ const saved = observations.publish(exec, 'state', ['post-action', raw.point?.x, raw.point?.y], raw, undefined, { archive: true })
71
+ return { snapshot: saved.observation, archive: saved.archive }
72
+ }
53
73
  const saveShot = (buf, ext = 'jpg') => {
54
74
  const p = snapshotPath(dataDir).replace(/\.jpg$/, `.${ext}`)
55
75
  return saveOwnedShot(dataDir, p, buf)
56
76
  }
57
77
 
78
+ // Per-call image delivery. `auto` is the existing behaviour and stays the default; `path`
79
+ // withholds the image block without withholding the file. An unknown value refuses here
80
+ // rather than silently behaving like auto — a typo must not quietly reintroduce the cost the
81
+ // caller asked to avoid.
82
+ const deliveryMode = (value) => {
83
+ if (value === undefined || value === 'auto') return 'auto'
84
+ if (value === 'path') return 'path'
85
+ throw Object.assign(new Error('delivery must be "auto" or "path"'), { code: 'BAD_DELIVERY', outcome: 'not-dispatched' })
86
+ }
87
+
58
88
  // Shared by direct calls, explicit batch steps, and the batch checkpoint.
59
89
  const takeScreenshot = async (args, exec) => {
90
+ // Refuse a bad value BEFORE the capture: a refused request that already wrote a file and
91
+ // reports outcome:"not-dispatched" is a false receipt.
92
+ const delivery = deliveryMode(args.delivery)
60
93
  const r = rect(args.region)
61
94
  const shot = await cu.screenshot({
62
95
  x: r && r.x, y: r && r.y, width: r && r.width, height: r && r.height,
@@ -64,9 +97,8 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
64
97
  maxWidth: r && r.width <= 900 ? r.width : 1280,
65
98
  })
66
99
  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)
100
+ const res = { path, mime: shot.mime, region: shot.region, ...screenshotMetadata(shot) }
101
+ return attachScreenshot(ctx, exec, res, shot.image, { rules: config.feedbackRules, delivery })
70
102
  }
71
103
 
72
104
  const clickTarget = async (args) => {
@@ -81,26 +113,30 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
81
113
  }
82
114
 
83
115
  const queryUia = async (args, exec) => {
84
- if (args.snapshot) return observations.get(exec, args.snapshot, 'uia')
116
+ if (args.snapshot) return observations.get(exec, args.snapshot, 'uia', { offset: args.offset, limit: args.limit })
85
117
  const p = pt(args.at)
86
118
  if (p && p.x !== undefined) return cu.call('uiaFromPoint', { x: p.x, y: p.y })
87
119
  // Identity witnesses help an intended action, but are expensive for a broad inventory.
88
120
  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,
121
+ const res = await cu.call('uia', { hwnd: args.hwnd, depth: args.depth ?? 6, maxNodes: args.maxNodes ?? 120,
122
+ budgetMs: args.budgetMs ?? 1200, anchor: args.anchor,
90
123
  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)
124
+ syncObservationEpoch()
125
+ 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],
126
+ { 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
127
  }
94
128
  const actUia = (args, exec) => {
95
129
  if (args.target && !observations.ownsTarget(exec, args.target)) {
96
130
  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
131
  }
98
132
  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 })
133
+ role: args.role, action: args.action, value: args.value, requireUnique: true,
134
+ depth: args.depth ?? 6, maxNodes: 300, anchor: args.anchor, budgetMs: args.budgetMs ?? 1200 })
100
135
  }
101
136
  const observeState = async (args, exec) => {
102
- if (args.snapshot) return observations.get(exec, args.snapshot, 'state')
137
+ if (args.snapshot) return observations.get(exec, args.snapshot, 'state', { offset: args.offset, limit: args.limit })
103
138
  const result = args.windows ? await cu.call('windows', {}) : await cu.screenState({ uiaDepth: args.depth })
139
+ syncObservationEpoch()
104
140
  return observations.publish(exec, 'state', [!!args.windows, args.depth ?? 6], result, args.since)
105
141
  }
106
142
 
@@ -128,28 +164,46 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
128
164
  if (exec?.signal?.aborted || cu.stopped || cu.exited || cu.cooperation?.active) return true
129
165
  try { return !!hooks?.askBlocked?.('computer_shot') } catch { return true }
130
166
  }
131
- const captureFeedback = async (exec, mode) => {
167
+ const captureFeedback = async (exec, policy, context = {}, delivery = 'auto') => {
132
168
  // Route resolution is async: the human can brake while it is pending.
133
169
  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 }
170
+ const compact = policy.detail === 'compact'
171
+ const checkAllowed = () => {
172
+ if (feedbackBlocked(exec)) throw new Error('compact observation cancelled-or-paused')
173
+ hooks?.assertOwnership?.(exec)
174
+ }
175
+ const observation = policy.kind === 'state'
176
+ ? compact ? await collectCompactState(cu, { ...context, checkAllowed,
177
+ archive: raw => hooks?.archiveFeedback ? hooks.archiveFeedback(exec, raw) : archiveFeedback(exec, raw) }) : await observeState({}, exec)
178
+ : await takeScreenshot(compact ? { region: feedbackViewport(context.args, context.receipt).region, delivery } : { delivery }, exec)
179
+ const envelope = { observation, feedback: policy, verification: feedbackVerification }
180
+ if (compact && policy.kind === 'state' && observation.inlineEvidence) {
181
+ // Resource fallback deliberately favors recoverable evidence over the
182
+ // compact byte cap. This is the already-read local result, never a retry.
183
+ envelope.feedback = { ...policy, detail: 'full', reason: 'cache unavailable; complete local evidence returned inline' }
184
+ return envelope
185
+ }
186
+ // Images retain their existing attachment/coordinate contract. The compact
187
+ // text budget bounds the extra local UIA envelope, never the native receipt.
188
+ return compact && policy.kind === 'state' ? limitCompactFeedback(envelope) : envelope
136
189
  }
137
190
 
138
191
  const renderFeedback = (original, args, value) => {
139
192
  if (!value?.feedback && !value?.observationError && !value?.observationSkipped) return original(args, value)
140
- const { observation, feedback, ...receipt } = value
193
+ const { observation, feedback, budget, ...receipt } = value
141
194
  return [
142
195
  ...original(args, receipt),
143
196
  ...(observation ? feedback?.kind === 'image' ? renderScreenshot(observation)
144
- : [{ type: 'text', text: JSON.stringify({ feedback, observation }) }] : []),
197
+ : [{ type: 'text', text: JSON.stringify({ feedback, observation, ...(budget ? { budget } : {}) }) }] : []),
145
198
  ]
146
199
  }
147
200
 
148
201
  const reg = (def) => {
149
202
  const inner = def.execute
150
- const hasFeedback = feedbackActions.has(def.name) && config.actionFeedback !== false
203
+ const hasFeedback = feedbackActions.has(def.name)
204
+ if (hasFeedback || def.name === 'computer_batch') def.parameters.feedback = P('string', 'compact (default) | full | none', { enum: ['compact', 'full', 'none'] })
151
205
  if (hasFeedback) {
152
- def.description += ' Returns a route-specific checkpoint.'
206
+ def.description += ' Returns a route-specific checkpoint; input success does not verify the task effect.'
153
207
  const original = def.output.render
154
208
  def.output.render = (args, value) => renderFeedback(original, args, value)
155
209
  // Input plus the existing bounded screenshot/state call share one deadline.
@@ -159,7 +213,10 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
159
213
  // human wait may be long; the owning tool's cancellation still releases it immediately.
160
214
  if (cooperativeTools.has(def.name)) def.timeoutMs = Math.max(def.timeoutMs, 3_600_000)
161
215
  def.execute = async (args, exec) => {
216
+ if (def.name === 'computer_ctrl' && args?.action === 'clear_cache')
217
+ return lossless({ ...observations.clear(exec), desktopInput: false })
162
218
  const releaseOnly = def.name === 'computer_ctrl' && args?.action === 'release'
219
+ const stateOnly = isCycleStateOnly(def.name, args)
163
220
  // hooks drive the "the agent stopped driving" signal: the activity border is cut the
164
221
  // instant the last computer-use call of a turn finishes.
165
222
  if (hooks && hooks.onStart) { try { hooks.onStart(exec) } catch { /* never break a tool */ } }
@@ -203,7 +260,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
203
260
  err.dshCycleRefusal = true
204
261
  throw err
205
262
  }
206
- if (ensureCycle && !releaseOnly) {
263
+ if (ensureCycle && !stateOnly) {
207
264
  let opened = null
208
265
  try { opened = await ensureCycle(exec) } catch (e) {
209
266
  // An OWNERSHIP refusal is a VERDICT, not a hiccup: another session owns this machine
@@ -244,27 +301,36 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
244
301
  // registration-only probe does exactly that), and the earlier `hooks.onDispatch` form threw
245
302
  // `Cannot read properties of undefined` straight into the dispatch path.
246
303
  const invoke = async () => {
304
+ syncObservationEpoch()
247
305
  if (usesInput(def.name, args) && cu.waitInputReady) await cu.waitInputReady()
248
306
  if (!releaseOnly && hooks?.assertOwnership) hooks.assertOwnership(exec)
249
307
  if (hooks && hooks.onDispatch) { try { hooks.onDispatch(exec) } catch { /* never break a dispatch for a log */ } }
250
308
  return inner(args, exec)
251
309
  }
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 }
310
+ const inputContext = { ...exec, ...hooks?.contextOf?.(exec), assertOwnership: () => hooks?.assertOwnership?.(exec) }
311
+ const executeWithFeedback = async () => {
312
+ const detail = hasFeedback ? selectFeedbackDetail(args?.feedback,
313
+ config.actionFeedback === false ? 'none' : config.actionFeedbackDetail ?? 'compact') : null
314
+ const out = await invoke()
315
+ if (out === undefined || out === null) return out
316
+ let observed = out
317
+ if (hasFeedback && detail !== 'none' && !actionRefusal({ tool: def.name, args }, out) && !feedbackBlocked(exec)) {
318
+ try {
319
+ const policy = await feedbackPolicy(ctx, exec, true, { rules: config.feedbackRules, detail })
320
+ if (policy.kind) {
321
+ hooks?.assertOwnership?.(exec)
322
+ const captured = await captureFeedback(exec, policy, { args, receipt: out, tool: def.name })
323
+ observed = { ...out, ...captured, verification: out.verification ?? captured.verification }
324
+ }
325
+ } catch (error) {
326
+ // Optional feedback cannot erase an input receipt or invite replay.
327
+ observed = { ...out, observationError: String(error.message || error), verification: out.verification ?? feedbackVerification }
328
+ }
264
329
  }
330
+ const safe = lossless(observed)
331
+ return safe === undefined ? null : safe
265
332
  }
266
- const safe = lossless(observed)
267
- return safe === undefined ? null : safe
333
+ return cu.withInputContext ? await cu.withInputContext(inputContext, executeWithFeedback) : await executeWithFeedback()
268
334
  } catch (error) {
269
335
  // The native/local wait has already finished. Return an explicit continuation
270
336
  // checkpoint through normal tool output so the host does not end the task as an error.
@@ -303,6 +369,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
303
369
  preset: P('string', 'taskbar'),
304
370
  png: P('boolean', 'PNG instead of JPEG'),
305
371
  wait: P('string', 'auto (default) | none'),
372
+ delivery: P('string', 'auto (default) | path — path writes the file and returns its absolute path with no image block'),
306
373
  },
307
374
  output: out((a, v) => renderScreenshot(v)),
308
375
  isConcurrencySafe: () => false,
@@ -346,6 +413,8 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
346
413
  depth: P('number', 'tree depth (default 6)'),
347
414
  since: P('string', 'base observation ID for changes; invalid base returns full'),
348
415
  snapshot: P('string', 'retrieve this full observation without capturing again'),
416
+ offset: P('number', 'snapshot only: first row, zero-based'),
417
+ limit: P('number', 'snapshot only: rows per array, 1–80'),
349
418
  },
350
419
  output: outObservation(),
351
420
  isConcurrencySafe: () => false,
@@ -388,15 +457,19 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
388
457
  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.",
389
458
  parameters: {
390
459
  at: P('string', 'hit test at "x,y"'),
391
- hwnd: P('number', 'window; omit to search desktop'),
460
+ hwnd: P('number', 'required window scope for a tree query; at needs no tree'),
392
461
  role: P('string', 'Button | Edit | Hyperlink | TabItem | ...'),
393
462
  name: P('string', 'name substring'),
394
463
  id: P('string', 'exact AutomationId from UIA'),
395
464
  targets: P('boolean', 'recovery handles; default on for hwnd + name/id, off for broad queries'),
396
465
  limit: P('number', 'maximum matches, 1–80 (default 80)'),
397
- depth: P('number', 'tree depth, 1–24 (default 16)'),
466
+ depth: P('number', 'tree depth, default 6; broader traversal needs anchor'),
467
+ anchor: P('string', 'observed "x,y" inside hwnd; search only this element subtree'),
468
+ maxNodes: P('number', 'scanned node cap, default 120; above 300 needs anchor'),
469
+ budgetMs: P('number', 'soft traversal budget 100–5000 ms (default 1200); one provider call can exceed it'),
398
470
  since: P('string', 'base observation ID; repeat the same query'),
399
471
  snapshot: P('string', 'retrieve a retained full observation ID'),
472
+ offset: P('number', 'snapshot only: first row, zero-based; limit bounds the page'),
400
473
  },
401
474
  output: outObservation(),
402
475
  isConcurrencySafe: () => false,
@@ -408,13 +481,15 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
408
481
  name: 'computer_uia_act',
409
482
  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.",
410
483
  parameters: {
411
- hwnd: P('number', 'window (default: desktop)'),
484
+ hwnd: P('number', 'required window scope unless using an observed target'),
412
485
  name: P('string', 'element name substring'),
413
486
  id: P('string', 'exact AutomationId from UIA; scope with hwnd'),
414
487
  role: P('string', 'TabItem | Button | ListItem | ComboBox | Edit | MenuItem'),
415
488
  action: P('string', 'invoke (default) | select | expand | collapse | toggle | setValue | focus | scrollIntoView'),
416
489
  value: P('string', 'value for setValue'),
417
- depth: P('number', 'tree depth, 1–24 (default 24)'),
490
+ depth: P('number', 'tree depth, default 6; broader traversal needs anchor'),
491
+ anchor: P('string', 'observed "x,y" inside hwnd; narrow selector search'),
492
+ budgetMs: P('number', 'soft traversal budget 100–5000 ms (default 1200)'),
418
493
  target: P('string', 'opaque target from computer_uia; do not combine with selectors'),
419
494
  rebind: P('boolean', 'allow unique recovery if the observed native element was replaced'),
420
495
  },
@@ -436,6 +511,8 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
436
511
  button: P('string', 'left (default) | right | middle'),
437
512
  clicks: P('number', '1 | 2 (double) | 3 (triple)'),
438
513
  shift: P('boolean', 'hold SHIFT (range selection)'),
514
+ 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'),
515
+ allowCovered: P('boolean', 'click a pixel owned by the target app\'s OWN embedded renderer (a descendant process of the target, e.g. a WeChat article panel); an unrelated window on top is still refused and the receipt says COVERED-UNRELATED'),
439
516
  },
440
517
  output: outJSON(),
441
518
  isConcurrencySafe: () => false,
@@ -445,7 +522,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
445
522
 
446
523
  reg({
447
524
  name: 'computer_move',
448
- description: "Move without clicking to reveal hover controls.",
525
+ 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.",
449
526
  parameters: { at: P('string', '"x,y"', { required: true }) },
450
527
  output: outJSON(),
451
528
  isConcurrencySafe: () => false,
@@ -459,16 +536,18 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
459
536
 
460
537
  reg({
461
538
  name: 'computer_drag',
462
- description: "Drag between measured points. Prefer computer_select for text ranges.",
539
+ 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.",
463
540
  parameters: {
464
541
  from: P('string', '"M3" or "x,y"', { required: true }),
465
542
  to: P('string', '"M7" or "x,y"', { required: true }),
543
+ 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.'),
544
+ hwnd: P('number', 'expected window at the drag start'),
466
545
  },
467
546
  output: outJSON(),
468
547
  isConcurrencySafe: () => false,
469
548
  timeoutMs: 15_000,
470
549
  async execute (args) {
471
- return cu.drag(await cu.resolveTarget(pt(args.from)), await cu.resolveTarget(pt(args.to)))
550
+ return cu.drag(await cu.resolveTarget(pt(args.from)), await cu.resolveTarget(pt(args.to)), { expectCursor: args.expectCursor, expectHwnd: args.hwnd })
472
551
  },
473
552
  })
474
553
 
@@ -564,6 +643,49 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
564
643
  async execute (args) { return args.text === undefined ? cu.clipRead() : cu.clipWrite(args.text) },
565
644
  })
566
645
 
646
+ // ---------------- launch ----------------
647
+ // ONE call replaces the Win+R route (activate, combo, type, enter, verify): the worker resolves
648
+ // the target with ShellExecute, so App Paths aliases (calc), shell: folders, URLs, folders and
649
+ // document associations all work. The launch is a real actuation — brake checks, the status
650
+ // halo (with a short self-expiring tail, because a launch returns in milliseconds) and the
651
+ // audit trail all come from the worker op, not from this file.
652
+ reg({
653
+ name: 'computer_launch',
654
+ description: 'Open an app/URL/folder/document in one call — replaces the Win+R route (activate, combo, type, enter). Aliases (calc), shell: folders, http/https URLs, absolute paths. Pass `args` to start an executable with arguments (a dedicated browser profile: target "…\\msedge.exe", args ["--user-data-dir=…", "--new-window", "<url>"]); each entry is one argument value, quoted once in the worker. Launched means the target reached the launcher, not that the app is ready; verify with computer_state.',
655
+ parameters: {
656
+ target: P('string', 'app alias (calc) | shell: folder | http/https URL | absolute path', { required: true }),
657
+ args: P('array', 'optional argument values for an executable target, one array entry per argument (e.g. ["--user-data-dir=D:\\\\profiles\\\\job", "--new-window", "https://…"]); no shell quoting — spaces and backslashes are ordinary'),
658
+ tailMs: P('number', 'how long to keep the status halo lit after the launch, 0–10000 ms (default 2500)'),
659
+ },
660
+ output: outJSON(),
661
+ isConcurrencySafe: () => false,
662
+ timeoutMs: 15_000,
663
+ async execute (args) { return cu.launch(validateLaunchTarget(args.target), { args: validateLaunchArgs(args.args) }) },
664
+ })
665
+
666
+ // ---------------- focus before typing ----------------
667
+ reg({
668
+ name: 'computer_focus',
669
+ description: 'Put keyboard focus on a FIELD and PROVE it before typing: clicks x,y, then reports the focused UIA control and whether a caret is provable there (OS caret, else a two-frame blink check). Typing follows the focused control, not the window — activating a dialog can leave focus in the document, where text is silently swallowed. Type only when verified:true.',
670
+ parameters: {
671
+ x: P('number', 'field centre x in screen pixels', { required: true }),
672
+ y: P('number', 'field centre y in screen pixels', { required: true }),
673
+ hwnd: P('number', 'target window; a modal dialog of the same app is accepted'),
674
+ verifyCaret: P('boolean', 'when the OS reports no caret, fall back to a two-frame blink check (default true)'),
675
+ },
676
+ output: outJSON(),
677
+ isConcurrencySafe: () => false,
678
+ timeoutMs: 15_000,
679
+ async execute (args) {
680
+ return cu.focusField({
681
+ x: args.x,
682
+ y: args.y,
683
+ ...(args.hwnd !== undefined ? { hwnd: args.hwnd } : {}),
684
+ ...(args.verifyCaret !== undefined ? { verifyCaret: args.verifyCaret } : {}),
685
+ })
686
+ },
687
+ })
688
+
567
689
  // ---------------- the agent asks ----------------
568
690
  reg({
569
691
  name: 'computer_ask',
@@ -624,6 +746,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
624
746
  parameters: {
625
747
  actions: P('array', '[{tool:"computer_click", args:{mark:"M3"}}, ...]', { required: true }),
626
748
  shot: P('string', 'auto (default) | always | never; no automatic image after failure, cancel or pause'),
749
+ delivery: P('string', 'auto (default) | path — the checkpoint keeps its absolute path but attaches no image'),
627
750
  },
628
751
  output: out((a, v) => {
629
752
  const { observation, ...receipt } = v
@@ -637,27 +760,47 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
637
760
  isConcurrencySafe: () => false,
638
761
  timeoutMs: 120_000,
639
762
  async execute (args, exec) {
640
- let automatic, feedback
641
- const result = await runBatch({ steps: args.actions, handlers: HANDLERS, signal: exec && exec.signal,
763
+ // Validate before any step runs: a bad delivery value must not be discovered only at the
764
+ // checkpoint, after the actions have already been dispatched.
765
+ const delivery = deliveryMode(args.delivery)
766
+ let automatic, feedback, lastContext = {}
767
+ const detail = selectFeedbackDetail(args.feedback,
768
+ config.actionFeedback === false ? 'none' : config.actionFeedbackDetail ?? 'compact')
769
+ // Batch still observes once at its final checkpoint. Capture the actual
770
+ // result coordinates without re-resolving potentially stale mark ids.
771
+ const feedbackHandlers = Object.fromEntries(Object.entries(HANDLERS).map(([name, handler]) => [name,
772
+ async (input, context) => {
773
+ const receipt = await handler(input, context)
774
+ lastContext = { args: input, receipt, tool: name }
775
+ return receipt
776
+ }]))
777
+ const result = await runBatch({ steps: args.actions, handlers: feedbackHandlers, signal: exec && exec.signal,
642
778
  stopped: () => cu.stopped || cu.exited, shot: args.shot, exec,
643
779
  assertOwnership: () => hooks?.assertOwnership?.(exec),
644
780
  screenshot: async () => {
645
- const mode = args.shot === 'always' ? 'image' : await feedbackMode(ctx, exec, config.actionFeedback !== false)
781
+ const policy = args.shot === 'always' ? { kind: 'image', detail: 'full', reason: 'explicit batch screenshot' }
782
+ : await feedbackPolicy(ctx, exec, config.actionFeedback !== false || args.feedback !== undefined, { rules: config.feedbackRules, detail })
646
783
  if (feedbackBlocked(exec)) { feedback = { observationSkipped: 'cancelled-or-paused' }; return null }
647
- if (mode === 'state') { feedback = await captureFeedback(exec, mode); return null }
648
- automatic = await takeScreenshot({}, exec)
784
+ if (!policy.kind) { feedback = { feedback: policy, observationSkipped: policy.reason || 'disabled' }; return null }
785
+ hooks?.assertOwnership?.(exec)
786
+ if (policy.kind === 'state') { feedback = await captureFeedback(exec, policy, lastContext, delivery); return null }
787
+ const captured = await captureFeedback(exec, policy, lastContext, delivery)
788
+ const { observation, ...details } = captured
789
+ automatic = observation
790
+ feedback = details
791
+ if (!automatic) return null
649
792
  return automatic.path
650
793
  } })
651
- return { ...selectBatchScreenshot(result, feedback?.observation ?? automatic, feedback?.feedback?.kind), ...feedback }
794
+ return { ...selectBatchScreenshot(result, feedback?.observation ?? automatic, feedback?.feedback?.kind, delivery), ...feedback }
652
795
  },
653
796
  })
654
797
 
655
798
  // ---------------- meta ----------------
656
799
  reg({
657
800
  name: 'computer_ctrl',
658
- 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.',
801
+ 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.',
659
802
  parameters: {
660
- action: P('string', 'release | selftest | calibrate | stop | exit | indicator | acknowledge | recover', { required: true }),
803
+ action: P('string', 'hold | free | release | clear_cache | selftest | calibrate | stop | exit | indicator | acknowledge | recover', { required: true }),
661
804
  why: P('string', 'reason recorded with a stop'),
662
805
  temporary: P('boolean', 'stop: explicitly agent-owned diagnostic pause, eligible for checked recovery; ordinary waits use computer_wait'),
663
806
  pauseId: P('string', 'recover: credential returned by an explicit temporary stop'),
@@ -671,6 +814,9 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
671
814
  timeoutMs: 30_000,
672
815
  async execute (args, exec) {
673
816
  switch (args.action) {
817
+ case 'clear_cache': return { ...observations.clear(exec), desktopInput: false }
818
+ case 'hold': return cu.indicator({ hold: true })
819
+ case 'free': return cu.indicator({ hold: false })
674
820
  case 'release': return hooks.releaseCycle(exec, args.why)
675
821
  case 'stop': return args.temporary ? hooks.temporaryStop(exec, args.why) : cu.panic(args.why)
676
822
  case 'recover': return hooks.recover(exec, args.pauseId)
@@ -706,7 +852,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
706
852
  const HANDLERS = {
707
853
  computer_click: clickTarget,
708
854
  computer_move: (a) => { const p = pt(a.at); return cu.move(p.x, p.y) },
709
- computer_drag: async (a) => cu.drag(await cu.resolveTarget(pt(a.from)), await cu.resolveTarget(pt(a.to))),
855
+ computer_drag: async (a) => cu.drag(await cu.resolveTarget(pt(a.from)), await cu.resolveTarget(pt(a.to)), { expectCursor: a.expectCursor, expectHwnd: a.hwnd }),
710
856
  computer_scroll: (a) => cu.scroll(a.dir, a.lines || 3, pt(a.at) ? { at: pt(a.at) } : {}),
711
857
  computer_select: async (a) => {
712
858
  const f = await cu.resolveTarget(pt(a.from)); const t = await cu.resolveTarget(pt(a.to))
@@ -716,6 +862,8 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
716
862
  computer_type: (a) => cu.type(a.text, { mode: a.mode, allowPassword: a.allowPassword }),
717
863
  computer_window: (a) => ((a.op || 'activate') === 'activate' ? cu.activateWindow({ hwnd: a.hwnd }) : cu.windowOp({ hwnd: a.hwnd }, a.op, rect(a.rect) || {})),
718
864
  computer_clip: (a) => (a.text === undefined ? cu.clipRead() : cu.clipWrite(a.text)),
865
+ computer_launch: (a) => cu.launch(validateLaunchTarget(a.target), { args: validateLaunchArgs(a.args) }),
866
+ computer_focus: (a) => cu.focusField({ x: a.x, y: a.y, ...(a.hwnd !== undefined ? { hwnd: a.hwnd } : {}), ...(a.verifyCaret !== undefined ? { verifyCaret: a.verifyCaret } : {}) }),
719
867
  computer_uia_act: actUia,
720
868
  computer_ask: (a, exec) => askQuestion(a, exec),
721
869
  computer_wait: (a) => a.mode === 'stable' ? cu.waitStable({ timeoutMs: a.timeoutMs, hwnd: a.hwnd })
@@ -749,6 +897,9 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
749
897
  computer_uia: queryUia,
750
898
  computer_ctrl: (a, exec) => {
751
899
  switch (a.action) {
900
+ case 'clear_cache': return { ...observations.clear(exec), desktopInput: false }
901
+ case 'hold': return cu.indicator({ hold: true })
902
+ case 'free': return cu.indicator({ hold: false })
752
903
  case 'release': return hooks.releaseCycle(exec, a.why)
753
904
  case 'stop': return a.temporary ? hooks.temporaryStop(exec, a.why) : cu.panic(a.why)
754
905
  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
  }