dsh-codex-style-computer-use 1.3.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.
@@ -346,6 +397,8 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
346
397
  depth: P('number', 'tree depth (default 6)'),
347
398
  since: P('string', 'base observation ID for changes; invalid base returns full'),
348
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'),
349
402
  },
350
403
  output: outObservation(),
351
404
  isConcurrencySafe: () => false,
@@ -388,15 +441,19 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
388
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.",
389
442
  parameters: {
390
443
  at: P('string', 'hit test at "x,y"'),
391
- hwnd: P('number', 'window; omit to search desktop'),
444
+ hwnd: P('number', 'required window scope for a tree query; at needs no tree'),
392
445
  role: P('string', 'Button | Edit | Hyperlink | TabItem | ...'),
393
446
  name: P('string', 'name substring'),
394
447
  id: P('string', 'exact AutomationId from UIA'),
395
448
  targets: P('boolean', 'recovery handles; default on for hwnd + name/id, off for broad queries'),
396
449
  limit: P('number', 'maximum matches, 1–80 (default 80)'),
397
- 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'),
398
454
  since: P('string', 'base observation ID; repeat the same query'),
399
455
  snapshot: P('string', 'retrieve a retained full observation ID'),
456
+ offset: P('number', 'snapshot only: first row, zero-based; limit bounds the page'),
400
457
  },
401
458
  output: outObservation(),
402
459
  isConcurrencySafe: () => false,
@@ -408,13 +465,15 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
408
465
  name: 'computer_uia_act',
409
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.",
410
467
  parameters: {
411
- hwnd: P('number', 'window (default: desktop)'),
468
+ hwnd: P('number', 'required window scope unless using an observed target'),
412
469
  name: P('string', 'element name substring'),
413
470
  id: P('string', 'exact AutomationId from UIA; scope with hwnd'),
414
471
  role: P('string', 'TabItem | Button | ListItem | ComboBox | Edit | MenuItem'),
415
472
  action: P('string', 'invoke (default) | select | expand | collapse | toggle | setValue | focus | scrollIntoView'),
416
473
  value: P('string', 'value for setValue'),
417
- 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)'),
418
477
  target: P('string', 'opaque target from computer_uia; do not combine with selectors'),
419
478
  rebind: P('boolean', 'allow unique recovery if the observed native element was replaced'),
420
479
  },
@@ -436,6 +495,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
436
495
  button: P('string', 'left (default) | right | middle'),
437
496
  clicks: P('number', '1 | 2 (double) | 3 (triple)'),
438
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'),
439
499
  },
440
500
  output: outJSON(),
441
501
  isConcurrencySafe: () => false,
@@ -445,7 +505,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
445
505
 
446
506
  reg({
447
507
  name: 'computer_move',
448
- 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.",
449
509
  parameters: { at: P('string', '"x,y"', { required: true }) },
450
510
  output: outJSON(),
451
511
  isConcurrencySafe: () => false,
@@ -459,16 +519,18 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
459
519
 
460
520
  reg({
461
521
  name: 'computer_drag',
462
- 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.",
463
523
  parameters: {
464
524
  from: P('string', '"M3" or "x,y"', { required: true }),
465
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'),
466
528
  },
467
529
  output: outJSON(),
468
530
  isConcurrencySafe: () => false,
469
531
  timeoutMs: 15_000,
470
532
  async execute (args) {
471
- 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 })
472
534
  },
473
535
  })
474
536
 
@@ -637,15 +699,32 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
637
699
  isConcurrencySafe: () => false,
638
700
  timeoutMs: 120_000,
639
701
  async execute (args, exec) {
640
- let automatic, feedback
641
- 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,
642
714
  stopped: () => cu.stopped || cu.exited, shot: args.shot, exec,
643
715
  assertOwnership: () => hooks?.assertOwnership?.(exec),
644
716
  screenshot: async () => {
645
- 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 })
646
719
  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)
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
649
728
  return automatic.path
650
729
  } })
651
730
  return { ...selectBatchScreenshot(result, feedback?.observation ?? automatic, feedback?.feedback?.kind), ...feedback }
@@ -655,9 +734,9 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
655
734
  // ---------------- meta ----------------
656
735
  reg({
657
736
  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.',
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.',
659
738
  parameters: {
660
- 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 }),
661
740
  why: P('string', 'reason recorded with a stop'),
662
741
  temporary: P('boolean', 'stop: explicitly agent-owned diagnostic pause, eligible for checked recovery; ordinary waits use computer_wait'),
663
742
  pauseId: P('string', 'recover: credential returned by an explicit temporary stop'),
@@ -671,6 +750,9 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
671
750
  timeoutMs: 30_000,
672
751
  async execute (args, exec) {
673
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 })
674
756
  case 'release': return hooks.releaseCycle(exec, args.why)
675
757
  case 'stop': return args.temporary ? hooks.temporaryStop(exec, args.why) : cu.panic(args.why)
676
758
  case 'recover': return hooks.recover(exec, args.pauseId)
@@ -706,7 +788,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
706
788
  const HANDLERS = {
707
789
  computer_click: clickTarget,
708
790
  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))),
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 }),
710
792
  computer_scroll: (a) => cu.scroll(a.dir, a.lines || 3, pt(a.at) ? { at: pt(a.at) } : {}),
711
793
  computer_select: async (a) => {
712
794
  const f = await cu.resolveTarget(pt(a.from)); const t = await cu.resolveTarget(pt(a.to))
@@ -749,6 +831,9 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
749
831
  computer_uia: queryUia,
750
832
  computer_ctrl: (a, exec) => {
751
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 })
752
837
  case 'release': return hooks.releaseCycle(exec, a.why)
753
838
  case 'stop': return a.temporary ? hooks.temporaryStop(exec, a.why) : cu.panic(a.why)
754
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
  }