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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +53 -22
  2. package/README.zh.md +46 -21
  3. package/icon.svg +29 -0
  4. package/lib/audit.js +7 -0
  5. package/lib/batch.js +14 -19
  6. package/lib/bin/attention-worker.exe +0 -0
  7. package/lib/bin/attention-worker.exe.json +1 -1
  8. package/lib/compact-feedback.js +175 -0
  9. package/lib/continuation.js +1 -1
  10. package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
  11. package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
  12. package/lib/core/compile-worker.ps1 +5 -1
  13. package/lib/core/index.js +452 -129
  14. package/lib/core/native-runtime.js +23 -1
  15. package/lib/core/winmd/Windows.winmd +0 -0
  16. package/lib/core/worker.cs +2415 -356
  17. package/lib/feedback-routes.json +11 -0
  18. package/lib/feedback.js +49 -16
  19. package/lib/image-output.js +59 -11
  20. package/lib/index.js +42 -7
  21. package/lib/key-command.js +37 -0
  22. package/lib/media-contract.js +43 -0
  23. package/lib/observations.js +267 -13
  24. package/lib/policy.js +30 -10
  25. package/lib/tool-contract.js +56 -0
  26. package/lib/tools.js +174 -51
  27. package/lib/verification-region.js +17 -0
  28. package/mcp/package.json +2 -1
  29. package/mcp/src/execution.js +8 -5
  30. package/mcp/src/index.js +16 -267
  31. package/mcp/src/tools.js +271 -0
  32. package/package.json +5 -2
  33. package/scripts/check-behavior.mjs +83 -0
  34. package/scripts/measure-tool-tokens.mjs +21 -0
  35. package/scripts/render-promo.py +81 -0
  36. package/skills/computer-use/SKILL.md +35 -8
  37. package/skills/computer-use/references/lifecycle.md +11 -7
  38. package/skills/computer-use/references/maintenance.md +9 -5
  39. package/skills/computer-use/references/precision.md +39 -14
  40. package/skills/computer-use/references/progressive-control.md +10 -4
  41. package/skills/computer-use/references/zero-uia-apps.md +54 -0
@@ -1,15 +1,180 @@
1
- import { randomBytes } from 'node:crypto'
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import v8 from 'node:v8'
5
+ import { createHash, randomBytes } from 'node:crypto'
2
6
 
3
7
  const clone = value => JSON.parse(JSON.stringify(value))
4
8
  const encode = value => JSON.stringify(value)
5
9
  const elementKey = e => e.target || (e.runtimeId ? `uia:${e.pid ?? ''}:${e.runtimeId}` : null)
6
10
  const windowKey = w => w.hwnd ? `window:${w.pid ?? ''}:${w.hwnd}` : null
