@anionex/dsh-computer-use 0.1.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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +307 -0
  4. package/README.zh.md +307 -0
  5. package/assets/computer-use-fixture.png +0 -0
  6. package/assets/cursor.png +0 -0
  7. package/cordis.patch.yml +13 -0
  8. package/docs/interaction-policy.i18n.yaml +4 -0
  9. package/docs/interaction-policy.md +140 -0
  10. package/docs/interaction-policy.zh.md +140 -0
  11. package/lib/approval-policy.js +12 -0
  12. package/lib/approval-policy.js.map +1 -0
  13. package/lib/artifacts.js +79 -0
  14. package/lib/artifacts.js.map +1 -0
  15. package/lib/backend.js +3 -0
  16. package/lib/backend.js.map +1 -0
  17. package/lib/client.js +349 -0
  18. package/lib/client.js.map +1 -0
  19. package/lib/config.js +120 -0
  20. package/lib/config.js.map +1 -0
  21. package/lib/confirmations.js +99 -0
  22. package/lib/confirmations.js.map +1 -0
  23. package/lib/diff.js +58 -0
  24. package/lib/diff.js.map +1 -0
  25. package/lib/errors.js +22 -0
  26. package/lib/errors.js.map +1 -0
  27. package/lib/exposure.js +231 -0
  28. package/lib/exposure.js.map +1 -0
  29. package/lib/index.js +56 -0
  30. package/lib/index.js.map +1 -0
  31. package/lib/leases.js +259 -0
  32. package/lib/leases.js.map +1 -0
  33. package/lib/providers/macos.js +148 -0
  34. package/lib/providers/macos.js.map +1 -0
  35. package/lib/providers/native-helper.js +356 -0
  36. package/lib/providers/native-helper.js.map +1 -0
  37. package/lib/service.js +480 -0
  38. package/lib/service.js.map +1 -0
  39. package/lib/skill.js +119 -0
  40. package/lib/skill.js.map +1 -0
  41. package/lib/target-resolver.js +180 -0
  42. package/lib/target-resolver.js.map +1 -0
  43. package/lib/tools.js +493 -0
  44. package/lib/tools.js.map +1 -0
  45. package/lib/types/approval-policy.d.ts +13 -0
  46. package/lib/types/approval-policy.d.ts.map +1 -0
  47. package/lib/types/artifacts.d.ts +10 -0
  48. package/lib/types/artifacts.d.ts.map +1 -0
  49. package/lib/types/backend.d.ts +94 -0
  50. package/lib/types/backend.d.ts.map +1 -0
  51. package/lib/types/client/index.d.ts +78 -0
  52. package/lib/types/client/index.d.ts.map +1 -0
  53. package/lib/types/config.d.ts +75 -0
  54. package/lib/types/config.d.ts.map +1 -0
  55. package/lib/types/confirmations.d.ts +23 -0
  56. package/lib/types/confirmations.d.ts.map +1 -0
  57. package/lib/types/diff.d.ts +5 -0
  58. package/lib/types/diff.d.ts.map +1 -0
  59. package/lib/types/errors.d.ts +15 -0
  60. package/lib/types/errors.d.ts.map +1 -0
  61. package/lib/types/exposure.d.ts +30 -0
  62. package/lib/types/exposure.d.ts.map +1 -0
  63. package/lib/types/index.d.ts +21 -0
  64. package/lib/types/index.d.ts.map +1 -0
  65. package/lib/types/leases.d.ts +80 -0
  66. package/lib/types/leases.d.ts.map +1 -0
  67. package/lib/types/providers/macos.d.ts +15 -0
  68. package/lib/types/providers/macos.d.ts.map +1 -0
  69. package/lib/types/providers/native-helper.d.ts +36 -0
  70. package/lib/types/providers/native-helper.d.ts.map +1 -0
  71. package/lib/types/service.d.ts +52 -0
  72. package/lib/types/service.d.ts.map +1 -0
  73. package/lib/types/skill.d.ts +9 -0
  74. package/lib/types/skill.d.ts.map +1 -0
  75. package/lib/types/target-resolver.d.ts +38 -0
  76. package/lib/types/target-resolver.d.ts.map +1 -0
  77. package/lib/types/tools.d.ts +6 -0
  78. package/lib/types/tools.d.ts.map +1 -0
  79. package/lib/types/types.d.ts +246 -0
  80. package/lib/types/types.d.ts.map +1 -0
  81. package/lib/types/web.d.ts +32 -0
  82. package/lib/types/web.d.ts.map +1 -0
  83. package/lib/types.js +9 -0
  84. package/lib/types.js.map +1 -0
  85. package/lib/web.js +163 -0
  86. package/lib/web.js.map +1 -0
  87. package/native/macos/Sources/Fixture/main.swift +366 -0
  88. package/native/macos/Sources/Helper/CursorImage.swift +11 -0
  89. package/native/macos/Sources/Helper/CursorOverlay.swift +488 -0
  90. package/native/macos/Sources/Helper/TargetedPointer.swift +314 -0
  91. package/native/macos/Sources/Helper/main.swift +1140 -0
  92. package/native/macos/Sources/Monitor/main.swift +139 -0
  93. package/native/macos/bin/dsh-computer-use-helper +0 -0
  94. package/native/macos/manifest.json +14 -0
  95. package/package.json +128 -0
  96. package/scripts/build-client.mjs +24 -0
  97. package/scripts/build-native.mjs +158 -0
  98. package/scripts/check-native.mjs +92 -0
  99. package/scripts/model-e2e.mjs +291 -0
  100. package/scripts/session-transcript.mjs +117 -0
  101. package/scripts/validate.mjs +256 -0
  102. package/src/approval-policy.ts +16 -0
  103. package/src/artifacts.ts +93 -0
  104. package/src/backend.ts +103 -0
  105. package/src/client/index.tsx +539 -0
  106. package/src/config.ts +196 -0
  107. package/src/confirmations.ts +128 -0
  108. package/src/diff.ts +52 -0
  109. package/src/errors.ts +39 -0
  110. package/src/exposure.ts +236 -0
  111. package/src/index.ts +66 -0
  112. package/src/leases.ts +337 -0
  113. package/src/providers/macos.ts +185 -0
  114. package/src/providers/native-helper.ts +393 -0
  115. package/src/service.ts +583 -0
  116. package/src/skill.ts +123 -0
  117. package/src/target-resolver.ts +237 -0
  118. package/src/tools.ts +528 -0
  119. package/src/types.ts +299 -0
  120. package/src/web.ts +180 -0
