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.
- package/README.ar.md +234 -0
- package/README.de.md +234 -0
- package/README.es.md +264 -0
- package/README.fa.md +234 -0
- package/README.fr.md +243 -0
- package/README.md +72 -17
- package/README.pt.md +234 -0
- package/README.ru.md +259 -0
- package/README.zh.md +66 -16
- package/icon.svg +20 -4
- package/lib/audit.js +7 -0
- package/lib/batch.js +14 -19
- package/lib/bin/attention-worker.exe +0 -0
- package/lib/bin/attention-worker.exe.json +1 -1
- package/lib/compact-feedback.js +175 -0
- package/lib/continuation.js +1 -1
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
- package/lib/core/index.js +403 -136
- package/lib/core/native-runtime.js +17 -0
- package/lib/core/worker.cs +2160 -229
- package/lib/feedback-routes.json +11 -0
- package/lib/feedback.js +49 -16
- package/lib/image-output.js +87 -17
- package/lib/index.js +25 -7
- package/lib/key-command.js +37 -0
- package/lib/launch.js +67 -0
- package/lib/media-contract.js +43 -0
- package/lib/observations.js +267 -13
- package/lib/policy.js +29 -9
- package/lib/tool-contract.js +56 -0
- package/lib/tools.js +203 -52
- package/lib/verification-region.js +17 -0
- package/mcp/package.json +2 -1
- package/mcp/src/execution.js +8 -5
- package/mcp/src/index.js +16 -267
- package/mcp/src/tools.js +271 -0
- package/package.json +6 -1
- package/scripts/check-behavior.mjs +83 -0
- package/scripts/measure-tool-tokens.mjs +21 -0
- package/scripts/verify-package.mjs +9 -0
- package/skills/computer-use/SKILL.md +55 -43
- package/skills/computer-use/references/lifecycle.md +11 -7
- package/skills/computer-use/references/maintenance.md +9 -5
- package/skills/computer-use/references/precision.md +39 -14
- package/skills/computer-use/references/progressive-control.md +10 -4
package/lib/tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// tools.js — defineTool registrations for dsh-computer-use.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
92
|
-
|
|
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,
|
|
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,
|
|
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
|
|
135
|
-
|
|
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)
|
|
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 && !
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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;
|
|
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,
|
|
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
|
|
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,
|
|
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: "
|
|
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
|
|
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
|
-
|
|
641
|
-
|
|
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
|
|
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 (
|
|
648
|
-
|
|
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: '
|
|
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
|
-
"
|
|
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" },
|
package/mcp/src/execution.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
return
|
|
19
|
-
|
|
20
|
-
|
|
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
|
}
|