11
+ const pagedFields = ['elements', 'uiaFlat', 'windows', 'ancestors']
12
+ const TTL = 24 * 60 * 60 * 1000, MEMORY_BYTES = 4 * 1024 * 1024, DISK_BYTES = 32 * 1024 * 1024
13
+ const FORMAT = 'dsh-computer-use-observation-cache-v1', ID = /^[a-f0-9]{32}$/
14
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex')
15
+ const failure = code => Object.assign(new Error('Observation cache unavailable'), { code })
16
+ const unavailable = () => new Error('OBSERVATION_UNAVAILABLE: snapshot expired or belongs to another Agent/tool; request a fresh full observation')
17
+ const pressure = () => { const heap = v8.getHeapStatistics(); return os.freemem() < 256 * 1024 * 1024 || heap.used_heap_size / heap.heap_size_limit > 0.85 }
18
+
19
+ // Only this dedicated directory is maintained. A manifest reserves ownership
20
+ // before data is written, so a crash/partial write remains identifiable on restart.
21
+ class DiskCache {
22
+ constructor (directory, io, now) { this.directory = directory; this.io = io; this.now = now; this.lastSweep = -Infinity }
23
+ file (name) { return path.join(this.directory, name) }
24
+ regular (file) { const stat = this.io.lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw failure('CACHE_UNSAFE'); return stat }
25
+ remove (file) {
26
+ try { const stat = this.regular(file); this.io.unlinkSync(file); return stat.size }
27
+ catch (error) { if (error.code === 'ENOENT') return 0; throw error }
28
+ }
29
+ lock (body, create = true) {
30
+ if (!this.directory || path.basename(this.directory).toLowerCase() !== 'observation-cache') throw failure('CACHE_DIRECTORY')
31
+ if (create) this.io.mkdirSync(this.directory, { recursive: true })
32
+ const directory = this.io.lstatSync(this.directory)
33
+ if (!directory.isDirectory() || directory.isSymbolicLink()) throw failure('CACHE_UNSAFE')
34
+ const lockFile = this.file('.lock'), nonce = randomBytes(16).toString('hex')
35
+ let handle
36
+ try { handle = this.io.openSync(lockFile, 'wx', 0o600) }
37
+ catch (error) {
38
+ if (error.code !== 'EEXIST') throw error
39
+ const stat = this.regular(lockFile); if (stat.size > 4096) throw failure('CACHE_LOCKED')
40
+ let stale; try { stale = JSON.parse(this.io.readFileSync(lockFile, 'utf8')) } catch { throw failure('CACHE_LOCKED') }
41
+ if (stale.format !== FORMAT || !ID.test(stale.nonce) || !Number.isSafeInteger(stale.pid) || stale.pid < 1) throw failure('CACHE_LOCKED')
42
+ if (stale.pid !== process.pid || this.recoverableLock !== stale.nonce)
43
+ try { process.kill(stale.pid, 0); throw failure('CACHE_LOCKED') } catch (alive) { if (alive.code !== 'ESRCH') throw failure('CACHE_LOCKED') }
44
+ // This exact temporary filename is owned by the persisted dead lock.
45
+ this.remove(this.file(`.manifest-${stale.nonce}.tmp`)); this.remove(lockFile)
46
+ this.recoverableLock = null
47
+ handle = this.io.openSync(lockFile, 'wx', 0o600)
48
+ }
49
+ const lease = { nonce, keep: false }
50
+ try {
51
+ this.io.writeFileSync(handle, encode({ format: FORMAT, nonce, pid: process.pid }) + '\n', 'utf8')
52
+ return body(lease)
53
+ } finally {
54
+ this.io.closeSync(handle)
55
+ if (lease.keep) this.recoverableLock = nonce
56
+ else this.remove(lockFile)
57
+ }
58
+ }
59
+ manifest () {
60
+ const file = this.file('.manifest.json')
61
+ let value
62
+ try { const stat = this.regular(file); if (stat.size > 128 * 1024) throw failure('CACHE_MANIFEST'); value = JSON.parse(this.io.readFileSync(file, 'utf8')) }
63
+ catch (error) { if (error.code === 'ENOENT') return []; throw error }
64
+ if (value.format !== FORMAT || !Array.isArray(value.entries) || value.entries.length > 64) throw failure('CACHE_MANIFEST')
65
+ const ids = new Set()
66
+ for (const e of value.entries) {
67
+ if (!ID.test(e.id) || !ID.test(e.owner) || ids.has(e.id) || !Number.isFinite(e.createdAt) ||
68
+ !Number.isSafeInteger(e.bytes) || e.bytes < 0 || !/^[a-f0-9]{64}$/.test(e.sha256)) throw failure('CACHE_MANIFEST')
69
+ ids.add(e.id)
70
+ }
71
+ return value.entries
72
+ }
73
+ commit (entries, lease) {
74
+ const temp = this.file(`.manifest-${lease.nonce}.tmp`)
75
+ let opened = false, handle
76
+ try {
77
+ handle = this.io.openSync(temp, 'wx', 0o600); opened = true
78
+ this.io.writeFileSync(handle, encode({ format: FORMAT, entries }) + '\n', 'utf8')
79
+ this.io.closeSync(handle); handle = undefined
80
+ this.io.renameSync(temp, this.file('.manifest.json')); opened = false
81
+ } finally {
82
+ if (handle !== undefined) this.io.closeSync(handle)
83
+ if (opened) try { this.remove(temp) } catch { lease.keep = true }
84
+ }
85
+ }
86
+ trim (entries, incomingBytes = 0, incomingFiles = 0) {
87
+ let changed = false, retained = 0
88
+ const discard = entry => {
89
+ try { this.remove(this.file(entry.id + '.json')); entries.splice(entries.indexOf(entry), 1); changed = true; return true }
90
+ catch { retained++; return false }
91
+ }
92
+ for (const entry of [...entries]) {
93
+ let missing = false
94
+ try { this.regular(this.file(entry.id + '.json')) } catch (error) { missing = error.code === 'ENOENT' }
95
+ if (missing || this.now() - entry.createdAt >= TTL) discard(entry)
96
+ }
97
+ for (const entry of [...entries].sort((a, b) => a.createdAt - b.createdAt)) {
98
+ if (entries.length + incomingFiles <= 64 && entries.reduce((n, e) => n + e.bytes, 0) + incomingBytes <= DISK_BYTES) break
99
+ discard(entry)
100
+ }
101
+ return { changed, retained, fits: entries.length + incomingFiles <= 64 && entries.reduce((n, e) => n + e.bytes, 0) + incomingBytes <= DISK_BYTES }
102
+ }
103
+ sweep (force = false) {
104
+ if (!this.directory || (!force && performance.now() - this.lastSweep < 60000)) return
105
+ this.lastSweep = performance.now()
106
+ try { this.lock(lease => { const entries = this.manifest(); if (this.trim(entries).changed) this.commit(entries, lease) }, false) } catch { /* Later save reports bounded fallback. */ }
107
+ }
108
+ save (owner, full) {
109
+ try {
110
+ const bytes = Buffer.from(encode(full) + '\n', 'utf8')
111
+ if (bytes.length > DISK_BYTES) throw failure('CACHE_CAPACITY')
112
+ return this.lock(lease => {
113
+ const entries = this.manifest(), cleanup = this.trim(entries, bytes.length, 1)
114
+ if (cleanup.changed) this.commit(entries, lease)
115
+ if (!cleanup.fits) throw failure('CACHE_CAPACITY')
116
+ const entry = { id: randomBytes(16).toString('hex'), owner, createdAt: this.now(), bytes: bytes.length, sha256: digest(bytes) }
117
+ entries.push(entry); this.commit(entries, lease)
118
+ const file = this.file(entry.id + '.json'); let handle, created = false
119
+ try { handle = this.io.openSync(file, 'wx', 0o600); created = true; this.io.writeFileSync(handle, bytes) }
120
+ catch (error) {
121
+ if (handle !== undefined) { this.io.closeSync(handle); handle = undefined }
122
+ let removed = !created
123
+ if (created) try { this.remove(file); removed = true } catch { }
124
+ if (removed) { entries.pop(); try { this.commit(entries, lease) } catch { } }
125
+ throw error
126
+ } finally { if (handle !== undefined) this.io.closeSync(handle) }
127
+ this.lastSweep = performance.now()
128
+ return { path: file, bytes: entry.bytes, sha256: entry.sha256, status: 'saved', cacheId: entry.id }
129
+ })
130
+ } catch (error) {
131
+ return { status: 'memory-only', reason: ['ENOSPC', 'EDQUOT'].includes(error.code) ? 'disk-full' : error.code === 'CACHE_CAPACITY' ? 'capacity' : 'disk-unavailable', fallback: 'inline' }
132
+ }
133
+ }
134
+ read (owner, archive) {
135
+ return this.lock(() => {
136
+ const entry = this.manifest().find(e => e.id === archive.cacheId && e.owner === owner)
137
+ if (!entry || this.now() - entry.createdAt >= TTL) throw unavailable()
138
+ const file = this.file(entry.id + '.json'), stat = this.regular(file)
139
+ if (stat.size !== entry.bytes || stat.size > DISK_BYTES) throw unavailable()
140
+ const bytes = this.io.readFileSync(file)
141
+ if (digest(bytes) !== entry.sha256) throw unavailable()
142
+ return JSON.parse(bytes)
143
+ }, false)
144
+ }
145
+ clear (owner) {
146
+ if (!this.directory) return { filesRemoved: 0, bytesFreed: 0, retainedOnFailure: 0 }
147
+ try { this.io.lstatSync(this.directory) } catch (error) { if (error.code === 'ENOENT') return { filesRemoved: 0, bytesFreed: 0, retainedOnFailure: 0 }; throw error }
148
+ return this.lock(lease => {
149
+ const entries = this.manifest(); let filesRemoved = 0, bytesFreed = 0, retainedOnFailure = 0
150
+ for (const entry of [...entries]) if (entry.owner === owner) {
151
+ try { const bytes = this.remove(this.file(entry.id + '.json')); filesRemoved += bytes > 0 ? 1 : 0; bytesFreed += bytes; entries.splice(entries.indexOf(entry), 1) }
152
+ catch { retainedOnFailure++ }
153
+ }
154
+ this.commit(entries, lease)
155
+ return { filesRemoved, bytesFreed, retainedOnFailure }
156
+ }, false)
157
+ }
158
+ }
7
159
 
