dsh-codex-style-computer-use 1.2.0-rc.3

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/README.zh.md +132 -0
  4. package/cordis.patch.yml +17 -0
  5. package/lib/artifacts.js +35 -0
  6. package/lib/attention-native.js +93 -0
  7. package/lib/attention-worker.cs +194 -0
  8. package/lib/attention.js +140 -0
  9. package/lib/audit.js +30 -0
  10. package/lib/batch.js +52 -0
  11. package/lib/bin/attention-worker.exe +0 -0
  12. package/lib/bin/attention-worker.exe.json +7 -0
  13. package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
  14. package/lib/core/bin/dsh-computer-use-worker.exe.json +7 -0
  15. package/lib/core/compile-worker.ps1 +58 -0
  16. package/lib/core/index.js +844 -0
  17. package/lib/core/native-runtime.js +97 -0
  18. package/lib/core/worker.cs +6375 -0
  19. package/lib/cycle.js +314 -0
  20. package/lib/exposure.js +91 -0
  21. package/lib/feedback.js +35 -0
  22. package/lib/image-output.js +92 -0
  23. package/lib/index.js +1715 -0
  24. package/lib/observations.js +78 -0
  25. package/lib/policy.js +109 -0
  26. package/lib/recovery.js +8 -0
  27. package/lib/skill-gate.js +68 -0
  28. package/lib/tools.js +717 -0
  29. package/mcp/package.json +12 -0
  30. package/mcp/src/index.js +335 -0
  31. package/package.json +88 -0
  32. package/scripts/build-native.mjs +7 -0
  33. package/scripts/smoke-native.mjs +38 -0
  34. package/scripts/verify-package.mjs +28 -0
  35. package/skills/computer-use/SKILL.md +74 -0
  36. package/skills/computer-use/references/lifecycle.md +180 -0
  37. package/skills/computer-use/references/maintenance.md +56 -0
  38. package/skills/computer-use/references/precision.md +297 -0
  39. package/skills/computer-use/references/progressive-control.md +30 -0
  40. package/skills/computer-use/references/shared-input.md +27 -0
