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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/README.zh.md +132 -0
- package/cordis.patch.yml +17 -0
- package/lib/artifacts.js +35 -0
- package/lib/attention-native.js +93 -0
- package/lib/attention-worker.cs +194 -0
- package/lib/attention.js +140 -0
- package/lib/audit.js +30 -0
- package/lib/batch.js +52 -0
- package/lib/bin/attention-worker.exe +0 -0
- package/lib/bin/attention-worker.exe.json +7 -0
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +7 -0
- package/lib/core/compile-worker.ps1 +58 -0
- package/lib/core/index.js +844 -0
- package/lib/core/native-runtime.js +97 -0
- package/lib/core/worker.cs +6375 -0
- package/lib/cycle.js +314 -0
- package/lib/exposure.js +91 -0
- package/lib/feedback.js +35 -0
- package/lib/image-output.js +92 -0
- package/lib/index.js +1715 -0
- package/lib/observations.js +78 -0
- package/lib/policy.js +109 -0
- package/lib/recovery.js +8 -0
- package/lib/skill-gate.js +68 -0
- package/lib/tools.js +717 -0
- package/mcp/package.json +12 -0
- package/mcp/src/index.js +335 -0
- package/package.json +88 -0
- package/scripts/build-native.mjs +7 -0
- package/scripts/smoke-native.mjs +38 -0
- package/scripts/verify-package.mjs +28 -0
- package/skills/computer-use/SKILL.md +74 -0
- package/skills/computer-use/references/lifecycle.md +180 -0
- package/skills/computer-use/references/maintenance.md +56 -0
- package/skills/computer-use/references/precision.md +297 -0
- package/skills/computer-use/references/progressive-control.md +30 -0
- 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
|
+
}
|