8
160
  // An explicit base prevents implicit deltas after compaction or in an unrelated Agent.
9
161
  // Full immutable observations are retained, including every provider field; only the wire view differs.
10
162
  export class ObservationStore {
11
- constructor ({ capacity = 64 } = {}) {
12
- this.capacity = capacity
163
+ constructor ({ capacity = 64, archiveDir, memoryPressure = pressure, fileSystem = fs, now = Date.now } = {}) {
164
+ if (!Number.isSafeInteger(capacity) || capacity < 1) throw new RangeError('Observation capacity must be a positive integer')
165
+ if (archiveDir !== undefined && (typeof archiveDir !== 'string' || !archiveDir.trim())) throw new TypeError('archiveDir must be a nonempty directory path')
166
+ this.capacity = Math.min(64, capacity)
167
+ this.archiveDir = archiveDir === undefined ? null : path.resolve(archiveDir)
168
+ this.now = now
169
+ this.disk = new DiskCache(this.archiveDir, fileSystem, now)
170
+ this.memoryPressure = memoryPressure
171
+ this.lastPressureCheck = -Infinity
172
+ this.pressured = false
173
+ this.entries = new Map()
174
+ this.memoryBytes = 0
175
+ this.epoch = 0
176
+ this.hasRead = false
177
+ this.owners = new WeakMap()
13
178
  this.scopes = new WeakMap()
14
179
  this.unscoped = {}
15
180
  this.sequence = 0
@@ -21,21 +186,93 @@ export class ObservationStore {
21
186
  if (!cache) { cache = new Map(); this.scopes.set(owner, cache) }
22
187
  return cache
23
188
  }
24
- get (exec, id, kind) {
189
+ owner (exec) {
190
+ const key = exec?.agent || this.unscoped
191
+ if (!this.owners.has(key)) this.owners.set(key, randomBytes(16).toString('hex'))
192
+ return this.owners.get(key)
193
+ }
194
+ evict (id) {
195
+ const entry = this.entries.get(id)
196
+ if (entry) { this.memoryBytes -= entry.full ? entry.bytes : 0; entry.cache.delete(id); this.entries.delete(id) }
197
+ }
198
+ checkPressure () {
199
+ if (performance.now() - this.lastPressureCheck >= 5000) {
200
+ this.lastPressureCheck = performance.now()
201
+ try { this.pressured = !!this.memoryPressure() } catch { this.pressured = true }
202
+ if (this.pressured) for (const [id, entry] of this.entries) {
203
+ if (entry.archive?.status === 'saved') { this.memoryBytes -= entry.full ? entry.bytes : 0; entry.full = null }
204
+ else this.evict(id)
205
+ }
206
+ }
207
+ return this.pressured
208
+ }
209
+ fullOf (entry) {
210
+ if (!entry || this.now() - entry.createdAt >= TTL) { if (entry) this.evict(entry.id); return null }
211
+ if (entry.full) return entry.full
212
+ try { return this.disk.read(entry.owner, entry.archive) } catch { this.evict(entry.id); return null }
213
+ }
214
+ invalidate (reason = 'evidence-invalidated') {
215
+ this.epoch++
216
+ return { invalidated: true, reason, historicalSnapshotsRetained: true }
217
+ }
218
+ clear (exec) {
219
+ const cache = this.scope(exec), cleared = cache.size
220
+ const knownFiles = [...cache.values()].filter(e => e.archive?.status === 'saved').length
221
+ for (const id of [...cache.keys()]) this.evict(id)
222
+ let disk = { filesRemoved: 0, bytesFreed: 0, retainedOnFailure: 0 }
223
+ if (this.archiveDir) try { disk = this.disk.clear(this.owner(exec)) }
224
+ catch { disk.retainedOnFailure = knownFiles || null }
225
+ return { cleared, ...disk }
226
+ }
227
+ get (exec, id, kind, view = {}) {
228
+ this.checkPressure(); this.disk.sweep(!this.hasRead); this.hasRead = true
25
229
  const entry = this.scope(exec).get(id)
26
- if (!entry || entry.kind !== kind) throw new Error('OBSERVATION_UNAVAILABLE: snapshot expired or belongs to another Agent/tool; request a fresh full observation')
27
- return clone(entry.full)
230
+ if (!entry || entry.kind !== kind) throw unavailable()
231
+ const full = this.fullOf(entry)
232
+ if (!full) throw unavailable()
233
+ const snapshot = clone(full)
234
+ snapshot.snapshotHistorical = true
235
+ snapshot.targetsValid = entry.epoch === this.epoch
236
+ if (entry.archive) snapshot.archive = { ...entry.archive }
237
+ if (view.offset !== undefined || view.limit !== undefined) {
238
+ const offset = view.offset ?? 0, limit = view.limit ?? 80
239
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 80)
240
+ throw new RangeError('Observation pagination requires offset >= 0 and limit between 1 and 80')
241
+ const page = { offset, limit, fields: {} }
242
+ for (const field of pagedFields) if (Array.isArray(snapshot[field])) {
243
+ const total = snapshot[field].length
244
+ snapshot[field] = snapshot[field].slice(offset, offset + limit)
245
+ const returned = snapshot[field].length, hasMore = offset < total && returned < total - offset
246
+ page.fields[field] = { total, returned, hasMore, nextOffset: hasMore ? offset + returned : null }
247
+ }
248
+ // A second complete tree would defeat an explicitly requested page. The
249
+ // default full read and archive still preserve every provider field.
250
+ if (Object.hasOwn(snapshot, 'uiaRoot')) { delete snapshot.uiaRoot; page.omittedFields = ['uiaRoot'] }
251
+ snapshot.page = page
252
+ }
253
+ return snapshot
28
254
  }
29
255
  ownsTarget (exec, target) {
30
- return [...this.scope(exec).values()].some(entry => (entry.full.elements || []).some(e => e.target === target))
256
+ this.checkPressure()
257
+ return [...this.scope(exec).values()].some(entry => entry.epoch === this.epoch && (this.fullOf(entry)?.elements || []).some(e => e.target === target))
31
258
  }
32
- publish (exec, kind, query, value, since) {
259
+ publish (exec, kind, query, value, since, options = {}) {
260
+ // The action already allocated its result. Under pressure do not create a
261
+ // second deep copy, diff, encoded buffer, or new disk record for that result.
262
+ if (this.checkPressure()) {
263
+ const inline = { ...value, capturedAt: value.capturedAt ?? value.ts ?? new Date().toISOString(), mode: 'full',
264
+ archive: { status: 'uncached', reason: 'memory-pressure', fallback: 'inline' } }
265
+ delete inline.observation
266
+ return inline
267
+ }
33
268
  const cache = this.scope(exec)
34
269
  const fields = kind === 'uia' ? { elements: elementKey } : { windows: windowKey, uiaFlat: elementKey }
35
- const full = clone({ ...value, observation: `O${this.prefix}-${++this.sequence}`, mode: 'full' })
270
+ const full = clone({ ...value, capturedAt: value.capturedAt ?? value.ts ?? new Date().toISOString(),
271
+ observation: `O${this.prefix}-${++this.sequence}`, mode: 'full' })
36
272
  // The scope includes the exact query and observed window/process. A change forces a full view.
37
273
  const scope = encode([query, value.hwnd ?? value.active?.hwnd ?? null, value.pid ?? value.active?.pid ?? null])
38
- const previous = since && cache.get(since)
274
+ const previousEntry = since && cache.get(since), previousFull = this.fullOf(previousEntry)
275
+ const previous = previousFull && { ...previousEntry, full: previousFull }
39
276
  let wire = full
40
277
  if (since) {
41
278
  let reason = !previous ? 'base-unavailable' : previous.kind !== kind || previous.scope !== scope ? 'scope-changed'
@@ -71,8 +308,25 @@ export class ObservationStore {
71
308
  }
72
309
  if (reason) wire = { ...full, resetReason: reason }
73
310
  }
74
- cache.set(full.observation, { full, kind, scope })
75
- while (cache.size > this.capacity) cache.delete(cache.keys().next().value)
76
- return wire
311
+ let archive = options.archive === true ? this.disk.save(this.owner(exec), full) : undefined
312
+ const bytes = Buffer.byteLength(encode(full)), keepMemory = !this.checkPressure() && bytes <= MEMORY_BYTES
313
+ if (!keepMemory && archive?.status !== 'saved') {
314
+ const inline = clone(full); delete inline.observation
315
+ return { ...inline, archive: { status: 'uncached', reason: 'memory-pressure', fallback: 'inline' } }
316
+ }
317
+ if (keepMemory) for (const [id, entry] of this.entries) {
318
+ if (this.memoryBytes + bytes <= MEMORY_BYTES) break
319
+ if (!entry.full) continue
320
+ if (entry.archive?.status === 'saved') { this.memoryBytes -= entry.bytes; entry.full = null }
321
+ else this.evict(id)
322
+ }
323
+ const entry = { id: full.observation, full: keepMemory ? full : null, bytes, kind, scope, archive,
324
+ owner: this.owner(exec), createdAt: this.now(), epoch: this.epoch, cache }
325
+ cache.set(full.observation, entry); this.entries.set(full.observation, entry)
326
+ if (keepMemory) this.memoryBytes += bytes
327
+ while (cache.size > this.capacity) this.evict(cache.keys().next().value)
328
+ while (this.entries.size > 64) this.evict(this.entries.keys().next().value)
329
+ // Neither the full wire view nor delta rows may alias the retained snapshot.
330
+ return { ...clone(wire), ...(archive ? { archive: { ...archive } } : {}) }
77
331
  }
78
332
  }
package/lib/policy.js CHANGED
@@ -18,8 +18,10 @@
18
18
  // plugin's session/event turn/start opens a cycle, and only the human's Ctrl+Alt+R releases a pause.)
19
19
  // computer_ask is argument-independent and BRAKE-LIKE — see its branch below.
20
20
 
21
+ import { keyCommandRisk } from './key-command.js'
22
+
21
23
  export const OBSERVATION_TOOLS = new Set([
22
- 'computer_shot', 'computer_state', 'computer_marks', 'computer_uia', 'computer_wait',
24
+ 'computer_shot', 'computer_state', 'computer_marks', 'computer_uia', 'computer_wait', 'computer_ocr',
23
25
  ])
24
26
 
25
27
  export const ACTUATION_TOOLS = new Set([
@@ -27,10 +29,6 @@ export const ACTUATION_TOOLS = new Set([
27
29
  'computer_key', 'computer_type', 'computer_uia_act', 'computer_window', 'computer_batch',
28
30
  ])
29
31
 
30
- // combos that escalate: run dialog, window close, signout/lock/taskmgr, delete-key
31
- const HIGH_RISK_KEY = /(^|\+)(win\+r|alt\+f4|ctrl\+shift\+esc|win\+l|win\+d)(\+|$)/i
32
- const DESTRUCTIVE_KEY = /(^|\+)(del|delete|bksp|backspace)(\+|$)/i
33
-
34
32
  export const HIGH_RISK_NOTE = 'high-risk action (destructive key / closing a window / password field) — always requires human approval'
35
33
 
36
34
  /** @returns {{kind:'allow'|'deny'|'ask', reason?: string}} */
@@ -43,6 +41,8 @@ export function policyDecision (name, args, mode) {
43
41
  // The brake is available in EVERY mode, including read-only, and it drives nothing.
44
42
  if (act === 'stop') return { kind: 'allow' }
45
43
  if (act === 'release') return { kind: 'allow' }
44
+ // An advisory visual intent never injects input or releases a human brake.
45
+ if (act === 'hold' || act === 'free') return { kind: 'allow' }
46
46
  if (act === 'recover') return mode === 'read-only'
47
47
  ? { kind: 'deny', reason: 'read-only mode cannot recover control' } : { kind: 'allow' }
48
48
  if (act === 'selftest') return { kind: 'allow' } // input-path health, never moves the pointer
@@ -74,15 +74,16 @@ export function policyDecision (name, args, mode) {
74
74
  }
75
75
 
76
76
  function decideActuation (name, a, mode) {
77
- const hr = highRisk(name, a)
77
+ let hr
78
+ try { hr = highRisk(name, a) } catch (error) { return { kind: 'deny', reason: error.message } }
78
79
  switch (mode) {
79
80
  case 'read-only':
80
81
  return { kind: 'deny', reason: `automationMode=read-only: ${name} drives the machine` }
81
82
  case 'standard':
82
- return hr ? { kind: 'ask', reason: HIGH_RISK_NOTE }
83
+ return hr ? { kind: 'ask', reason: HIGH_RISK_NOTE + riskSuffix(name, a) }
83
84
  : { kind: 'ask', reason: `automationMode=standard: actuation ${name}` }
84
85
  case 'autonomous':
85
- return hr ? { kind: 'ask', reason: HIGH_RISK_NOTE } : { kind: 'allow' }
86
+ return hr ? { kind: 'ask', reason: HIGH_RISK_NOTE + riskSuffix(name, a) } : { kind: 'allow' }
86
87
  case 'unrestricted':
87
88
  return { kind: 'allow' }
88
89
  default:
@@ -90,11 +91,30 @@ function decideActuation (name, a, mode) {
90
91
  }
91
92
  }
92
93
 
94
+ /** Names the exact argument that escalated, so a refused batch names its offending step
95
+ * (test report 2026-11-04: "ctrl+a then delete" read as a generic high-risk refusal and the
96
+ * model had to guess which op tripped it). Empty string when there is nothing to add. */
97
+ function riskSuffix (name, a) {
98
+ if (name === 'computer_key') return ` [combo=${a.combo}]`
99
+ if (name === 'computer_window') return ` [op=${a.op}]`
100
+ if (name === 'computer_type') return ' [allowPassword]'
101
+ if (name === 'computer_batch') {
102
+ const actions = Array.isArray(a.actions) ? a.actions : []
103
+ for (let i = 0; i < actions.length; i++) {
104
+ const step = actions[i]
105
+ if (!step) continue
106
+ const t = String(step.tool || '')
107
+ if ((ACTUATION_TOOLS.has(t) || t === 'computer_clip' || t === 'computer_ctrl') && highRisk(t, step.args || {}))
108
+ return ` [action[${i}] ${t}${step.args?.combo !== undefined ? ` combo=${step.args.combo}` : step.args?.op !== undefined ? ` op=${step.args.op}` : ''}]`
109
+ }
110
+ }
111
+ return ''
112
+ }
113
+
93
114
  export function highRisk (name, args) {
94
115
  const a = args || {}
95
116
  if (name === 'computer_key') {
96
- const combo = String(a.combo || '')
97
- if (HIGH_RISK_KEY.test(combo) || DESTRUCTIVE_KEY.test(combo)) return true
117
+ return keyCommandRisk(a.combo)
98
118
  }
99
119
  if (name === 'computer_window' && String(a.op || 'activate') === 'close') return true
100
120
  if (name === 'computer_type' && a.allowPassword) return true
@@ -0,0 +1,56 @@
1
+ // Host names are aliases of one operation/result contract. Adapters execute
2
+ // their native argument shapes; batching and refusal semantics use this view.
3
+ export function canonicalStep (step = {}) {
4
+ step ||= {}
5
+ const args = step.args || {}
6
+ const names = {
7
+ cu_screenshot: 'computer_shot', cu_screen_state: 'computer_state', cu_windows: 'computer_state',
8
+ cu_cursor: 'computer_state', cu_marks: 'computer_marks', cu_uia_query: 'computer_uia',
9
+ cu_uia_at_point: 'computer_uia', cu_wait: 'computer_wait', cu_move: 'computer_move',
10
+ cu_click: 'computer_click', cu_drag: 'computer_drag', cu_scroll: 'computer_scroll',
11
+ cu_key: 'computer_key', cu_type: 'computer_type', cu_batch: 'computer_batch',
12
+ }
13
+ if (step.tool === 'cu_activate_window') return { tool: 'computer_window', args: { ...args, op: 'activate' } }
14
+ if (step.tool === 'cu_window_op') return { tool: 'computer_window', args }
15
+ if (['cu_hold', 'cu_free', 'cu_release'].includes(step.tool)) return { tool: 'computer_ctrl', args: { action: step.tool.slice(3) } }
16
+ if (step.tool === 'cu_clipboard_read') return { tool: 'computer_clip', args: {} }
17
+ if (step.tool === 'cu_clipboard_write') return { tool: 'computer_clip', args }
18
+ return { tool: names[step.tool] || step.tool, args }
19
+ }
20
+
21
+ const reads = new Set(['computer_state', 'computer_uia', 'computer_marks', 'computer_shot', 'computer_wait', 'computer_ocr'])
22
+ export const isReadStep = step => {
23
+ const s = canonicalStep(step)
24
+ return reads.has(s.tool) || (s.tool === 'computer_clip' && s.args.text === undefined)
25
+ }
26
+ export const isReadbackStep = step => {
27
+ const s = canonicalStep(step)
28
+ return ['computer_state', 'computer_uia', 'computer_marks', 'computer_shot', 'computer_ocr'].includes(s.tool) ||
29
+ (s.tool === 'computer_clip' && s.args.text === undefined)
30
+ }
31
+ export const endsBatch = step => {
32
+ const s = canonicalStep(step)
33
+ return s.tool === 'computer_ctrl' && ['stop', 'exit', 'release'].includes(s.args.action)
34
+ }
35
+
36
+ export function actionRefusal (step, result) {
37
+ const s = canonicalStep(step)
38
+ if (result?.ok === false || result?.success === false) return result.error || result.reason || 'action reported failure'
39
+ if (s.tool === 'computer_window' && (s.args.op || 'activate') === 'activate' && result?.activated !== true) {
40
+ return 'target window activation was not confirmed; subsequent actions were not dispatched'
41
+ }
42
+ if (s.tool === 'computer_ctrl' && s.args.action === 'recover' && result?.recovered !== true) return result.reason || 'temporary pause recovery refused'
43
+ if (s.tool === 'computer_ctrl' && s.args.action === 'release' && !result?.released && !result?.alreadyReleased) return result.reason || 'cycle release refused'
44
+ if (s.tool === 'computer_batch' && result?.actions?.some(action => action.ok === false)) return 'nested batch did not complete; subsequent actions were not dispatched'
45
+ return null
46
+ }
47
+
48
+ export function actionError (error) {
49
+ return { ok: false, error: String(error?.message || error),
50
+ code: error?.code || 'action-error', outcome: error?.outcome || 'unknown',
51
+ ...(error?.dragStarted !== undefined ? { dragStarted: error.dragStarted } : {}),
52
+ ...(error?.expectedCursor !== undefined ? { expectedCursor: error.expectedCursor, actualCursor: error.actualCursor } : {}),
53
+ ...(error?.cursorCheck !== undefined ? { cursorCheck: error.cursorCheck } : {}),
54
+ ...(error?.cancelSource ? { cancelSource: error.cancelSource } : {}),
55
+ ...(error?.handoffWait ? { handoffWait: error.handoffWait } : {}) }
56
+ }