package/lib/tools.js ADDED
@@ -0,0 +1,717 @@
1
+ // tools.js — defineTool registrations for dsh-computer-use.
2
+ //
3
+ // 18 TOOLS. The previous 28 were merged, not deleted; every capability still exists and the
4
+ // behavioural detail lives in the skill (loaded only for computer-use work, while these
5
+ // definitions are paid for on EVERY request).
6
+ //
7
+ // Cost model (measured, 2026-09-13): the model payload is ~14.3k chars for the old surface, and
8
+ // the JSON-Schema scaffolding — one {type, description} object per parameter — dominated the
9
+ // prose. Nine parameters cost more than a 400-character description. Ranking of the levers:
10
+ // 1. fewer parameters 2. shorter parameter names 3. shorter descriptions 4. fewer tools
11
+ // Hence: whole tools fold into one action/flag parameter (clip read|write, wait idle|stable|
12
+ // change, window activate|minimize|…|move, ctrl stop|exit|selftest|calibrate|indicator), and
13
+ // multi-number arguments collapse into ONE "M3" | "x,y" | "x,y,w,h" string. Result ~7.1k chars.
14
+ //
15
+ // The DSL requires every `type: 'object'` node to declare additionalProperties outright
16
+ // (true/false, no default) or defineTool throws at load time.
17
+
18
+ import { runBatch, actionRefusal } from './batch.js'
19
+ import { saveOwnedShot } from './artifacts.js'
20
+ import { attachScreenshot, renderScreenshot, selectBatchScreenshot } from './image-output.js'
21
+ import { ObservationStore } from './observations.js'
22
+ import { feedbackMode, feedbackActions, feedbackVerification } from './feedback.js'
23
+
24
+ const P = (type, description, extra = {}) => ({ type, description, ...extra })
25
+ const cooperativeTools = new Set(['computer_click', 'computer_move', 'computer_drag', 'computer_scroll',
26
+ 'computer_select', 'computer_key', 'computer_type', 'computer_window', 'computer_uia_act', 'computer_batch', 'computer_clip'])
27
+ const usesInput = (name, args = {}) => name === 'computer_clip' ? args.text !== undefined :
28
+ name === 'computer_batch' ? (args.actions || []).some(step => usesInput(step.tool, step.args)) :
29
+ cooperativeTools.has(name)
30
+
31
+ /** "M3" | "x,y" | "x y" -> {mark:'M3'} | {x,y}. One parameter instead of four. */
32
+ const pt = (v) => {
33
+ if (v === undefined || v === null) return null
34
+ const s = String(v).trim()
35
+ const m = /^M(\d+)$/i.exec(s)
36
+ if (m) return { mark: 'M' + m[1] }
37
+ const c = s.split(/[,\s]+/).map(Number)
38
+ if (c.length >= 2 && Number.isFinite(c[0]) && Number.isFinite(c[1])) return { x: c[0], y: c[1] }
39
+ return null
40
+ }
41
+
42
+ /** "x,y,w,h" -> {x,y,width,height} */
43
+ const rect = (v) => {
44
+ if (v === undefined || v === null) return null
45
+ const c = String(v).split(/[,\s]+/).map(Number)
46
+ if (c.length >= 4 && c.every(Number.isFinite)) return { x: c[0], y: c[1], width: c[2], height: c[3] }
47
+ return null
48
+ }
49
+
50
+ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, defineTool, hooks, ensureCycle, register }) {
51
+ const observations = new ObservationStore()
52
+ const saveShot = (buf, ext = 'jpg') => {
53
+ const p = snapshotPath(dataDir).replace(/\.jpg$/, `.${ext}`)
54
+ return saveOwnedShot(dataDir, p, buf)
55
+ }
56
+
57
+ // Shared by direct calls, explicit batch steps, and the batch checkpoint.
58
+ const takeScreenshot = async (args, exec) => {
59
+ const r = rect(args.region)
60
+ const shot = await cu.screenshot({
61
+ x: r && r.x, y: r && r.y, width: r && r.width, height: r && r.height,
62
+ preset: args.preset, format: args.png ? 'png' : 'jpeg', cursor: true, wait: args.wait,
63
+ maxWidth: r && r.width <= 900 ? r.width : 1280,
64
+ })
65
+ const path = saveShot(shot.image, shot.mime === 'image/png' ? 'png' : 'jpg')
66
+ const res = { path, mime: shot.mime, region: shot.region }
67
+ if (typeof shot.settled === 'boolean') res.settled = shot.settled
68
+ return attachScreenshot(ctx, exec, res, shot.image)
69
+ }
70
+
71
+ const clickTarget = async (args) => {
72
+ const target = args.mark ? { mark: args.mark } : pt(args.at)
73
+ if (args.shift || args.dx !== undefined || args.dy !== undefined) {
74
+ const p = await cu.resolveTarget(target)
75
+ const x = p.x + (args.dx || 0), y = p.y + (args.dy || 0)
76
+ const options = { ...args, ...(p.expectHwnd ? { expectHwnd: p.expectHwnd } : {}) }
77
+ return args.shift ? cu.shiftClick(x, y, options) : cu.click({ x, y }, options)
78
+ }
79
+ return cu.click(target, args)
80
+ }
81
+
82
+ const queryUia = async (args, exec) => {
83
+ if (args.snapshot) return observations.get(exec, args.snapshot, 'uia')
84
+ const p = pt(args.at)
85
+ if (p && p.x !== undefined) return cu.call('uiaFromPoint', { x: p.x, y: p.y })
86
+ // Identity witnesses help an intended action, but are expensive for a broad inventory.
87
+ const targets = !!args.hwnd && (args.targets ?? !!(args.id || args.name))
88
+ const res = await cu.call('uia', { hwnd: args.hwnd, depth: args.depth ?? 16, maxNodes: 3000,
89
+ query: true, targets, role: args.role, nameContains: args.name, automationId: args.id, limit: args.limit ?? 80 })
90
+ return observations.publish(exec, 'uia', [args.hwnd ?? null, args.depth ?? 16, args.role ?? null, args.name ?? null, args.id ?? null, args.limit ?? 80, targets],
91
+ { count: res.count, elements: res.flat || [], scanned: res.scanned, truncated: res.truncated, hwnd: res.hwnd ?? args.hwnd, pid: res.pid }, args.since)
92
+ }
93
+ const actUia = (args, exec) => {
94
+ if (args.target && !observations.ownsTarget(exec, args.target)) {
95
+ 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' })
96
+ }
97
+ return cu.uiaAct({ hwnd: args.hwnd, nameContains: args.name, automationId: args.id, target: args.target, allowRebind: args.rebind === true,
98
+ role: args.role, action: args.action, value: args.value, requireUnique: true, depth: args.depth ?? 24, maxNodes: 3500 })
99
+ }
100
+ const observeState = async (args, exec) => {
101
+ if (args.snapshot) return observations.get(exec, args.snapshot, 'state')
102
+ const result = args.windows ? await cu.call('windows', {}) : await cu.screenState({ uiaDepth: args.depth })
103
+ return observations.publish(exec, 'state', [!!args.windows, args.depth ?? 6], result, args.since)
104
+ }
105
+
106
+ // Lossless-JSON guard on EVERY tool result. 2026-09-12: a tool returned `settleNote: undefined`
107
+ // and the harness rejected the whole result as "value is not lossless JSON" AFTER the work was
108
+ // done — the screenshot existed but its path never reached the model. Normalise once, here.
109
+ const lossless = (v) => {
110
+ if (v === undefined) return undefined
111
+ try {
112
+ return JSON.parse(JSON.stringify(v, (k, x) => (typeof x === 'number' && !Number.isFinite(x) ? null : x)))
113
+ } catch { return { error: 'tool result was not serialisable' } }
114
+ }
115
+
116
+ // one implementation for the tool AND for computer_batch (merge-design.md §5.4 item 23): a batch
117
+ // step that asks a question must reach the SAME card and the SAME answer, and there must not be
118
+ // two orchestrations to keep in step. The pause decision travels inside the answer record that
119
+ // `hooks.askHuman` returns (it is the human's own choice on the card, D3), so nothing here derives
120
+ // it from how the call ended.
121
+ const askQuestion = (args, exec) => cu.ask({
122
+ question: args && args.question,
123
+ onAsk: (info) => hooks.askHuman(args && args.question, info, exec),
124
+ })
125
+
126
+ const feedbackBlocked = (exec) => {
127
+ if (exec?.signal?.aborted || cu.stopped || cu.exited || cu.cooperation?.active) return true
128
+ try { return !!hooks?.askBlocked?.('computer_shot') } catch { return true }
129
+ }
130
+ const captureFeedback = async (exec, mode) => {
131
+ // Route resolution is async: the human can brake while it is pending.
132
+ if (feedbackBlocked(exec)) return { observationSkipped: 'cancelled-or-paused' }
133
+ const observation = mode === 'state' ? await observeState({}, exec) : await takeScreenshot({}, exec)
134
+ return { observation, feedback: { kind: mode }, verification: feedbackVerification }
135
+ }
136
+
137
+ const renderFeedback = (original, args, value) => {
138
+ if (!value?.feedback && !value?.observationError && !value?.observationSkipped) return original(args, value)
139
+ const { observation, feedback, ...receipt } = value
140
+ return [
141
+ ...original(args, receipt),
142
+ ...(observation ? feedback?.kind === 'image' ? renderScreenshot(observation)
143
+ : [{ type: 'text', text: JSON.stringify({ feedback, observation }) }] : []),
144
+ ]
145
+ }
146
+
147
+ const reg = (def) => {
148
+ const inner = def.execute
149
+ const hasFeedback = feedbackActions.has(def.name) && config.actionFeedback !== false
150
+ if (hasFeedback) {
151
+ def.description += ' Returns a route-specific checkpoint.'
152
+ const original = def.output.render
153
+ def.output.render = (args, value) => renderFeedback(original, args, value)
154
+ // Input plus the existing bounded screenshot/state call share one deadline.
155
+ def.timeoutMs += 30_000
156
+ }
157
+ // Native calls retain their short failure deadlines. Only the local, event-driven
158
+ // human wait may be long; the owning tool's cancellation still releases it immediately.
159
+ if (cooperativeTools.has(def.name)) def.timeoutMs = Math.max(def.timeoutMs, 3_600_000)
160
+ def.execute = async (args, exec) => {
161
+ // hooks drive the "the agent stopped driving" signal: the activity border is cut the
162
+ // instant the last computer-use call of a turn finishes.
163
+ if (hooks && hooks.onStart) { try { hooks.onStart(exec) } catch { /* never break a tool */ } }
164
+ try {
165
+ // ---- THE BRAKE IS A VERDICT: STOP, DO NOT TAKE ONE MORE STEP -------------------------------
166
+ // The human's brake stops the MACHINE; before this check it did not stop the AGENT, because
167
+ // observations stayed legal while paused and `agent.steer`'s "wrap up" notice only lands at a
168
+ // step boundary. The agent therefore always had one more legal move and kept taking it — the
169
+ // human's own report: "卡停就是让你停住, 关键是你没停啊? 你还在思考中啊". Refusing HERE, at the
170
+ // one door every computer_* call goes through, makes the wrap-up directive the RESULT of the
171
+ // call already being made, so there is no boundary to wait for and nothing to miss.
172
+ // `hooks.askBlocked` returns null unless the cycle is actually paused, and null for the
173
+ // safety-direction tools (`computer_ctrl`, `computer_ask` — see lib/index.js); `ended` remains
174
+ // the cycle gate's verdict, not this one's.
175
+ if (hooks && hooks.askBlocked) {
176
+ let why = null
177
+ try { why = hooks.askBlocked(def.name) } catch { why = null } // never let the guard break a tool
178
+ if (why) {
179
+ const err = new Error(why)
180
+ err.dshCycleRefusal = true
181
+ throw err
182
+ }
183
+ }
184
+ // ---- THE CYCLE IS OPENED HERE, AND `await` IS THE WHOLE POINT ---------------------------------
185
+ // This wrapper is the ONE point every computer_* invocation passes through exactly once, and
186
+ // it is the LAST point before the tool body (and therefore the worker op) runs: the await
187
+ // below resolves — the worker having ANSWERED the `resume` op — before `inner()` is even
188
+ // entered, let alone before any `cu.*` call writes to the worker's stdin. Ordering is
189
+ // enforced by the same async chain, so there is nothing to race: no host event has to arrive
190
+ // first, no session has to be recognised first. It is also why this is not in
191
+ // tools/pre-execute: that hook is a host waterfall whose await semantics belong to the host,
192
+ // while this one is a line of our own code, and it covers every dispatch path there is —
193
+ // including computer_batch, whose steps go straight to the shared HANDLERS map (the cycle is
194
+ // already open and the latch makes the second call a no-op).
195
+ // `ensureCycle` itself is what decides whether an opener is needed at all, and it is the
196
+ // component that refuses to touch a brake (see lib/index.js).
197
+ // A call that is ALREADY cancelled never reaches the worker (the same check runs again after
198
+ // the opener is awaited — the await is exactly where a human's Ctrl+Alt+Q can land).
199
+ if (exec && exec.signal && exec.signal.aborted) {
200
+ const err = new Error('not dispatched: this call was cancelled before dispatch')
201
+ err.dshCycleRefusal = true
202
+ throw err
203
+ }
204
+ if (ensureCycle) {
205
+ let opened = null
206
+ try { opened = await ensureCycle(exec) } catch (e) {
207
+ // An OWNERSHIP refusal is a VERDICT, not a hiccup: another session owns this machine
208
+ // right now, so this call must fail loudly rather than drive anyway (lib/cycle.js tags
209
+ // it; a failed *opener* keeps the old quiet path — the worker's own refusal follows).
210
+ if (e && e.dshCycleRefusal) throw e
211
+ if (log && typeof log.warn === 'function') log.warn(`cycle open failed: ${e.message}`)
212
+ }
213
+ // A VOIDED OPENING IS A REFUSAL, NOT A WARNING (integration gap, Codex review 2026-09-13).
214
+ // Ignoring this return value let an op whose opening the exit had already cancelled go on
215
+ // to be written to the worker — and the opener's `resume` may have cleared the record a
216
+ // moment before the human ended the session again, so the worker had nothing left to refuse.
217
+ // The same check runs on the cancellation signal, which can also fire while the opener is
218
+ // awaited; both are checked at the point where the op would enter the worker, not earlier.
219
+ if (opened && opened.voided) {
220
+ const err = new Error(
221
+ `not dispatched: ${opened.reason}. The computer-use cycle was ended while this call was ` +
222
+ 'opening it, so nothing was sent to the worker. Do not retry in this turn — a new cycle ' +
223
+ 'needs a new turn, and Ctrl+Alt+R is the human\'s.')
224
+ err.dshCycleRefusal = true
225
+ throw err
226
+ }
227
+ if (exec && exec.signal && exec.signal.aborted) {
228
+ const err = new Error('not dispatched: this call was cancelled while the cycle was opening')
229
+ err.dshCycleRefusal = true
230
+ throw err
231
+ }
232
+ }
233
+ // ---- THE ONLY HONEST PLACE TO SAY "THIS ACTUATION REACHED THE WORKER" (2026-09-16) ----------
234
+ // Every refusal above this line THROWS (an ownership verdict, a voided opening, a cancellation),
235
+ // and the brake/policy refusals live ABOVE the whole wrapper in `reg`. So this point — the last
236
+ // statement before the tool body — is the one where "it will actually be dispatched" is true.
237
+ // The turn-end card's recommendation is derived from exactly this (see the plugin's
238
+ // `turnSituation`): a call that was denied, refused or cancelled must NOT claim the task was in
239
+ // motion, and a call the human APPROVED high-risk must not be invisible either. An earlier version
240
+ // marked it in `tools/pre-execute`, which runs BEFORE all of these, and produced both errors.
241
+ // `hooks` IS OPTIONAL HERE, not just the member: `registerTools` may be called without one (a
242
+ // registration-only probe does exactly that), and the earlier `hooks.onDispatch` form threw
243
+ // `Cannot read properties of undefined` straight into the dispatch path.
244
+ const invoke = async () => {
245
+ if (usesInput(def.name, args) && cu.waitInputReady) await cu.waitInputReady()
246
+ if (hooks && hooks.onDispatch) { try { hooks.onDispatch(exec) } catch { /* never break a dispatch for a log */ } }
247
+ return inner(args, exec)
248
+ }
249
+ const out = cu.withInputContext ? await cu.withInputContext(exec, invoke) : await invoke()
250
+ if (out === undefined || out === null) return out
251
+ let observed = out
252
+ if (hasFeedback && !actionRefusal({ tool: def.name, args }, out) && !feedbackBlocked(exec)) {
253
+ try {
254
+ const mode = await feedbackMode(ctx, exec, true)
255
+ if (mode) observed = { ...out, ...await captureFeedback(exec, mode) }
256
+ } catch (error) {
257
+ // The input already returned. A failed optional observation must never
258
+ // hide that receipt or turn it into an apparent failed/retryable write.
259
+ observed = { ...out, observationError: String(error.message || error), verification: feedbackVerification }
260
+ }
261
+ }
262
+ const safe = lossless(observed)
263
+ return safe === undefined ? null : safe
264
+ } finally {
265
+ if (hooks && hooks.onEnd) { try { hooks.onEnd(exec) } catch { /* never break a tool */ } }
266
+ }
267
+ }
268
+ const definition = defineTool(def)
269
+ if (register) register(definition)
270
+ else ctx.tools.register(definition)
271
+ }
272
+
273
+ // Render helpers.
274
+ //
275
+ // BUG (2026-09-13, exposed by the very first call after deploying the rewrite): `out(json)`
276
+ // handed the JSON helper straight to `render(a, v)`, so the ARGS OBJECT landed in `json`'s
277
+ // second parameter — which is `cap` — and `String.slice(0, {})` coerces to `.slice(0, 0)`:
278
+ // every tool using it returned an EMPTY string, silently. A render function takes (args, value)
279
+ // and must ignore args; `outJSON()` is the only correct way to ask for the plain-JSON render.
280
+ // build/test-renders.mjs now fails loudly if any registered tool renders empty.
281
+ const TXT = (v, cap = 30_000) => [{ type: 'text', text: typeof v === 'string' ? v.slice(0, cap) : JSON.stringify(v ?? null, null, 1).slice(0, cap) }]
282
+ const out = (render) => ({ schema: { type: 'object', additionalProperties: true }, render })
283
+ const outJSON = () => out((a, v) => TXT(v))
284
+ const outObservation = () => out((_a, value) => [{ type: 'text', text: JSON.stringify(value) }])
285
+
286
+ // ---------------- observation ----------------
287
+ reg({
288
+ name: 'computer_shot',
289
+ description: "Screenshot; inspect its attachment directly, or follow view if path-only. KICKOFF: first computer_state {windows:true}, then activate the target with computer_window; require activated:true. settled:false needs a fresh shot.",
290
+ parameters: {
291
+ region: P('string', 'crop "x,y,w,h" (default: whole screen)'),
292
+ preset: P('string', 'taskbar'),
293
+ png: P('boolean', 'PNG instead of JPEG'),
294
+ wait: P('string', 'auto (default) | none'),
295
+ },
296
+ output: out((a, v) => renderScreenshot(v)),
297
+ isConcurrencySafe: () => false,
298
+ timeoutMs: 30_000,
299
+ execute: takeScreenshot,
300
+ })
301
+
302
+ reg({
303
+ name: 'computer_state',
304
+ description: "Window/UIA state; start with windows:true, then activate the target. since returns fresh changes against that observation; omit for full. snapshot retrieves a retained full observation for this Agent.",
305
+ parameters: {
306
+ windows: P('boolean', 'windows only (no tree)'),
307
+ depth: P('number', 'tree depth (default 6)'),
308
+ since: P('string', 'base observation ID for changes; invalid base returns full'),
309
+ snapshot: P('string', 'retrieve this full observation without capturing again'),
310
+ },
311
+ output: outObservation(),
312
+ isConcurrencySafe: () => false,
313
+ timeoutMs: 30_000,
314
+ execute: observeState,
315
+ })
316
+
317
+ reg({
318
+ name: 'computer_marks',
319
+ description: "Cached UIA map with snapshot IDs. Reuse while unchanged; mark actions check window identity, geometry and sampled pixels. STALE_MARK requires refresh and reselecting the intended element. Refreshed IDs replace old IDs.",
320
+ parameters: {
321
+ hwnd: P('number', 'window (default: active)'),
322
+ shot: P('boolean', 'also return a marked-up screenshot path'),
323
+ refresh: P('boolean', 'rebuild instead of reusing the cache'),
324
+ },
325
+ output: out((a, v) => {
326
+ if (!v || !v.marks) return [{ type: 'text', text: 'no marks' }]
327
+ const head = `marks ${v.count}${v.reused ? ' (cached)' : ''} hwnd=${v.hwnd}` + (v.annotatedShot ? `\nannotatedShot: ${v.annotatedShot}` : '')
328
+ return [{ type: 'text', text: head + '\n' + v.marks.map(m => `${m.id} [${m.role}] (${m.center[0]},${m.center[1]}) ${m.name}`).join('\n') }]
329
+ }),
330
+ isConcurrencySafe: () => false,
331
+ timeoutMs: 45_000,
332
+ async execute (args) {
333
+ const res = await cu.landmarks({ hwnd: args.hwnd, refresh: args.refresh })
334
+ const res2 = {
335
+ count: res.count, reused: !!res.reused, hwnd: res.hwnd,
336
+ marks: res.marks.map(m => ({ id: m.id, role: m.role, name: m.name, center: [m.cx, m.cy] })),
337
+ }
338
+ if (args.shot) {
339
+ const m = await cu.marks({ annotated: true, hwnd: args.hwnd, landmarkMap: res })
340
+ if (m.annotated) res2.annotatedShot = saveShot(m.annotated)
341
+ }
342
+ return res2
343
+ },
344
+ })
345
+
346
+ reg({
347
+ name: 'computer_uia',
348
+ 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.",
349
+ parameters: {
350
+ at: P('string', 'hit test at "x,y"'),
351
+ hwnd: P('number', 'window; omit to search desktop'),
352
+ role: P('string', 'Button | Edit | Hyperlink | TabItem | ...'),
353
+ name: P('string', 'name substring'),
354
+ id: P('string', 'exact AutomationId from UIA'),
355
+ targets: P('boolean', 'recovery handles; default on for hwnd + name/id, off for broad queries'),
356
+ limit: P('number', 'maximum matches, 1–80 (default 80)'),
357
+ depth: P('number', 'tree depth, 1–24 (default 16)'),
358
+ since: P('string', 'base observation ID; repeat the same query'),
359
+ snapshot: P('string', 'retrieve a retained full observation ID'),
360
+ },
361
+ output: outObservation(),
362
+ isConcurrencySafe: () => false,
363
+ timeoutMs: 45_000,
364
+ execute: queryUia,
365
+ })
366
+
367
+ reg({
368
+ name: 'computer_uia_act',
369
+ 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.",
370
+ parameters: {
371
+ hwnd: P('number', 'window (default: desktop)'),
372
+ name: P('string', 'element name substring'),
373
+ id: P('string', 'exact AutomationId from UIA; scope with hwnd'),
374
+ role: P('string', 'TabItem | Button | ListItem | ComboBox | Edit | MenuItem'),
375
+ action: P('string', 'invoke (default) | select | expand | collapse | toggle | setValue | focus | scrollIntoView'),
376
+ value: P('string', 'value for setValue'),
377
+ depth: P('number', 'tree depth, 1–24 (default 24)'),
378
+ target: P('string', 'opaque target from computer_uia; do not combine with selectors'),
379
+ rebind: P('boolean', 'allow unique recovery if the observed native element was replaced'),
380
+ },
381
+ output: outJSON(),
382
+ isConcurrencySafe: () => false,
383
+ timeoutMs: 20_000,
384
+ execute: actUia,
385
+ })
386
+
387
+ // ---------------- actuation ----------------
388
+ reg({
389
+ name: 'computer_click',
390
+ description: "Click mark or screen coordinates; dx/dy offset either form. Mark IDs refer to an observed snapshot, not a moving target. STALE_MARK needs a fresh map and re-selection. Check returned focus and receiver.",
391
+ parameters: {
392
+ mark: P('string', 'mark id, e.g. "M7" (preferred)'),
393
+ at: P('string', '"x,y" instead of a mark'),
394
+ dx: P('number', 'offset from the mark centre: +x px'),
395
+ dy: P('number', 'offset from the mark centre: +y px'),
396
+ button: P('string', 'left (default) | right | middle'),
397
+ clicks: P('number', '1 | 2 (double) | 3 (triple)'),
398
+ shift: P('boolean', 'hold SHIFT (range selection)'),
399
+ },
400
+ output: outJSON(),
401
+ isConcurrencySafe: () => false,
402
+ timeoutMs: 15_000,
403
+ execute: clickTarget,
404
+ })
405
+
406
+ reg({
407
+ name: 'computer_move',
408
+ description: "Move without clicking to reveal hover controls.",
409
+ parameters: { at: P('string', '"x,y"', { required: true }) },
410
+ output: outJSON(),
411
+ isConcurrencySafe: () => false,
412
+ timeoutMs: 15_000,
413
+ async execute (args) {
414
+ const p = pt(args.at)
415
+ if (!p || p.x === undefined) throw new Error('at must be "x,y"')
416
+ return cu.move(p.x, p.y)
417
+ },
418
+ })
419
+
420
+ reg({
421
+ name: 'computer_drag',
422
+ description: "Drag between measured points. Prefer computer_select for text ranges.",
423
+ parameters: {
424
+ from: P('string', '"M3" or "x,y"', { required: true }),
425
+ to: P('string', '"M7" or "x,y"', { required: true }),
426
+ },
427
+ output: outJSON(),
428
+ isConcurrencySafe: () => false,
429
+ timeoutMs: 15_000,
430
+ async execute (args) {
431
+ return cu.drag(await cu.resolveTarget(pt(args.from)), await cu.resolveTarget(pt(args.to)))
432
+ },
433
+ })
434
+
435
+ reg({
436
+ name: 'computer_scroll',
437
+ description: "Wheel at a mark/point, or at the current pointer when at is omitted. Refresh marks after scrolling.",
438
+ parameters: {
439
+ dir: P('string', 'up | down | left | right', { required: true }),
440
+ at: P('string', 'scroll at "M3" or "x,y"'),
441
+ lines: P('number', 'notches (default 3)'),
442
+ },
443
+ output: outJSON(),
444
+ isConcurrencySafe: () => false,
445
+ timeoutMs: 15_000,
446
+ async execute (args) {
447
+ const at = pt(args.at)
448
+ return cu.scroll(args.dir, args.lines || 3, at ? { at } : {})
449
+ },
450
+ })
451
+
452
+ reg({
453
+ name: 'computer_select',
454
+ description: "Click anchor, optionally scroll, then shift-click the end. Use measured points valid for each step.",
455
+ parameters: {
456
+ from: P('string', 'anchor "M3" or "x,y"', { required: true }),
457
+ to: P('string', 'end "M7" or "x,y"', { required: true }),
458
+ clicks: P('number', 'clicks on each end: 2 word | 3 block (default 1 caret)'),
459
+ scroll: P('number', 'wheel notches between the ends (negative = up)'),
460
+ },
461
+ output: outJSON(),
462
+ isConcurrencySafe: () => false,
463
+ timeoutMs: 30_000,
464
+ async execute (args) {
465
+ const a = await cu.resolveTarget(pt(args.from))
466
+ const b = await cu.resolveTarget(pt(args.to))
467
+ return cu.selectRange({
468
+ fromX: a.x, fromY: a.y, toX: b.x, toY: b.y,
469
+ fromClicks: args.clicks, toClicks: args.clicks, scrollClicks: args.scroll,
470
+ })
471
+ },
472
+ })
473
+
474
+ reg({
475
+ name: 'computer_key',
476
+ description: "Press a key combo, e.g. ctrl+shift+t. Activate the target first; missing target or failed refocus refuses input.",
477
+ parameters: { combo: P('string', 'e.g. "ctrl+a"', { required: true }) },
478
+ output: outJSON(),
479
+ isConcurrencySafe: () => false,
480
+ timeoutMs: 15_000,
481
+ async execute (args) { return cu.key(args.combo) },
482
+ })
483
+
484
+ reg({
485
+ name: 'computer_type',
486
+ description: "Type into the focused field; target it first. CJK/long text uses paste. Check receiver/readback. Passwords require existing explicit consent and allowPassword.",
487
+ parameters: {
488
+ text: P('string', 'text to type', { required: true }),
489
+ mode: P('string', 'unicode | paste (default: auto)'),
490
+ allowPassword: P('boolean', 'human consented to typing a password'),
491
+ },
492
+ output: outJSON(),
493
+ isConcurrencySafe: () => false,
494
+ timeoutMs: 30_000,
495
+ async execute (args) { return cu.type(args.text, { mode: args.mode, allowPassword: args.allowPassword }) },
496
+ })
497
+
498
+ reg({
499
+ name: 'computer_window',
500
+ description: "Activate a known hwnd and require activated:true before capture/input. Other operations: minimize, maximize, restore, close, move. A failed activation stops a batch.",
501
+ parameters: {
502
+ hwnd: P('number', 'window handle', { required: true }),
503
+ op: P('string', 'activate (default) | minimize | maximize | restore | close | move'),
504
+ rect: P('string', 'for move: "x,y,w,h"'),
505
+ },
506
+ output: outJSON(),
507
+ isConcurrencySafe: () => false,
508
+ timeoutMs: 15_000,
509
+ async execute (args) {
510
+ const sel = { hwnd: args.hwnd }
511
+ const op = args.op || 'activate'
512
+ if (op === 'activate') return cu.activateWindow(sel)
513
+ return cu.windowOp(sel, op, rect(args.rect) || {})
514
+ },
515
+ })
516
+
517
+ reg({
518
+ name: 'computer_clip',
519
+ description: "Read the clipboard, or write text. Use only content needed for the task.",
520
+ parameters: { text: P('string', 'text to put on the clipboard; omit to read') },
521
+ output: out((a, v) => [{ type: 'text', text: JSON.stringify(v).slice(0, 10_000) }]),
522
+ isConcurrencySafe: () => false,
523
+ timeoutMs: 10_000,
524
+ async execute (args) { return args.text === undefined ? cu.clipRead() : cu.clipWrite(args.text) },
525
+ })
526
+
527
+ // ---------------- the agent asks ----------------
528
+ reg({
529
+ name: 'computer_ask',
530
+ description: "Pause and ask one necessary question on the DSH chat card; no countdown. The human selects then confirms. ② 放你走 (recommended), a custom answer, dismissal or cancellation releases the ask brake; ① 让你停 keeps it until Ctrl+Alt+R. Inspect keepPause. Existing authorization needs no repeat question.",
531
+ parameters: {
532
+ question: P('string', 'the one short question to put in front of the human', { required: true }),
533
+ },
534
+ output: outJSON(),
535
+ isConcurrencySafe: () => false,
536
+ // THE TOOL'S DEADLINE IS A WEDGE DETECTOR, NOT A GUILLOTINE (merge-design.md §3, D2) — AND IT IS
537
+ // ALSO THE CARD'S REAPER. Both halves matter, and the second one is measured
538
+ // (merge-abort-evidence.md, V1 caveat):
539
+ //
540
+ // * The host's timeout policy is COOPERATIVE: `deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)`
541
+ // (dsh-tool-call-timeout-policy/lib/index.js:125, dsh-timeout/lib/index.js:63-72,
542
+ // `AbortSignal.any([upstream, timer.signal])`) aborts a signal, it does not cancel the body —
543
+ // the wrapper `await next()`s and substitutes a TOOL_TIMEOUT result only AFTER our promise
544
+ // settles (:129-131). So the `finally` in `core.ask()` always gets to release the brake.
545
+ // * But the signal it aborts is THE SAME ONE we hand to `ask({signal})`. So at T+1 h the human's
546
+ // CARD IS TORN DOWN, `ask` settles `ASK_ABORTED`, the brake is released, and only then does
547
+ // the policy replace the result with TOOL_TIMEOUT. That is correct behaviour, and it is why
548
+ // this number must stay far above any human answer time.
549
+ //
550
+ // THE HUMAN ASKED FOR "WAIT FOR THE ANSWER FOR EVER, NO TIMEOUT". This one line is where that
551
+ // instruction is implemented as a one-hour compromise: a truly unbounded wait is permitted (omit
552
+ // `timeoutMs` and the policy returns early at :124), but then a bug — an answerer that never
553
+ // settles — hangs a turn with no ceiling and no `TOOL_TIMEOUT` breadcrumb. One hour is long
554
+ // enough that no human answer is ever cut off at the card, and short enough that a genuine wedge
555
+ // is diagnosable. It must stay ABOVE the core's own 15 s ask deadline, which is a literal and is
556
+ // never derived from this one (G3). The old 75_000 was 60 s of worker-side countdown plus slack,
557
+ // and its whole reason for existing is gone.
558
+ timeoutMs: 3_600_000,
559
+ async execute (args, exec) { return askQuestion(args, exec) },
560
+ })
561
+
562
+ // ---------------- flow ----------------
563
+ reg({
564
+ name: 'computer_wait',
565
+ description: "Wait for idle, stable pixels or a pixel change. For pixel modes, pass an observed hwnd to exclude unrelated desktop animation. A change or timeout does not establish task success/failure; inspect before replaying a write.",
566
+ parameters: {
567
+ mode: P('string', 'idle (default) | stable | change'),
568
+ timeoutMs: P('number', 'hard cap (default 5000/8000/6000 by mode)'),
569
+ hwnd: P('number', 'observed window for stable/change; omit for the whole virtual desktop'),
570
+ },
571
+ output: outJSON(),
572
+ isConcurrencySafe: () => false,
573
+ timeoutMs: 35_000,
574
+ async execute (args) {
575
+ if (args.mode === 'stable') return cu.waitStable({ timeoutMs: args.timeoutMs, hwnd: args.hwnd })
576
+ if (args.mode === 'change') return cu.waitChange({ timeoutMs: args.timeoutMs, hwnd: args.hwnd })
577
+ return cu.waitForIdle({ timeoutMs: args.timeoutMs })
578
+ },
579
+ })
580
+
581
+ reg({
582
+ name: 'computer_batch',
583
+ description: "Run actions serially to a checkpoint; failure or cancellation stops later steps. Reuse readback; auto adds one needed image or route-specific UIA state. Action ok does not verify the business result.",
584
+ parameters: {
585
+ actions: P('array', '[{tool:"computer_click", args:{mark:"M3"}}, ...]', { required: true }),
586
+ shot: P('string', 'auto (default) | always | never; no automatic image after failure, cancel or pause'),
587
+ },
588
+ output: out((a, v) => {
589
+ const { observation, ...receipt } = v
590
+ return [
591
+ ...TXT(receipt),
592
+ ...(observation ? v.feedback?.kind === 'state'
593
+ ? [{ type: 'text', text: JSON.stringify({ observation }) }]
594
+ : renderScreenshot(observation) : []),
595
+ ]
596
+ }),
597
+ isConcurrencySafe: () => false,
598
+ timeoutMs: 120_000,
599
+ async execute (args, exec) {
600
+ let automatic, feedback
601
+ const result = await runBatch({ steps: args.actions, handlers: HANDLERS, signal: exec && exec.signal,
602
+ stopped: () => cu.stopped || cu.exited, shot: args.shot, exec,
603
+ screenshot: async () => {
604
+ const mode = args.shot === 'always' ? 'image' : await feedbackMode(ctx, exec, config.actionFeedback !== false)
605
+ if (feedbackBlocked(exec)) { feedback = { observationSkipped: 'cancelled-or-paused' }; return null }
606
+ if (mode === 'state') { feedback = await captureFeedback(exec, mode); return null }
607
+ automatic = await takeScreenshot({}, exec)
608
+ return automatic.path
609
+ } })
610
+ return { ...selectBatchScreenshot(result, feedback?.observation ?? automatic, feedback?.feedback?.kind), ...feedback }
611
+ },
612
+ })
613
+
614
+ // ---------------- meta ----------------
615
+ reg({
616
+ name: 'computer_ctrl',
617
+ description: 'Control. "stop" = raise the emergency brake; human/default stops require Ctrl+Alt+R. temporary:true creates your diagnostic pause. recover requires pauseId and a fresh observation of the resolved blocker. exit ends the cycle. selftest/calibrate diagnose input; indicator controls the border; acknowledge accepts the exact skill receipt.',
618
+ parameters: {
619
+ action: P('string', 'selftest | calibrate | stop | exit | indicator | acknowledge | recover', { required: true }),
620
+ why: P('string', 'reason recorded with a stop'),
621
+ temporary: P('boolean', 'stop: explicitly agent-owned diagnostic pause, eligible for checked recovery; ordinary waits use computer_wait'),
622
+ pauseId: P('string', 'recover: credential returned by an explicit temporary stop'),
623
+ on: P('boolean', 'indicator: true = light, false = clear'),
624
+ text: P('string', 'acknowledge: the exact phrase quoted from the computer-use skill; every DRIVING call is refused until it is passed'),
625
+ },
626
+ output: out((a, v) => TXT(v.mode
627
+ ? { mode: v.mode, inputPass: v.inputPass, maxErrorPx: v.maxErrorPx, visualAnchor: v.visualAnchor }
628
+ : v)),
629
+ isConcurrencySafe: () => false,
630
+ timeoutMs: 30_000,
631
+ async execute (args, exec) {
632
+ switch (args.action) {
633
+ case 'stop': return args.temporary ? hooks.temporaryStop(exec, args.why) : cu.panic(args.why)
634
+ case 'recover': return hooks.recover(exec, args.pauseId)
635
+ // THE READ-RECEIPT (2026-09-13, human's rule: "未先读 skill 时拒绝驱动类 op"). The plugin's
636
+ // tools/pre-execute gate refuses every DRIVING call until the session has produced the exact
637
+ // phrase that exists only inside the computer-use skill; this action is how it is produced.
638
+ // The gate validated `args.text` before this handler ran, so there is nothing to check here.
639
+ case 'acknowledge': return { acknowledged: true, note: 'procedure acknowledged — driving is open for this session' }
640
+ // "exit" was advertised in the description above but NEVER WIRED here: the call fell
641
+ // through to `default: selftest`, so computer_ctrl{action:"exit"} returned an input-path
642
+ // health report instead of ending the session. The batch handler below always had it.
643
+ case 'exit': return cu.exit(args.why)
644
+ case 'calibrate': return cu.calibrate({})
645
+ case 'indicator': return cu.indicator({ on: args.on !== false })
646
+ // The `resume` ACTION IS GONE (2026-09-13). It used to call the core's cycle opener, which
647
+ // un-latches Ctrl+Alt+Q — so a TOOL CALL could re-open a cycle the human had ended. A new
648
+ // cycle is opened by the PLUGIN, never by an op the model can name: `ensureCycle` does it,
649
+ // once per turn, on the first computer_* call that actually reaches the worker (and never
650
+ // while a brake is up), while a pause is released only by the human's Ctrl+Alt+R.
651
+ // Refusing LOUDLY matters: falling through to `default: selftest` would hand a stale
652
+ // caller a health report it could read as a successful re-arm.
653
+ case 'resume': throw new Error(
654
+ 'action "resume" no longer exists: no tool call may open or re-open a computer-use ' +
655
+ 'cycle. After Ctrl+Alt+Q the PLUGIN opens a new cycle on the first computer_* call of a ' +
656
+ 'later turn — you do not have to ask for it, and you cannot force it; a pause is released ' +
657
+ 'only by the human pressing Ctrl+Alt+R.')
658
+ default: return cu.selfTest()
659
+ }
660
+ },
661
+ })
662
+
663
+ // Shared handlers so computer_batch can re-run any of the above.
664
+ const HANDLERS = {
665
+ computer_click: clickTarget,
666
+ computer_move: (a) => { const p = pt(a.at); return cu.move(p.x, p.y) },
667
+ computer_drag: async (a) => cu.drag(await cu.resolveTarget(pt(a.from)), await cu.resolveTarget(pt(a.to))),
668
+ computer_scroll: (a) => cu.scroll(a.dir, a.lines || 3, pt(a.at) ? { at: pt(a.at) } : {}),
669
+ computer_select: async (a) => {
670
+ const f = await cu.resolveTarget(pt(a.from)); const t = await cu.resolveTarget(pt(a.to))
671
+ return cu.selectRange({ fromX: f.x, fromY: f.y, toX: t.x, toY: t.y, fromClicks: a.clicks, toClicks: a.clicks, scrollClicks: a.scroll })
672
+ },
673
+ computer_key: (a) => cu.key(a.combo),
674
+ computer_type: (a) => cu.type(a.text, { mode: a.mode, allowPassword: a.allowPassword }),
675
+ computer_window: (a) => ((a.op || 'activate') === 'activate' ? cu.activateWindow({ hwnd: a.hwnd }) : cu.windowOp({ hwnd: a.hwnd }, a.op, rect(a.rect) || {})),
676
+ computer_clip: (a) => (a.text === undefined ? cu.clipRead() : cu.clipWrite(a.text)),
677
+ computer_uia_act: actUia,
678
+ computer_ask: (a, exec) => askQuestion(a, exec),
679
+ computer_wait: (a) => a.mode === 'stable' ? cu.waitStable({ timeoutMs: a.timeoutMs, hwnd: a.hwnd })
680
+ : a.mode === 'change' ? cu.waitChange({ timeoutMs: a.timeoutMs, hwnd: a.hwnd }) : cu.waitForIdle({ timeoutMs: a.timeoutMs }),
681
+ // The observation tools and the meta tool were missing from this map, so a batch could not
682
+ // take a screenshot: "unknown tool computer_shot" (reported 2026-09-12). The map must cover
683
+ // the WHOLE surface — a caller should never have to know which subset happens to be wired.
684
+ computer_shot: takeScreenshot,
685
+ computer_state: observeState,
686
+ computer_marks: async (a) => {
687
+ const res = await cu.landmarks({ hwnd: a.hwnd, refresh: a.refresh })
688
+ const res2 = {
689
+ count: res.count, reused: !!res.reused, hwnd: res.hwnd,
690
+ marks: res.marks.map(m => ({ id: m.id, role: m.role, name: m.name, center: [m.cx, m.cy] })),
691
+ }
692
+ if (a.shot) {
693
+ const m = await cu.marks({ annotated: true, hwnd: a.hwnd, landmarkMap: res })
694
+ if (m.annotated) res2.annotatedShot = saveShot(m.annotated)
695
+ }
696
+ return res2
697
+ },
698
+ computer_uia: queryUia,
699
+ computer_ctrl: (a, exec) => {
700
+ switch (a.action) {
701
+ case 'stop': return a.temporary ? hooks.temporaryStop(exec, a.why) : cu.panic(a.why)
702
+ case 'recover': return hooks.recover(exec, a.pauseId)
703
+ case 'acknowledge': return { acknowledged: true, note: 'procedure acknowledged — driving is open for this session' }
704
+ case 'exit': return cu.exit(a.why)
705
+ case 'calibrate': return cu.calibrate({})
706
+ case 'indicator': return cu.indicator({ on: a.on !== false })
707
+ // Same refusal as the tool path above: `resume` is not an action any more, and it must not
708
+ // be reachable through computer_batch either.
709
+ case 'resume': throw new Error(
710
+ 'action "resume" no longer exists: no tool call may open or re-open a computer-use ' +
711
+ 'cycle (the PLUGIN opens one on the first computer_* call of a later turn, and only the ' +
712
+ 'human\'s Ctrl+Alt+R releases a pause).')
713
+ default: return cu.selfTest()
714
+ }
715
+ },
716
+ }
717
+ }