@anionex/dsh-computer-use 0.1.0 → 0.2.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 (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +15 -1
  3. package/README.zh.md +15 -1
  4. package/lib/index.js +6 -0
  5. package/lib/index.js.map +1 -1
  6. package/lib/providers/macos.js +37 -23
  7. package/lib/providers/macos.js.map +1 -1
  8. package/lib/providers/native-helper.js +151 -6
  9. package/lib/providers/native-helper.js.map +1 -1
  10. package/lib/providers/unsupported.js +45 -0
  11. package/lib/providers/unsupported.js.map +1 -0
  12. package/lib/service.js +101 -15
  13. package/lib/service.js.map +1 -1
  14. package/lib/skill.js +7 -1
  15. package/lib/skill.js.map +1 -1
  16. package/lib/tools.js +18 -0
  17. package/lib/tools.js.map +1 -1
  18. package/lib/types/backend.d.ts +17 -2
  19. package/lib/types/backend.d.ts.map +1 -1
  20. package/lib/types/index.d.ts.map +1 -1
  21. package/lib/types/providers/macos.d.ts +20 -1
  22. package/lib/types/providers/macos.d.ts.map +1 -1
  23. package/lib/types/providers/native-helper.d.ts +6 -2
  24. package/lib/types/providers/native-helper.d.ts.map +1 -1
  25. package/lib/types/providers/unsupported.d.ts +19 -0
  26. package/lib/types/providers/unsupported.d.ts.map +1 -0
  27. package/lib/types/service.d.ts +6 -0
  28. package/lib/types/service.d.ts.map +1 -1
  29. package/lib/types/skill.d.ts +1 -1
  30. package/lib/types/skill.d.ts.map +1 -1
  31. package/lib/types/tools.d.ts.map +1 -1
  32. package/lib/types/types.d.ts +23 -1
  33. package/lib/types/types.d.ts.map +1 -1
  34. package/lib/types.js.map +1 -1
  35. package/native/macos/Sources/Helper/CursorOverlay.swift +96 -21
  36. package/native/macos/Sources/Helper/WindowNumberMatcher.swift +29 -0
  37. package/native/macos/Sources/Helper/main.swift +20 -7
  38. package/native/macos/bin/dsh-computer-use-helper +0 -0
  39. package/native/macos/manifest.json +3 -3
  40. package/package.json +1 -1
  41. package/scripts/build-native.mjs +8 -1
  42. package/scripts/model-e2e.mjs +11 -2
  43. package/src/backend.ts +18 -2
  44. package/src/index.ts +6 -0
  45. package/src/providers/macos.ts +35 -19
  46. package/src/providers/native-helper.ts +154 -5
  47. package/src/providers/unsupported.ts +67 -0
  48. package/src/service.ts +103 -16
  49. package/src/skill.ts +7 -1
  50. package/src/tools.ts +18 -0
  51. package/src/types.ts +21 -1
@@ -14,6 +14,7 @@ import type {
14
14
  BackendObservation,
15
15
  BackendObserveOptions,
16
16
  ComputerUseBackend,
17
+ CursorVisibility,
17
18
  } from '../backend.ts'
18
19
  import {
19
20
  Config,
@@ -22,10 +23,10 @@ import {
22
23
  type ComputerUseConfig,
23
24
  type ResolvedComputerUseConfig,
24
25
  } from '../config.ts'
25
- import { ComputerUseError } from '../errors.ts'
26
26
  import { ComputerUseService } from '../service.ts'
27
27
  import type { ComputerAppIdentity, ComputerAppSelector, ComputerAppSummary } from '../types.ts'
28
28
  import { NativeHelperClient } from './native-helper.ts'
29
+ import { UnsupportedPlatformBackend } from './unsupported.ts'
29
30
 
30
31
  interface NativeHealth {
31
32
  helperVersion: string
@@ -35,8 +36,14 @@ interface NativeHealth {
35
36
 
36
37
  interface NativeObservation extends BackendObservation {}
37
38
 
39
+ function createBackend(ctx: Context, config: ResolvedComputerUseConfig): ComputerUseBackend {
40
+ return process.platform === 'darwin'
41
+ ? new MacOSBackend(ctx, config)
42
+ : new UnsupportedPlatformBackend(process.platform)
43
+ }
44
+
38
45
  /** Fixed-command native backend. */
39
- class MacOSBackend implements ComputerUseBackend {
46
+ export class MacOSBackend implements ComputerUseBackend {
40
47
  readonly name = 'macos-ax' as const
41
48
  readonly client: NativeHelperClient
42
49
 
@@ -79,11 +86,17 @@ class MacOSBackend implements ComputerUseBackend {
79
86
  }, signal)
80
87
  }
81
88
 
82
- async visualizeCursor(action: BackendCursorAction, phase: 'before' | 'after', signal: AbortSignal): Promise<void> {
83
- if (this.config.interaction.cursorVisualization !== 'visible') return
89
+ async visualizeCursor(action: BackendCursorAction, phase: 'before' | 'after', signal: AbortSignal): Promise<CursorVisibility> {
90
+ if (this.config.interaction.cursorVisualization !== 'visible') return { visible: false, reason: 'the agent cursor is disabled by configuration' }
91
+ // The overlay answers per command; the least visible outcome wins, because
92
+ // a cursor that vanished partway through is a cursor the user cannot follow.
93
+ let outcome: CursorVisibility = { visible: true }
94
+ const record = (response: CursorVisibility): void => {
95
+ if (!response.visible && outcome.visible) outcome = response
96
+ }
84
97
  const autoHideMs = this.config.interaction.cursorAutoHideMs
85
98
  const move = async (point: { x: number; y: number }, durationMs: number): Promise<void> => {
86
- await this.client.cursorCommand({
99
+ record(await this.client.cursorCommand({
87
100
  op: 'move',
88
101
  x: point.x,
89
102
  y: point.y,
@@ -92,36 +105,39 @@ class MacOSBackend implements ComputerUseBackend {
92
105
  targetPid: action.targetPid,
93
106
  targetWindowNumber: action.targetWindowNumber,
94
107
  targetWindowFrame: action.targetWindowFrame,
95
- }, signal)
108
+ }, signal))
96
109
  }
97
110
  if (phase === 'after') {
98
- if (action.kind === 'drag') await this.client.cursorCommand({
99
- op: 'release',
111
+ // Every action validates the bound target after native input. Only drag
112
+ // needs release semantics; click and scroll use a side-effect-free check.
113
+ record(await this.client.cursorCommand({
114
+ op: action.kind === 'drag' ? 'release' : 'validate',
100
115
  autoHideMs,
101
116
  targetPid: action.targetPid,
102
117
  targetWindowNumber: action.targetWindowNumber,
103
118
  targetWindowFrame: action.targetWindowFrame,
104
- }, signal)
105
- return
119
+ }, signal))
120
+ return outcome
106
121
  }
107
122
  const start = action.kind === 'drag' ? action.from : action.to
108
- if (start === undefined) return
123
+ if (start === undefined) return { visible: false, reason: 'this action has no cursor position to show' }
109
124
  await move(start, this.config.interaction.cursorMotionMs)
110
125
  if (this.config.interaction.cursorMotionMs > 0) {
111
126
  await delay(this.config.interaction.cursorMotionMs, undefined, { signal })
112
127
  }
113
- if (action.kind === 'scroll') return
114
- await this.client.cursorCommand({
128
+ if (action.kind === 'scroll') return outcome
129
+ record(await this.client.cursorCommand({
115
130
  op: 'press',
116
131
  autoHideMs,
117
132
  targetPid: action.targetPid,
118
133
  targetWindowNumber: action.targetWindowNumber,
119
134
  targetWindowFrame: action.targetWindowFrame,
120
135
  sustainedPress: action.kind === 'drag',
121
- }, signal)
136
+ }, signal))
122
137
  if (action.kind === 'drag') {
123
138
  await move(action.to, Math.max(this.config.interaction.cursorMotionMs, 240))
124
139
  }
140
+ return outcome
125
141
  }
126
142
 
127
143
  async dispose(): Promise<void> {
@@ -152,20 +168,20 @@ export class MacOSComputerUseProvider extends ComputerUseService {
152
168
  private readonly settings
153
169
 
154
170
  constructor(ctx: Context, config: ComputerUseConfig = {}) {
155
- if (process.platform !== 'darwin') {
156
- throw new ComputerUseError('COMPUTER_UNSUPPORTED_PLATFORM', `dsh-computer-use 0.1.0 supports macOS only; current platform is ${process.platform}`)
157
- }
158
171
  const settings = ctx.settings.register(COMPUTER_USE_SETTINGS_NAMESPACE, Config, {
159
172
  base: config,
160
173
  applies: 'live',
161
174
  validate: (value) => { resolveConfig(value) },
162
175
  })
163
176
  const resolved = resolveConfig(settings.get())
164
- super(ctx, new MacOSBackend(ctx, resolved), resolved)
177
+ super(ctx, createBackend(ctx, resolved), resolved)
165
178
  this.settings = settings
179
+ if (process.platform !== 'darwin') {
180
+ ctx.logger.warn('dsh-computer-use: supports macOS only; Computer Use Tools are disabled on %s', process.platform)
181
+ }
166
182
  ctx.effect(() => this.settings.watch(async (next) => {
167
183
  const candidate = resolveConfig(next)
168
- const backend = new MacOSBackend(ctx, candidate)
184
+ const backend = createBackend(ctx, candidate)
169
185
  try {
170
186
  await this.reconfigure(backend, candidate)
171
187
  } catch (error) {
@@ -8,6 +8,7 @@ import { dirname, resolve } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import type { Context } from '@deepseek-ai/cordis'
10
10
  import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
11
+ import type { CursorVisibility } from '../backend.ts'
11
12
  import type { ResolvedComputerUseConfig } from '../config.ts'
12
13
  import { ComputerUseError, computerUseError, type ComputerUseErrorCode } from '../errors.ts'
13
14
 
@@ -38,14 +39,26 @@ interface HelperSuccess<T> {
38
39
 
39
40
  type HelperEnvelope<T> = HelperFailure | HelperSuccess<T>
40
41
 
42
+ /** The overlay normally answers in under a millisecond; silence is a visibility failure. */
43
+ // Keep this off the native action's critical path. A timed-out generation is
44
+ // discarded before the next serialized command, so a late frame cannot be
45
+ // mistaken for the next command's response.
46
+ const CURSOR_RESPONSE_TIMEOUT_MS = 120
47
+
41
48
  const CURSOR_READY_TIMEOUT_MS = 2_000
42
49
  const CURSOR_PROTOCOL_MAX_BYTES = 64 * 1024
43
50
 
51
+ type CursorProtocolFrame =
52
+ | { kind: 'response'; response: Record<string, unknown> }
53
+ | { kind: 'failure'; reason: string }
54
+
44
55
  interface CursorProcess {
45
56
  stdin: Writable
46
57
  done: Promise<SubprocessOutcome>
47
58
  terminate: () => void
48
59
  waitForExit: SubprocessHandle['waitForExit']
60
+ /** Next response line from this process generation, in command order. */
61
+ nextResponse: () => Promise<CursorProtocolFrame>
49
62
  }
50
63
 
51
64
  function collected(reader: SubprocessOutputReader | undefined): string {
@@ -63,6 +76,59 @@ function nativeRoot(): string {
63
76
  return fileURLToPath(new URL('../../native/macos/', import.meta.url))
64
77
  }
65
78
 
79
+ function cursorErrorMessage(error: unknown): string | undefined {
80
+ if (typeof error === 'string' && error.length > 0) return error.slice(0, 1000)
81
+ if (typeof error !== 'object' || error === null) return undefined
82
+ const message = (error as { message?: unknown }).message
83
+ return typeof message === 'string' && message.length > 0 ? message.slice(0, 1000) : undefined
84
+ }
85
+
86
+ function normalizeCursorResponse(
87
+ command: Record<string, unknown>,
88
+ response: Record<string, unknown>,
89
+ ): { result: CursorVisibility; discardGeneration: boolean } {
90
+ if (response.ok === false) {
91
+ const detail = cursorErrorMessage(response.error)
92
+ return {
93
+ result: {
94
+ visible: false,
95
+ reason: detail === undefined
96
+ ? 'the native cursor overlay rejected its command'
97
+ : `the native cursor overlay rejected its command: ${detail}`,
98
+ },
99
+ discardGeneration: false,
100
+ }
101
+ }
102
+ if (response.ok !== true) {
103
+ return {
104
+ result: { visible: false, reason: 'the native cursor overlay returned a malformed response' },
105
+ discardGeneration: true,
106
+ }
107
+ }
108
+ if (typeof command.op !== 'string' || response.op !== command.op) {
109
+ return {
110
+ result: { visible: false, reason: 'the native cursor overlay response did not match its command' },
111
+ discardGeneration: true,
112
+ }
113
+ }
114
+ if (response.visible === true) return { result: { visible: true }, discardGeneration: false }
115
+ if (response.visible === false) {
116
+ return {
117
+ result: {
118
+ visible: false,
119
+ ...(typeof response.reason === 'string' && response.reason.length > 0
120
+ ? { reason: response.reason.slice(0, 1000) }
121
+ : { reason: 'the native cursor overlay reported that the cursor is not visible' }),
122
+ },
123
+ discardGeneration: false,
124
+ }
125
+ }
126
+ return {
127
+ result: { visible: false, reason: 'the native cursor overlay did not report boolean visibility' },
128
+ discardGeneration: true,
129
+ }
130
+ }
131
+
66
132
  /** Exact helper paths and integrity data for one active generation. */
67
133
  export interface PreparedNativeHelper {
68
134
  path: string
@@ -75,6 +141,7 @@ export class NativeHelperClient {
75
141
  private prepared?: PreparedNativeHelper
76
142
  private cursor: CursorProcess | undefined
77
143
  private cursorStart: { promise: Promise<CursorProcess> } | undefined
144
+ private cursorCommandTail: Promise<void> = Promise.resolve()
78
145
 
79
146
  constructor(
80
147
  private readonly ctx: Context,
@@ -186,8 +253,14 @@ export class NativeHelperClient {
186
253
  return envelope.value
187
254
  }
188
255
 
189
- /** Send one best-effort command to the persistent, click-through Agent cursor overlay. */
190
- async cursorCommand(command: Record<string, unknown>, signal: AbortSignal): Promise<void> {
256
+ /** Send one serialized command to the persistent, click-through Agent cursor overlay. */
257
+ cursorCommand(command: Record<string, unknown>, signal: AbortSignal): Promise<CursorVisibility> {
258
+ const run = this.cursorCommandTail.then(async () => await this.executeCursorCommand(command, signal))
259
+ this.cursorCommandTail = run.then(() => undefined, () => undefined)
260
+ return run
261
+ }
262
+
263
+ private async executeCursorCommand(command: Record<string, unknown>, signal: AbortSignal): Promise<CursorVisibility> {
191
264
  const prepared = this.prepared ?? await this.prepare(signal)
192
265
  const cursor = await this.getCursor(prepared, signal)
193
266
  signal.throwIfAborted()
@@ -198,13 +271,25 @@ export class NativeHelperClient {
198
271
  else rejectWrite(error)
199
272
  })
200
273
  })
274
+ const frame = await cursor.nextResponse()
275
+ if (frame.kind === 'failure') {
276
+ this.discardCursor(cursor)
277
+ return { visible: false, reason: frame.reason }
278
+ }
279
+ const normalized = normalizeCursorResponse(command, frame.response)
280
+ if (normalized.discardGeneration) this.discardCursor(cursor)
281
+ return normalized.result
201
282
  } catch (error) {
202
- if (this.cursor === cursor) this.cursor = undefined
203
- cursor.terminate()
283
+ this.discardCursor(cursor)
204
284
  throw computerUseError(error, 'native cursor overlay command failed')
205
285
  }
206
286
  }
207
287
 
288
+ private discardCursor(cursor: CursorProcess): void {
289
+ if (this.cursor === cursor) this.cursor = undefined
290
+ cursor.terminate()
291
+ }
292
+
208
293
  /** Stop the cursor process before a provider generation is replaced or disposed. */
209
294
  async dispose(): Promise<void> {
210
295
  const cursor = this.cursor ?? await this.cursorStart?.promise.catch(() => undefined)
@@ -308,10 +393,74 @@ export class NativeHelperClient {
308
393
  { cause: error },
309
394
  )
310
395
  }
311
- handle.stdout.resume()
396
+ // Commands are serialized by the client and the overlay answers one line
397
+ // per command. A protocol failure invalidates this entire process
398
+ // generation; its buffered or late frames can never reach a replacement.
399
+ const pending: Array<(frame: CursorProtocolFrame) => void> = []
400
+ const buffered: CursorProtocolFrame[] = []
401
+ const dispatch = (frame: CursorProtocolFrame): void => {
402
+ const waiter = pending.shift()
403
+ if (waiter === undefined) buffered.push(frame)
404
+ else waiter(frame)
405
+ }
406
+ let residue = ''
407
+ handle.stdout.setEncoding('utf8').on('data', (chunk: string) => {
408
+ residue += chunk
409
+ if (Buffer.byteLength(residue) > CURSOR_PROTOCOL_MAX_BYTES && !residue.includes('\n')) {
410
+ residue = ''
411
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay response exceeded its protocol limit' })
412
+ return
413
+ }
414
+ while (true) {
415
+ const newline = residue.indexOf('\n')
416
+ if (newline < 0) break
417
+ const line = residue.slice(0, newline).trim()
418
+ residue = residue.slice(newline + 1)
419
+ if (line.length === 0) continue
420
+ if (Buffer.byteLength(line) > CURSOR_PROTOCOL_MAX_BYTES) {
421
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay response exceeded its protocol limit' })
422
+ continue
423
+ }
424
+ let parsed: unknown
425
+ try {
426
+ parsed = JSON.parse(line)
427
+ } catch {
428
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay returned invalid JSON' })
429
+ continue
430
+ }
431
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
432
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay returned a malformed response' })
433
+ continue
434
+ }
435
+ dispatch({ kind: 'response', response: parsed as Record<string, unknown> })
436
+ }
437
+ })
438
+ handle.stdout.once('end', () => {
439
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay closed before replying' })
440
+ })
441
+ handle.stdout.once('error', error => {
442
+ dispatch({ kind: 'failure', reason: `the native cursor overlay response stream failed: ${error.message}` })
443
+ })
312
444
  return {
313
445
  stdin: handle.stdin,
314
446
  done: handle.done,
447
+ nextResponse: async () => {
448
+ const ready = buffered.shift()
449
+ if (ready !== undefined) return ready
450
+ return await new Promise<CursorProtocolFrame>((resolveResponse) => {
451
+ const settle = (frame: CursorProtocolFrame): void => {
452
+ const index = pending.indexOf(settle)
453
+ if (index >= 0) pending.splice(index, 1)
454
+ clearTimeout(timer)
455
+ resolveResponse(frame)
456
+ }
457
+ const timer = setTimeout(() => settle({
458
+ kind: 'failure',
459
+ reason: `the native cursor overlay did not respond within ${CURSOR_RESPONSE_TIMEOUT_MS} milliseconds`,
460
+ }), CURSOR_RESPONSE_TIMEOUT_MS)
461
+ pending.push(settle)
462
+ })
463
+ },
315
464
  terminate: () => {
316
465
  cursorSignal.abort()
317
466
  handle.terminate()
@@ -0,0 +1,67 @@
1
+ /** Non-macOS fallback backend: keeps the Service injectable, fails closed, and reports an unavailable health state. */
2
+
3
+ import type {
4
+ BackendActionRequest,
5
+ BackendActionResult,
6
+ BackendCursorAction,
7
+ BackendHealth,
8
+ BackendObservation,
9
+ BackendObserveOptions,
10
+ ComputerUseBackend,
11
+ CursorVisibility,
12
+ } from '../backend.ts'
13
+ import { ComputerUseError } from '../errors.ts'
14
+ import type { ComputerAppIdentity, ComputerAppSelector, ComputerAppSummary } from '../types.ts'
15
+
16
+ function unsupported(platform: NodeJS.Platform): ComputerUseError {
17
+ return new ComputerUseError(
18
+ 'COMPUTER_UNSUPPORTED_PLATFORM',
19
+ `dsh-computer-use supports macOS only; Computer Use is disabled on ${platform}`,
20
+ )
21
+ }
22
+
23
+ /** Backend that reports a clear unavailable state instead of failing profile startup on non-macOS hosts. */
24
+ export class UnsupportedPlatformBackend implements ComputerUseBackend {
25
+ readonly name = 'unsupported' as const
26
+ readonly helperPath = ''
27
+
28
+ constructor(private readonly platform: NodeJS.Platform) {}
29
+
30
+ async health(): Promise<BackendHealth> {
31
+ const failure = unsupported(this.platform)
32
+ return {
33
+ ready: false,
34
+ error: failure.message,
35
+ helperVersion: 'unsupported',
36
+ helperSha256: '',
37
+ accessibility: 'unavailable',
38
+ screenRecording: 'unavailable',
39
+ }
40
+ }
41
+
42
+ resolveApp(_selector: ComputerAppSelector): Promise<ComputerAppIdentity> {
43
+ return Promise.reject(unsupported(this.platform))
44
+ }
45
+
46
+ listApps(): Promise<ComputerAppSummary[]> {
47
+ return Promise.reject(unsupported(this.platform))
48
+ }
49
+
50
+ observe(_app: ComputerAppIdentity, _options: BackendObserveOptions): Promise<BackendObservation> {
51
+ return Promise.reject(unsupported(this.platform))
52
+ }
53
+
54
+ act(_request: BackendActionRequest): Promise<BackendActionResult> {
55
+ return Promise.reject(unsupported(this.platform))
56
+ }
57
+
58
+ visualizeCursor(_action: BackendCursorAction, _phase: 'before' | 'after'): Promise<CursorVisibility> {
59
+ return Promise.reject(unsupported(this.platform))
60
+ }
61
+
62
+ openSettings(_kind: 'accessibility' | 'screen-recording'): Promise<void> {
63
+ return Promise.reject(unsupported(this.platform))
64
+ }
65
+
66
+ async dispose(): Promise<void> {}
67
+ }
package/src/service.ts CHANGED
@@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto'
4
4
  import { setTimeout as delay } from 'node:timers/promises'
5
5
  import { Context, Service } from '@deepseek-ai/cordis'
6
6
  import type { Agent } from '@deepseek-ai/dsh-agent'
7
- import type { BackendCursorAction, BackendObservation, ComputerUseBackend } from './backend.ts'
7
+ import type { BackendCursorAction, BackendHealth, BackendObservation, ComputerUseBackend, CursorVisibility } from './backend.ts'
8
8
  import { allocateScreenshotPath, describeScreenshot } from './artifacts.ts'
9
9
  import type { ResolvedComputerUseConfig } from './config.ts'
10
10
  import { ComputerConfirmationManager } from './confirmations.ts'
@@ -188,6 +188,7 @@ export class ComputerUseService extends Service {
188
188
  private config: ResolvedComputerUseConfig
189
189
  private generation = 1
190
190
  private readonly agents = new Map<Agent, AgentState>()
191
+ private readonly actionTails = new Map<string, Promise<void>>()
191
192
  private readonly leases: ComputerLeaseManager
192
193
  private readonly confirmations: ComputerConfirmationManager
193
194
  private readonly lifecycle = new AbortController()
@@ -197,6 +198,18 @@ export class ComputerUseService extends Service {
197
198
  screenRecording: 'unavailable',
198
199
  }
199
200
 
201
+ /** Persist backend health facts while allowing a disabled provider to stay ready=false with a visible reason. */
202
+ private applyHealth(health: BackendHealth): void {
203
+ this.healthState = {
204
+ ready: health.ready ?? true,
205
+ helperVersion: health.helperVersion,
206
+ helperSha256: health.helperSha256,
207
+ accessibility: health.accessibility,
208
+ screenRecording: health.screenRecording,
209
+ ...(health.error === undefined ? {} : { lastError: health.error }),
210
+ }
211
+ }
212
+
200
213
  /** Register `ctx.computerUse` using one validated backend and configuration generation. */
201
214
  constructor(ctx: Context, backend: ComputerUseBackend, config: ResolvedComputerUseConfig) {
202
215
  super(ctx, 'computerUse')
@@ -215,8 +228,7 @@ export class ComputerUseService extends Service {
215
228
  protected async initialize(): Promise<void> {
216
229
  try {
217
230
  await this.leases.initialize()
218
- const health = await this.backend.health(this.lifecycle.signal)
219
- this.healthState = { ready: true, ...health }
231
+ this.applyHealth(await this.backend.health(this.lifecycle.signal))
220
232
  } catch (error) {
221
233
  const failure = computerUseError(error, 'Computer Use provider initialization failed')
222
234
  this.healthState = {
@@ -237,7 +249,7 @@ export class ComputerUseService extends Service {
237
249
  this.config = config
238
250
  this.generation += 1
239
251
  this.clearState()
240
- this.healthState = { ready: true, ...health }
252
+ this.applyHealth(health)
241
253
  await previous.dispose()
242
254
  }
243
255
 
@@ -245,7 +257,7 @@ export class ComputerUseService extends Service {
245
257
  status(): ComputerUseStatus {
246
258
  return {
247
259
  platform: process.platform,
248
- provider: 'macos-ax',
260
+ provider: this.backend.name,
249
261
  generation: this.generation,
250
262
  helperPath: this.backend.helperPath,
251
263
  ...this.healthState,
@@ -255,8 +267,7 @@ export class ComputerUseService extends Service {
255
267
  /** Re-run non-mutating provider health checks. */
256
268
  async health(signal: AbortSignal): Promise<ComputerUseStatus> {
257
269
  try {
258
- const health = await this.backend.health(AbortSignal.any([signal, this.lifecycle.signal]))
259
- this.healthState = { ready: true, ...health }
270
+ this.applyHealth(await this.backend.health(AbortSignal.any([signal, this.lifecycle.signal])))
260
271
  } catch (error) {
261
272
  const failure = computerUseError(error, 'Computer Use health check failed')
262
273
  this.healthState = { ...this.healthState, ready: false, lastError: failure.message }
@@ -300,6 +311,19 @@ export class ComputerUseService extends Service {
300
311
  const signal = AbortSignal.any([context.signal, this.lifecycle.signal])
301
312
  const stored = this.requireObservation(action.observationId, context.agent)
302
313
  if (action.kind === 'wait') return await this.wait(stored, action, context, signal)
314
+ return await this.enqueueAction(stored.backend.app, async () => {
315
+ signal.throwIfAborted()
316
+ return await this.actNow(action, context, signal)
317
+ })
318
+ }
319
+
320
+ /** Keep this service's actions for one process ordered through post-action observation. */
321
+ private async actNow(
322
+ action: Exclude<ComputerActionRequest, { kind: 'wait' }>,
323
+ context: ComputerUseContext,
324
+ signal: AbortSignal,
325
+ ): Promise<ComputerActionResult> {
326
+ const stored = this.requireObservation(action.observationId, context.agent)
303
327
  const index = targetIndex(action)
304
328
  const handle = targetHandle(action)
305
329
  const originalElement = index === undefined ? undefined : stored.backend.elements.find(candidate => candidate.index === index)
@@ -366,13 +390,31 @@ export class ComputerUseService extends Service {
366
390
  }
367
391
  this.confirmations.consume(context.agent, stored.backend.app, action)
368
392
  const visualization = cursorAction(action, element, actionObservation.window, actionObservation.app)
393
+ const cursorRequested = this.config.interaction.cursorVisualization === 'visible'
394
+ && (action.kind === 'click' || action.kind === 'scroll' || action.kind === 'drag')
369
395
  let cursorStarted = false
370
- if (visualization !== undefined && this.config.interaction.cursorVisualization === 'visible') {
396
+ // The overlay is presentation-only and never blocks native input, but its
397
+ // least-visible outcome is reported across both phases of the action.
398
+ let cursorState: CursorVisibility | undefined
399
+ const recordCursor = (next: CursorVisibility): void => {
400
+ if (cursorState === undefined || (cursorState.visible && !next.visible)) cursorState = next
401
+ }
402
+ if (cursorRequested && visualization === undefined) {
403
+ recordCursor({
404
+ visible: false,
405
+ reason: actionObservation.window?.id === undefined
406
+ ? 'the agent cursor could not be bound because the target window has no stable window id'
407
+ : 'the agent cursor could not be placed because this action has no observable cursor position',
408
+ })
409
+ } else if (visualization !== undefined && cursorRequested) {
371
410
  try {
372
- await this.backend.visualizeCursor(visualization, 'before', signal)
411
+ recordCursor(await this.backend.visualizeCursor(visualization, 'before', signal))
373
412
  cursorStarted = true
374
- } catch {
375
- // The overlay is presentation-only; native input remains authoritative.
413
+ } catch (error) {
414
+ recordCursor({
415
+ visible: false,
416
+ reason: `the agent cursor could not be driven before the action: ${error instanceof Error ? error.message : String(error)}`,
417
+ })
376
418
  }
377
419
  }
378
420
  let outcome
@@ -390,14 +432,21 @@ export class ComputerUseService extends Service {
390
432
  } finally {
391
433
  if (cursorStarted && visualization !== undefined) {
392
434
  try {
393
- await this.backend.visualizeCursor(visualization, 'after', signal)
394
- } catch {
395
- // The overlay is presentation-only; native input remains authoritative.
435
+ recordCursor(await this.backend.visualizeCursor(visualization, 'after', signal))
436
+ } catch (error) {
437
+ recordCursor({
438
+ visible: false,
439
+ reason: `the agent cursor could not be validated after the action: ${error instanceof Error ? error.message : String(error)}`,
440
+ })
396
441
  }
397
442
  }
398
443
  }
444
+ // The settle loop reports whether the bounded structural observation
445
+ // changed. It complements routing facts without claiming causal proof or
446
+ // visibility into pixel-only, transient, or remote effects.
399
447
  const started = Date.now()
400
448
  let latest: BackendObservation | undefined
449
+ let settled = false
401
450
  do {
402
451
  if (this.config.settleMs > 0) await delay(this.config.settleMs, undefined, { signal })
403
452
  latest = await this.backend.observe(stored.backend.app, {
@@ -406,7 +455,7 @@ export class ComputerUseService extends Service {
406
455
  maxDepth: this.config.maxDepth,
407
456
  maxTextBytes: this.config.maxTextBytes,
408
457
  }, signal)
409
- if (latest.stateHash !== actionObservation.stateHash) break
458
+ if (latest.stateHash !== actionObservation.stateHash) { settled = true; break }
410
459
  } while (Date.now() - started < this.config.maxSettleMs)
411
460
  const observation = await this.capture(
412
461
  stored.backend.app,
@@ -421,11 +470,41 @@ export class ComputerUseService extends Service {
421
470
  activation: outcome.activation,
422
471
  pointerInput: outcome.pointerInput,
423
472
  pointerRouting: outcome.pointerRouting,
473
+ // Only reported when the cursor is meant to be showing and is not, so a
474
+ // normal result stays unchanged and a lost cursor becomes visible to the
475
+ // caller instead of to nobody.
476
+ ...(cursorState === undefined || cursorState.visible ? {} : {
477
+ agentCursor: { visible: false, ...(cursorState.reason === undefined ? {} : { reason: cursorState.reason }) },
478
+ }),
479
+ // This reports only what the bounded structural observation can prove.
480
+ // Pixel-only, remote, or transient effects remain outside this hash and
481
+ // must not be described as action failure.
482
+ effect: {
483
+ observedStateChanged: settled,
484
+ observedForMs: Date.now() - started,
485
+ ...(settled ? {} : {
486
+ note: 'no change was observed in the window title, id, frame, or accessibility element tree;'
487
+ + ' pixel-only, remote, or transient effects may still have occurred',
488
+ }),
489
+ },
424
490
  ...(resolution === undefined ? {} : { resolution }),
425
491
  observation,
426
492
  }
427
493
  }
428
494
 
495
+ private async enqueueAction<T>(app: ComputerAppIdentity, operation: () => Promise<T>): Promise<T> {
496
+ const key = `${app.bundleId}:${app.pid}`
497
+ const previous = this.actionTails.get(key) ?? Promise.resolve()
498
+ const run = previous.catch(() => undefined).then(operation)
499
+ const tail = run.then(() => undefined, () => undefined)
500
+ this.actionTails.set(key, tail)
501
+ try {
502
+ return await run
503
+ } finally {
504
+ if (this.actionTails.get(key) === tail) this.actionTails.delete(key)
505
+ }
506
+ }
507
+
429
508
  /** Release all scoped observations and confirmations for one disposed Agent. */
430
509
  releaseAgent(agent: Agent): void {
431
510
  this.agents.delete(agent)
@@ -545,7 +624,8 @@ export class ComputerUseService extends Service {
545
624
  if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > this.config.maxSettleMs) {
546
625
  throw new ComputerUseError('COMPUTER_TIMEOUT', `wait timeout must be between 100 and ${this.config.maxSettleMs} milliseconds`)
547
626
  }
548
- const deadline = Date.now() + timeoutMs
627
+ const started = Date.now()
628
+ const deadline = started + timeoutMs
549
629
  let latest = stored.backend
550
630
  while (!matchesWait(latest, action)) {
551
631
  if (Date.now() >= deadline) throw new ComputerUseError('COMPUTER_TIMEOUT', 'wait condition was not met before the configured deadline')
@@ -568,6 +648,13 @@ export class ComputerUseService extends Service {
568
648
  action: 'wait',
569
649
  channel: 'wait',
570
650
  activation: 'not-requested',
651
+ effect: {
652
+ observedStateChanged: latest.stateHash !== stored.backend.stateHash,
653
+ observedForMs: Date.now() - started,
654
+ ...(latest.stateHash === stored.backend.stateHash
655
+ ? { note: 'the wait condition was already satisfied by the referenced observation' }
656
+ : {}),
657
+ },
571
658
  pointerInput: false,
572
659
  pointerRouting: 'none',
573
660
  observation,
package/src/skill.ts CHANGED
@@ -29,7 +29,13 @@ plugin; an API or CLI; browser automation for browser tasks; then Computer Use.
29
29
  carries the same observationId.
30
30
  5. Every successful action returns the fresh post-action observation. Read it
31
31
  before deciding the next step; do not add a redundant observe unless you need
32
- a full tree or screenshot that the returned state omitted.
32
+ a full tree or screenshot that the returned state omitted. The accompanying
33
+ effect.observedStateChanged reports only changes in the window metadata and
34
+ Accessibility element tree. False does not prove the action failed: pixel-only,
35
+ transient, remote, or otherwise external effects may still have occurred.
36
+ Verify those effects visually before retrying, especially for sensitive actions.
37
+ 6. When agentCursor is present, the separate Agent cursor was expected but is not
38
+ visible. Read its reason and do not claim the user can see where the action landed.
33
39
 
34
40
  ## Visual evidence handoff
35
41