@anionex/dsh-computer-use 0.1.0 → 0.2.1

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 +153 -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 +156 -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,28 @@ interface HelperSuccess<T> {
38
39
 
39
40
  type HelperEnvelope<T> = HelperFailure | HelperSuccess<T>
40
41
 
42
+ /**
43
+ * The response includes synchronous WindowServer validation, which can exceed
44
+ * a scheduler tick under load even though the protocol itself is local.
45
+ */
46
+ // Keep the bound finite. A timed-out generation is discarded before the next
47
+ // serialized command, so a late frame cannot satisfy a later command.
48
+ const CURSOR_RESPONSE_TIMEOUT_MS = 1_000
49
+
41
50
  const CURSOR_READY_TIMEOUT_MS = 2_000
42
51
  const CURSOR_PROTOCOL_MAX_BYTES = 64 * 1024
43
52
 
53
+ type CursorProtocolFrame =
54
+ | { kind: 'response'; response: Record<string, unknown> }
55
+ | { kind: 'failure'; reason: string }
56
+
44
57
  interface CursorProcess {
45
58
  stdin: Writable
46
59
  done: Promise<SubprocessOutcome>
47
60
  terminate: () => void
48
61
  waitForExit: SubprocessHandle['waitForExit']
62
+ /** Next response line from this process generation, in command order. */
63
+ nextResponse: () => Promise<CursorProtocolFrame>
49
64
  }
50
65
 
51
66
  function collected(reader: SubprocessOutputReader | undefined): string {
@@ -63,6 +78,59 @@ function nativeRoot(): string {
63
78
  return fileURLToPath(new URL('../../native/macos/', import.meta.url))
64
79
  }
65
80
 
81
+ function cursorErrorMessage(error: unknown): string | undefined {
82
+ if (typeof error === 'string' && error.length > 0) return error.slice(0, 1000)
83
+ if (typeof error !== 'object' || error === null) return undefined
84
+ const message = (error as { message?: unknown }).message
85
+ return typeof message === 'string' && message.length > 0 ? message.slice(0, 1000) : undefined
86
+ }
87
+
88
+ function normalizeCursorResponse(
89
+ command: Record<string, unknown>,
90
+ response: Record<string, unknown>,
91
+ ): { result: CursorVisibility; discardGeneration: boolean } {
92
+ if (response.ok === false) {
93
+ const detail = cursorErrorMessage(response.error)
94
+ return {
95
+ result: {
96
+ visible: false,
97
+ reason: detail === undefined
98
+ ? 'the native cursor overlay rejected its command'
99
+ : `the native cursor overlay rejected its command: ${detail}`,
100
+ },
101
+ discardGeneration: false,
102
+ }
103
+ }
104
+ if (response.ok !== true) {
105
+ return {
106
+ result: { visible: false, reason: 'the native cursor overlay returned a malformed response' },
107
+ discardGeneration: true,
108
+ }
109
+ }
110
+ if (typeof command.op !== 'string' || response.op !== command.op) {
111
+ return {
112
+ result: { visible: false, reason: 'the native cursor overlay response did not match its command' },
113
+ discardGeneration: true,
114
+ }
115
+ }
116
+ if (response.visible === true) return { result: { visible: true }, discardGeneration: false }
117
+ if (response.visible === false) {
118
+ return {
119
+ result: {
120
+ visible: false,
121
+ ...(typeof response.reason === 'string' && response.reason.length > 0
122
+ ? { reason: response.reason.slice(0, 1000) }
123
+ : { reason: 'the native cursor overlay reported that the cursor is not visible' }),
124
+ },
125
+ discardGeneration: false,
126
+ }
127
+ }
128
+ return {
129
+ result: { visible: false, reason: 'the native cursor overlay did not report boolean visibility' },
130
+ discardGeneration: true,
131
+ }
132
+ }
133
+
66
134
  /** Exact helper paths and integrity data for one active generation. */
67
135
  export interface PreparedNativeHelper {
68
136
  path: string
@@ -75,6 +143,7 @@ export class NativeHelperClient {
75
143
  private prepared?: PreparedNativeHelper
76
144
  private cursor: CursorProcess | undefined
77
145
  private cursorStart: { promise: Promise<CursorProcess> } | undefined
146
+ private cursorCommandTail: Promise<void> = Promise.resolve()
78
147
 
79
148
  constructor(
80
149
  private readonly ctx: Context,
@@ -186,8 +255,14 @@ export class NativeHelperClient {
186
255
  return envelope.value
187
256
  }
188
257
 
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> {
258
+ /** Send one serialized command to the persistent, click-through Agent cursor overlay. */
259
+ cursorCommand(command: Record<string, unknown>, signal: AbortSignal): Promise<CursorVisibility> {
260
+ const run = this.cursorCommandTail.then(async () => await this.executeCursorCommand(command, signal))
261
+ this.cursorCommandTail = run.then(() => undefined, () => undefined)
262
+ return run
263
+ }
264
+
265
+ private async executeCursorCommand(command: Record<string, unknown>, signal: AbortSignal): Promise<CursorVisibility> {
191
266
  const prepared = this.prepared ?? await this.prepare(signal)
192
267
  const cursor = await this.getCursor(prepared, signal)
193
268
  signal.throwIfAborted()
@@ -198,13 +273,25 @@ export class NativeHelperClient {
198
273
  else rejectWrite(error)
199
274
  })
200
275
  })
276
+ const frame = await cursor.nextResponse()
277
+ if (frame.kind === 'failure') {
278
+ this.discardCursor(cursor)
279
+ return { visible: false, reason: frame.reason }
280
+ }
281
+ const normalized = normalizeCursorResponse(command, frame.response)
282
+ if (normalized.discardGeneration) this.discardCursor(cursor)
283
+ return normalized.result
201
284
  } catch (error) {
202
- if (this.cursor === cursor) this.cursor = undefined
203
- cursor.terminate()
285
+ this.discardCursor(cursor)
204
286
  throw computerUseError(error, 'native cursor overlay command failed')
205
287
  }
206
288
  }
207
289
 
290
+ private discardCursor(cursor: CursorProcess): void {
291
+ if (this.cursor === cursor) this.cursor = undefined
292
+ cursor.terminate()
293
+ }
294
+
208
295
  /** Stop the cursor process before a provider generation is replaced or disposed. */
209
296
  async dispose(): Promise<void> {
210
297
  const cursor = this.cursor ?? await this.cursorStart?.promise.catch(() => undefined)
@@ -308,10 +395,74 @@ export class NativeHelperClient {
308
395
  { cause: error },
309
396
  )
310
397
  }
311
- handle.stdout.resume()
398
+ // Commands are serialized by the client and the overlay answers one line
399
+ // per command. A protocol failure invalidates this entire process
400
+ // generation; its buffered or late frames can never reach a replacement.
401
+ const pending: Array<(frame: CursorProtocolFrame) => void> = []
402
+ const buffered: CursorProtocolFrame[] = []
403
+ const dispatch = (frame: CursorProtocolFrame): void => {
404
+ const waiter = pending.shift()
405
+ if (waiter === undefined) buffered.push(frame)
406
+ else waiter(frame)
407
+ }
408
+ let residue = ''
409
+ handle.stdout.setEncoding('utf8').on('data', (chunk: string) => {
410
+ residue += chunk
411
+ if (Buffer.byteLength(residue) > CURSOR_PROTOCOL_MAX_BYTES && !residue.includes('\n')) {
412
+ residue = ''
413
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay response exceeded its protocol limit' })
414
+ return
415
+ }
416
+ while (true) {
417
+ const newline = residue.indexOf('\n')
418
+ if (newline < 0) break
419
+ const line = residue.slice(0, newline).trim()
420
+ residue = residue.slice(newline + 1)
421
+ if (line.length === 0) continue
422
+ if (Buffer.byteLength(line) > CURSOR_PROTOCOL_MAX_BYTES) {
423
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay response exceeded its protocol limit' })
424
+ continue
425
+ }
426
+ let parsed: unknown
427
+ try {
428
+ parsed = JSON.parse(line)
429
+ } catch {
430
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay returned invalid JSON' })
431
+ continue
432
+ }
433
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
434
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay returned a malformed response' })
435
+ continue
436
+ }
437
+ dispatch({ kind: 'response', response: parsed as Record<string, unknown> })
438
+ }
439
+ })
440
+ handle.stdout.once('end', () => {
441
+ dispatch({ kind: 'failure', reason: 'the native cursor overlay closed before replying' })
442
+ })
443
+ handle.stdout.once('error', error => {
444
+ dispatch({ kind: 'failure', reason: `the native cursor overlay response stream failed: ${error.message}` })
445
+ })
312
446
  return {
313
447
  stdin: handle.stdin,
314
448
  done: handle.done,
449
+ nextResponse: async () => {
450
+ const ready = buffered.shift()
451
+ if (ready !== undefined) return ready
452
+ return await new Promise<CursorProtocolFrame>((resolveResponse) => {
453
+ const settle = (frame: CursorProtocolFrame): void => {
454
+ const index = pending.indexOf(settle)
455
+ if (index >= 0) pending.splice(index, 1)
456
+ clearTimeout(timer)
457
+ resolveResponse(frame)
458
+ }
459
+ const timer = setTimeout(() => settle({
460
+ kind: 'failure',
461
+ reason: `the native cursor overlay did not respond within ${CURSOR_RESPONSE_TIMEOUT_MS} milliseconds`,
462
+ }), CURSOR_RESPONSE_TIMEOUT_MS)
463
+ pending.push(settle)
464
+ })
465
+ },
315
466
  terminate: () => {
316
467
  cursorSignal.abort()
317
468
  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