package/src/service.ts ADDED
@@ -0,0 +1,583 @@
1
+ /** Provider-independent Computer Use Service: leases, observations, staleness, confirmations, and fresh post-action state. */
2
+
3
+ import { randomUUID } from 'node:crypto'
4
+ import { setTimeout as delay } from 'node:timers/promises'
5
+ import { Context, Service } from '@deepseek-ai/cordis'
6
+ import type { Agent } from '@deepseek-ai/dsh-agent'
7
+ import type { BackendCursorAction, BackendObservation, ComputerUseBackend } from './backend.ts'
8
+ import { allocateScreenshotPath, describeScreenshot } from './artifacts.ts'
9
+ import type { ResolvedComputerUseConfig } from './config.ts'
10
+ import { ComputerConfirmationManager } from './confirmations.ts'
11
+ import { diffElements } from './diff.ts'
12
+ import { ComputerUseError, computerUseError } from './errors.ts'
13
+ import { ComputerLeaseManager } from './leases.ts'
14
+ import {
15
+ describeComputerTarget,
16
+ resolveComputerTarget,
17
+ type ComputerTargetDescriptor,
18
+ } from './target-resolver.ts'
19
+ import {
20
+ ComputerObservationId,
21
+ ComputerTargetHandle,
22
+ type ComputerActionRequest,
23
+ type ComputerActionResult,
24
+ type ComputerAppIdentity,
25
+ type ComputerAppSummary,
26
+ type ComputerConfirmRequest,
27
+ type ComputerConfirmation,
28
+ type ComputerElement,
29
+ type ComputerObservation,
30
+ type ComputerObserveRequest,
31
+ type ComputerTargetResolutionResult,
32
+ type ComputerUseContext,
33
+ type ComputerUseStatus,
34
+ } from './types.ts'
35
+
36
+ declare module '@deepseek-ai/cordis' {
37
+ interface Context {
38
+ computerUse: ComputerUseService
39
+ }
40
+ }
41
+
42
+ interface StoredObservation {
43
+ public: ComputerObservation
44
+ backend: BackendObservation
45
+ targets: Map<ComputerTargetHandle, ComputerTargetDescriptor>
46
+ generation: number
47
+ }
48
+
49
+ interface AgentState {
50
+ observations: Map<ComputerObservationId, StoredObservation>
51
+ latestByApp: Map<string, ComputerObservationId>
52
+ }
53
+
54
+ function publicElements(observation: BackendObservation): {
55
+ elements: ComputerElement[]
56
+ targets: Map<ComputerTargetHandle, ComputerTargetDescriptor>
57
+ } {
58
+ const targets = new Map<ComputerTargetHandle, ComputerTargetDescriptor>()
59
+ const elements = observation.elements.map((backendElement) => {
60
+ const { locator: _locator, nativeIdentifier: _nativeIdentifier, ...element } = backendElement
61
+ const targetHandle = ComputerTargetHandle(randomUUID())
62
+ targets.set(targetHandle, describeComputerTarget(backendElement, observation))
63
+ return { ...element, targetHandle }
64
+ })
65
+ return { elements, targets }
66
+ }
67
+
68
+ function matchesWait(observation: BackendObservation, action: Extract<ComputerActionRequest, { kind: 'wait' }>): boolean {
69
+ const condition = action.condition
70
+ if (condition.text !== undefined && !observation.treeText.toLocaleLowerCase().includes(condition.text.toLocaleLowerCase())) return false
71
+ if (condition.elementRole !== undefined && !observation.elements.some(element => element.role === condition.elementRole)) return false
72
+ if (condition.elementTitle !== undefined && !observation.elements.some(element => element.title === condition.elementTitle || element.label === condition.elementTitle)) return false
73
+ return condition.text !== undefined || condition.elementRole !== undefined || condition.elementTitle !== undefined
74
+ }
75
+
76
+ function targetIndex(action: ComputerActionRequest): number | undefined {
77
+ switch (action.kind) {
78
+ case 'click':
79
+ case 'scroll': return action.elementIndex
80
+ case 'set-value':
81
+ case 'perform-action': return action.elementIndex
82
+ case 'type-text':
83
+ case 'press-key':
84
+ case 'drag':
85
+ case 'wait': return undefined
86
+ }
87
+ }
88
+
89
+ function targetHandle(action: ComputerActionRequest): ComputerTargetHandle | undefined {
90
+ switch (action.kind) {
91
+ case 'click':
92
+ case 'scroll':
93
+ case 'set-value':
94
+ case 'perform-action': return action.targetHandle
95
+ case 'type-text':
96
+ case 'press-key':
97
+ case 'drag':
98
+ case 'wait': return undefined
99
+ }
100
+ }
101
+
102
+ function allowsTargetRebind(action: ComputerActionRequest): boolean {
103
+ switch (action.kind) {
104
+ case 'click':
105
+ case 'scroll':
106
+ case 'set-value':
107
+ case 'perform-action': return action.allowRebind === true
108
+ case 'type-text':
109
+ case 'press-key':
110
+ case 'drag':
111
+ case 'wait': return false
112
+ }
113
+ }
114
+
115
+ function requiresElement(action: ComputerActionRequest): boolean {
116
+ return action.kind === 'set-value' || action.kind === 'perform-action'
117
+ }
118
+
119
+ function requiresPointerInput(
120
+ action: Exclude<ComputerActionRequest, { kind: 'wait' }>,
121
+ element: BackendObservation['elements'][number] | undefined,
122
+ ): boolean {
123
+ switch (action.kind) {
124
+ case 'click':
125
+ if (action.x !== undefined || action.y !== undefined) return true
126
+ return element !== undefined
127
+ && !element.actions.includes('AXPress')
128
+ && action.allowCoordinateFallback === true
129
+ case 'scroll':
130
+ case 'drag': return true
131
+ case 'set-value':
132
+ case 'type-text':
133
+ case 'press-key':
134
+ case 'perform-action': return false
135
+ }
136
+ }
137
+
138
+ function requiresForegroundPermission(action: Exclude<ComputerActionRequest, { kind: 'wait' }>): boolean {
139
+ return action.kind === 'perform-action' && action.action === 'AXRaise'
140
+ }
141
+
142
+ function cursorAction(
143
+ action: Exclude<ComputerActionRequest, { kind: 'wait' }>,
144
+ element: BackendObservation['elements'][number] | undefined,
145
+ window: BackendObservation['window'] | undefined,
146
+ app: BackendObservation['app'],
147
+ ): BackendCursorAction | undefined {
148
+ if (window?.id === undefined) return undefined
149
+ const target = {
150
+ targetPid: app.pid,
151
+ targetWindowNumber: window.id,
152
+ targetWindowFrame: { ...window.frame },
153
+ }
154
+ const elementPoint = element?.frame === undefined
155
+ ? undefined
156
+ : {
157
+ x: element.frame.x + element.frame.width / 2,
158
+ y: element.frame.y + element.frame.height / 2,
159
+ }
160
+ const coordinateSpace = action.kind === 'click' || action.kind === 'scroll' || action.kind === 'drag'
161
+ ? action.coordinateSpace
162
+ : undefined
163
+ const windowPoint = (x: number | undefined, y: number | undefined): { x: number; y: number } | undefined => {
164
+ if (x === undefined || y === undefined || window === undefined) return undefined
165
+ return coordinateSpace === 'screen' ? { x, y } : { x: window.frame.x + x, y: window.frame.y + y }
166
+ }
167
+ switch (action.kind) {
168
+ case 'click':
169
+ case 'scroll': {
170
+ const point = elementPoint ?? windowPoint(action.x, action.y)
171
+ return point === undefined ? undefined : { kind: action.kind, to: point, ...target }
172
+ }
173
+ case 'drag': {
174
+ const from = windowPoint(action.fromX, action.fromY)
175
+ const to = windowPoint(action.toX, action.toY)
176
+ return from === undefined || to === undefined ? undefined : { kind: 'drag', from, to, ...target }
177
+ }
178
+ case 'set-value':
179
+ case 'type-text':
180
+ case 'press-key':
181
+ case 'perform-action': return undefined
182
+ }
183
+ }
184
+
185
+ /** Complete Service Definition plus provider-independent implementation. */
186
+ export class ComputerUseService extends Service {
187
+ private backend: ComputerUseBackend
188
+ private config: ResolvedComputerUseConfig
189
+ private generation = 1
190
+ private readonly agents = new Map<Agent, AgentState>()
191
+ private readonly leases: ComputerLeaseManager
192
+ private readonly confirmations: ComputerConfirmationManager
193
+ private readonly lifecycle = new AbortController()
194
+ private healthState: Omit<ComputerUseStatus, 'platform' | 'provider' | 'generation' | 'helperPath'> = {
195
+ ready: false,
196
+ accessibility: 'unavailable',
197
+ screenRecording: 'unavailable',
198
+ }
199
+
200
+ /** Register `ctx.computerUse` using one validated backend and configuration generation. */
201
+ constructor(ctx: Context, backend: ComputerUseBackend, config: ResolvedComputerUseConfig) {
202
+ super(ctx, 'computerUse')
203
+ this.backend = backend
204
+ this.config = config
205
+ this.leases = new ComputerLeaseManager(ctx, () => this.config)
206
+ this.confirmations = new ComputerConfirmationManager(ctx, () => this.config)
207
+ ctx.effect(() => async () => {
208
+ this.lifecycle.abort()
209
+ this.clearState()
210
+ await this.backend.dispose()
211
+ }, 'dsh-computer-use: service lifecycle')
212
+ }
213
+
214
+ /** Verify the active backend before consumers become injectable. */
215
+ protected async initialize(): Promise<void> {
216
+ try {
217
+ await this.leases.initialize()
218
+ const health = await this.backend.health(this.lifecycle.signal)
219
+ this.healthState = { ready: true, ...health }
220
+ } catch (error) {
221
+ const failure = computerUseError(error, 'Computer Use provider initialization failed')
222
+ this.healthState = {
223
+ ready: false,
224
+ accessibility: 'unavailable',
225
+ screenRecording: 'unavailable',
226
+ lastError: failure.message,
227
+ }
228
+ throw failure
229
+ }
230
+ }
231
+
232
+ /** Replace the backend/config generation after a validated live Settings update. */
233
+ protected async reconfigure(backend: ComputerUseBackend, config: ResolvedComputerUseConfig): Promise<void> {
234
+ const health = await backend.health(this.lifecycle.signal)
235
+ const previous = this.backend
236
+ this.backend = backend
237
+ this.config = config
238
+ this.generation += 1
239
+ this.clearState()
240
+ this.healthState = { ready: true, ...health }
241
+ await previous.dispose()
242
+ }
243
+
244
+ /** Current provider and permission diagnostics. */
245
+ status(): ComputerUseStatus {
246
+ return {
247
+ platform: process.platform,
248
+ provider: 'macos-ax',
249
+ generation: this.generation,
250
+ helperPath: this.backend.helperPath,
251
+ ...this.healthState,
252
+ }
253
+ }
254
+
255
+ /** Re-run non-mutating provider health checks. */
256
+ async health(signal: AbortSignal): Promise<ComputerUseStatus> {
257
+ try {
258
+ const health = await this.backend.health(AbortSignal.any([signal, this.lifecycle.signal]))
259
+ this.healthState = { ready: true, ...health }
260
+ } catch (error) {
261
+ const failure = computerUseError(error, 'Computer Use health check failed')
262
+ this.healthState = { ...this.healthState, ready: false, lastError: failure.message }
263
+ throw failure
264
+ }
265
+ return this.status()
266
+ }
267
+
268
+ /** Open the exact macOS privacy pane after an explicit Settings-page action. */
269
+ async openPermissionSettings(kind: 'accessibility' | 'screen-recording', signal: AbortSignal): Promise<void> {
270
+ await this.backend.openSettings(kind, AbortSignal.any([signal, this.lifecycle.signal]))
271
+ }
272
+
273
+ /** List bounded running applications without inspecting their UI contents. */
274
+ async listApps(context: ComputerUseContext): Promise<ComputerAppSummary[]> {
275
+ return await this.backend.listApps(AbortSignal.any([context.signal, this.lifecycle.signal]))
276
+ }
277
+
278
+ /** Obtain a fresh, scoped observation after enforcing the app read lease. */
279
+ async observe(request: ComputerObserveRequest, context: ComputerUseContext): Promise<ComputerObservation> {
280
+ const signal = AbortSignal.any([context.signal, this.lifecycle.signal])
281
+ const app = await this.backend.resolveApp(request.app, signal)
282
+ await this.leases.ensure(context.agent, app, 'read', 'computer_observe', context.callId, signal)
283
+ return await this.capture(app, request, context, 'computer_observe')
284
+ }
285
+
286
+ /** Ask for a one-use token bound to an exact proposed sensitive action. */
287
+ async confirm(request: ComputerConfirmRequest, context: ComputerUseContext): Promise<ComputerConfirmation> {
288
+ const stored = this.requireObservation(request.action.observationId, context.agent)
289
+ return await this.confirmations.confirm(
290
+ context.agent,
291
+ stored.backend.app,
292
+ request,
293
+ context.callId,
294
+ AbortSignal.any([context.signal, this.lifecycle.signal]),
295
+ )
296
+ }
297
+
298
+ /** Execute one observation-bound action and always return a fresh post-action observation. */
299
+ async act(action: ComputerActionRequest, context: ComputerUseContext): Promise<ComputerActionResult> {
300
+ const signal = AbortSignal.any([context.signal, this.lifecycle.signal])
301
+ const stored = this.requireObservation(action.observationId, context.agent)
302
+ if (action.kind === 'wait') return await this.wait(stored, action, context, signal)
303
+ const index = targetIndex(action)
304
+ const handle = targetHandle(action)
305
+ const originalElement = index === undefined ? undefined : stored.backend.elements.find(candidate => candidate.index === index)
306
+ if (index !== undefined && originalElement === undefined) {
307
+ throw new ComputerUseError('COMPUTER_ELEMENT_UNAVAILABLE', `element ${index} is not part of observation ${String(action.observationId)}`)
308
+ }
309
+ if (allowsTargetRebind(action) && handle === undefined) {
310
+ throw new ComputerUseError('COMPUTER_TARGET_UNAVAILABLE', 'allowRebind requires a targetHandle from the referenced observation')
311
+ }
312
+ const descriptor = handle === undefined ? undefined : stored.targets.get(handle)
313
+ if (handle !== undefined && descriptor === undefined) {
314
+ throw new ComputerUseError('COMPUTER_TARGET_UNAVAILABLE', 'targetHandle is unknown or does not belong to the referenced observation')
315
+ }
316
+ if (descriptor !== undefined && index !== undefined && (descriptor.locator.length !== originalElement?.locator.length
317
+ || !descriptor.locator.every((part, position) => part === originalElement.locator[position]))) {
318
+ throw new ComputerUseError('COMPUTER_TARGET_UNAVAILABLE', 'elementIndex and targetHandle select different elements')
319
+ }
320
+ const selectedOriginalElement = originalElement ?? (descriptor === undefined
321
+ ? undefined
322
+ : stored.backend.elements.find(candidate => candidate.locator.length === descriptor.locator.length
323
+ && candidate.locator.every((part, position) => part === descriptor.locator[position])))
324
+ if (descriptor !== undefined && selectedOriginalElement === undefined) {
325
+ throw new ComputerUseError('COMPUTER_TARGET_UNAVAILABLE', 'targetHandle no longer has provider evidence in the referenced observation')
326
+ }
327
+ if (requiresElement(action) && selectedOriginalElement === undefined) {
328
+ throw new ComputerUseError('COMPUTER_ELEMENT_UNAVAILABLE', `${action.kind} requires elementIndex or targetHandle`)
329
+ }
330
+ if (requiresPointerInput(action, selectedOriginalElement) && this.config.interaction.pointerInputPolicy === 'deny') {
331
+ throw new ComputerUseError(
332
+ 'COMPUTER_ACTION_BLOCKED',
333
+ `${action.kind} requires target-process pointer input, which interaction.pointerInputPolicy denies; use an Accessibility action or enable targeted pointer input in host Settings`,
334
+ )
335
+ }
336
+ if (requiresForegroundPermission(action) && this.config.interaction.focusPolicy === 'preserve') {
337
+ throw new ComputerUseError(
338
+ 'COMPUTER_ACTION_BLOCKED',
339
+ 'AXRaise may raise the target window, which interaction.focusPolicy preserve denies; enable explicit activation in host Settings before using this action',
340
+ )
341
+ }
342
+ await this.leases.ensure(context.agent, stored.backend.app, 'control', `computer_${action.kind}`, context.callId, signal)
343
+ let actionObservation = stored.backend
344
+ let element = selectedOriginalElement
345
+ let resolution: ComputerTargetResolutionResult | undefined = selectedOriginalElement === undefined
346
+ ? undefined
347
+ : { mode: 'exact-locator', confidence: 1, candidateCount: 1, targetChanged: false }
348
+ if (descriptor !== undefined) {
349
+ const fresh = await this.backend.observe(stored.backend.app, {
350
+ screenshot: 'none',
351
+ maxNodes: this.config.maxNodes,
352
+ maxDepth: this.config.maxDepth,
353
+ maxTextBytes: this.config.maxTextBytes,
354
+ }, signal)
355
+ const resolved = resolveComputerTarget(stored.backend, fresh, descriptor, allowsTargetRebind(action))
356
+ actionObservation = resolved.observation
357
+ element = resolved.element
358
+ resolution = resolved.resolution
359
+ if (action.sensitive === true && resolution.targetChanged) {
360
+ this.confirmations.invalidate(context.agent, action.confirmationToken)
361
+ throw new ComputerUseError(
362
+ 'COMPUTER_TARGET_REBIND_REQUIRES_CONFIRMATION',
363
+ 'the sensitive target rebound to a fresh element; observe the current UI and request a new one-use confirmation before acting',
364
+ )
365
+ }
366
+ }
367
+ this.confirmations.consume(context.agent, stored.backend.app, action)
368
+ const visualization = cursorAction(action, element, actionObservation.window, actionObservation.app)
369
+ let cursorStarted = false
370
+ if (visualization !== undefined && this.config.interaction.cursorVisualization === 'visible') {
371
+ try {
372
+ await this.backend.visualizeCursor(visualization, 'before', signal)
373
+ cursorStarted = true
374
+ } catch {
375
+ // The overlay is presentation-only; native input remains authoritative.
376
+ }
377
+ }
378
+ let outcome
379
+ try {
380
+ outcome = await this.backend.act({
381
+ action,
382
+ app: actionObservation.app,
383
+ expectedStateHash: actionObservation.stateHash,
384
+ interaction: this.config.interaction,
385
+ ...(element === undefined ? {} : { element }),
386
+ ...(actionObservation.window === undefined ? {} : { window: actionObservation.window }),
387
+ }, signal)
388
+ } catch (error) {
389
+ throw computerUseError(error, `Computer Use ${action.kind} failed`)
390
+ } finally {
391
+ if (cursorStarted && visualization !== undefined) {
392
+ try {
393
+ await this.backend.visualizeCursor(visualization, 'after', signal)
394
+ } catch {
395
+ // The overlay is presentation-only; native input remains authoritative.
396
+ }
397
+ }
398
+ }
399
+ const started = Date.now()
400
+ let latest: BackendObservation | undefined
401
+ do {
402
+ if (this.config.settleMs > 0) await delay(this.config.settleMs, undefined, { signal })
403
+ latest = await this.backend.observe(stored.backend.app, {
404
+ screenshot: 'none',
405
+ maxNodes: this.config.maxNodes,
406
+ maxDepth: this.config.maxDepth,
407
+ maxTextBytes: this.config.maxTextBytes,
408
+ }, signal)
409
+ if (latest.stateHash !== actionObservation.stateHash) break
410
+ } while (Date.now() - started < this.config.maxSettleMs)
411
+ const observation = await this.capture(
412
+ stored.backend.app,
413
+ { app: { bundleId: stored.backend.app.bundleId, pid: stored.backend.app.pid }, screenshot: stored.public.screenshot === undefined ? 'none' : 'optional' },
414
+ context,
415
+ 'computer_action',
416
+ latest,
417
+ )
418
+ return {
419
+ action: action.kind,
420
+ channel: outcome.channel,
421
+ activation: outcome.activation,
422
+ pointerInput: outcome.pointerInput,
423
+ pointerRouting: outcome.pointerRouting,
424
+ ...(resolution === undefined ? {} : { resolution }),
425
+ observation,
426
+ }
427
+ }
428
+
429
+ /** Release all scoped observations and confirmations for one disposed Agent. */
430
+ releaseAgent(agent: Agent): void {
431
+ this.agents.delete(agent)
432
+ this.leases.releaseAgent(agent)
433
+ this.confirmations.releaseAgent(agent)
434
+ }
435
+
436
+ private state(agent: Agent): AgentState {
437
+ let state = this.agents.get(agent)
438
+ if (state === undefined) {
439
+ state = { observations: new Map(), latestByApp: new Map() }
440
+ this.agents.set(agent, state)
441
+ }
442
+ return state
443
+ }
444
+
445
+ private requireObservation(id: ComputerObservationId, agent: Agent): StoredObservation {
446
+ this.prune(agent)
447
+ const stored = this.state(agent).observations.get(id)
448
+ if (stored === undefined || stored.generation !== this.generation) {
449
+ throw new ComputerUseError('COMPUTER_STALE_OBSERVATION', `observation ${String(id)} is unknown, expired, or belongs to another provider generation`)
450
+ }
451
+ return stored
452
+ }
453
+
454
+ private prune(agent: Agent): void {
455
+ const state = this.agents.get(agent)
456
+ if (state === undefined) return
457
+ const now = Date.now()
458
+ for (const [id, stored] of state.observations) {
459
+ if (Date.parse(stored.public.expiresAt) <= now || stored.generation !== this.generation) state.observations.delete(id)
460
+ }
461
+ for (const [app, id] of state.latestByApp) {
462
+ if (!state.observations.has(id)) state.latestByApp.delete(app)
463
+ }
464
+ }
465
+
466
+ private async capture(
467
+ app: ComputerAppIdentity,
468
+ request: ComputerObserveRequest,
469
+ context: ComputerUseContext,
470
+ sourceTool: 'computer_observe' | 'computer_action',
471
+ preObserved?: BackendObservation,
472
+ ): Promise<ComputerObservation> {
473
+ const signal = AbortSignal.any([context.signal, this.lifecycle.signal])
474
+ const screenshot = request.screenshot ?? 'optional'
475
+ const screenshotPath = screenshot === 'none'
476
+ ? undefined
477
+ : await allocateScreenshotPath(context.workspace, this.config.artifactRoot, context.agent.session.id)
478
+ const backend = preObserved !== undefined && screenshot === 'none'
479
+ ? preObserved
480
+ : await this.backend.observe(app, {
481
+ screenshot,
482
+ ...(screenshotPath === undefined ? {} : { screenshotPath }),
483
+ maxNodes: this.config.maxNodes,
484
+ maxDepth: this.config.maxDepth,
485
+ maxTextBytes: this.config.maxTextBytes,
486
+ }, signal)
487
+ if (backend.app.bundleId !== app.bundleId || backend.app.pid !== app.pid) {
488
+ throw new ComputerUseError('COMPUTER_STALE_OBSERVATION', 'the selected application restarted or resolved to a different process')
489
+ }
490
+ const state = this.state(context.agent)
491
+ this.prune(context.agent)
492
+ const key = `${app.bundleId}:${app.pid}`
493
+ const previousId = state.latestByApp.get(key)
494
+ const previous = previousId === undefined ? undefined : state.observations.get(previousId)
495
+ const projected = publicElements(backend)
496
+ const elements = projected.elements
497
+ const full = request.full === true || previous === undefined
498
+ const createdAt = Date.now()
499
+ const observationId = ComputerObservationId(randomUUID())
500
+ const artifact = backend.screenshot === undefined
501
+ ? undefined
502
+ : await describeScreenshot(
503
+ backend.screenshot.path,
504
+ backend.screenshot.width,
505
+ backend.screenshot.height,
506
+ this.config.maxScreenshotBytes,
507
+ sourceTool,
508
+ )
509
+ const observation: ComputerObservation = {
510
+ observationId,
511
+ app: backend.app,
512
+ createdAt: new Date(createdAt).toISOString(),
513
+ expiresAt: this.config.observationTtlMs === 0
514
+ ? '9999-12-31T23:59:59.999Z'
515
+ : new Date(createdAt + this.config.observationTtlMs).toISOString(),
516
+ frontmost: backend.frontmost,
517
+ ...(backend.window === undefined ? {} : { window: backend.window }),
518
+ tree: {
519
+ mode: full ? 'full' : 'diff',
520
+ text: full ? backend.treeText : diffElements(previous.public.elements, elements, this.config.maxTextBytes),
521
+ truncated: backend.truncated,
522
+ },
523
+ elements,
524
+ ...(artifact === undefined ? {} : { screenshot: artifact }),
525
+ permissions: backend.permissions,
526
+ }
527
+ state.observations.set(observationId, { public: observation, backend, targets: projected.targets, generation: this.generation })
528
+ state.latestByApp.set(key, observationId)
529
+ while (state.observations.size > 64) {
530
+ const oldest = state.observations.keys().next().value as ComputerObservationId | undefined
531
+ if (oldest === undefined) break
532
+ state.observations.delete(oldest)
533
+ }
534
+ return observation
535
+ }
536
+
537
+ private async wait(
538
+ stored: StoredObservation,
539
+ action: Extract<ComputerActionRequest, { kind: 'wait' }>,
540
+ context: ComputerUseContext,
541
+ signal: AbortSignal,
542
+ ): Promise<ComputerActionResult> {
543
+ await this.leases.ensure(context.agent, stored.backend.app, 'read', 'computer_wait', context.callId, signal)
544
+ const timeoutMs = action.timeoutMs ?? this.config.maxSettleMs
545
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > this.config.maxSettleMs) {
546
+ throw new ComputerUseError('COMPUTER_TIMEOUT', `wait timeout must be between 100 and ${this.config.maxSettleMs} milliseconds`)
547
+ }
548
+ const deadline = Date.now() + timeoutMs
549
+ let latest = stored.backend
550
+ while (!matchesWait(latest, action)) {
551
+ if (Date.now() >= deadline) throw new ComputerUseError('COMPUTER_TIMEOUT', 'wait condition was not met before the configured deadline')
552
+ await delay(Math.min(this.config.settleMs || 100, Math.max(1, deadline - Date.now())), undefined, { signal })
553
+ latest = await this.backend.observe(stored.backend.app, {
554
+ screenshot: 'none',
555
+ maxNodes: this.config.maxNodes,
556
+ maxDepth: this.config.maxDepth,
557
+ maxTextBytes: this.config.maxTextBytes,
558
+ }, signal)
559
+ }
560
+ const observation = await this.capture(
561
+ stored.backend.app,
562
+ { app: { bundleId: stored.backend.app.bundleId, pid: stored.backend.app.pid }, screenshot: stored.public.screenshot === undefined ? 'none' : 'optional' },
563
+ context,
564
+ 'computer_action',
565
+ latest,
566
+ )
567
+ return {
568
+ action: 'wait',
569
+ channel: 'wait',
570
+ activation: 'not-requested',
571
+ pointerInput: false,
572
+ pointerRouting: 'none',
573
+ observation,
574
+ }
575
+ }
576
+
577
+ private clearState(): void {
578
+ this.agents.clear()
579
+ this.confirmations.clear()
580
+ }
581
+ }
582
+
583
+ export default ComputerUseService