@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.
- package/README.i18n.yaml +2 -2
- package/README.md +15 -1
- package/README.zh.md +15 -1
- package/lib/index.js +6 -0
- package/lib/index.js.map +1 -1
- package/lib/providers/macos.js +37 -23
- package/lib/providers/macos.js.map +1 -1
- package/lib/providers/native-helper.js +153 -6
- package/lib/providers/native-helper.js.map +1 -1
- package/lib/providers/unsupported.js +45 -0
- package/lib/providers/unsupported.js.map +1 -0
- package/lib/service.js +101 -15
- package/lib/service.js.map +1 -1
- package/lib/skill.js +7 -1
- package/lib/skill.js.map +1 -1
- package/lib/tools.js +18 -0
- package/lib/tools.js.map +1 -1
- package/lib/types/backend.d.ts +17 -2
- package/lib/types/backend.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/providers/macos.d.ts +20 -1
- package/lib/types/providers/macos.d.ts.map +1 -1
- package/lib/types/providers/native-helper.d.ts +6 -2
- package/lib/types/providers/native-helper.d.ts.map +1 -1
- package/lib/types/providers/unsupported.d.ts +19 -0
- package/lib/types/providers/unsupported.d.ts.map +1 -0
- package/lib/types/service.d.ts +6 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/skill.d.ts +1 -1
- package/lib/types/skill.d.ts.map +1 -1
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/types.d.ts +23 -1
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/native/macos/Sources/Helper/CursorOverlay.swift +96 -21
- package/native/macos/Sources/Helper/WindowNumberMatcher.swift +29 -0
- package/native/macos/Sources/Helper/main.swift +20 -7
- package/native/macos/bin/dsh-computer-use-helper +0 -0
- package/native/macos/manifest.json +3 -3
- package/package.json +1 -1
- package/scripts/build-native.mjs +8 -1
- package/scripts/model-e2e.mjs +11 -2
- package/src/backend.ts +18 -2
- package/src/index.ts +6 -0
- package/src/providers/macos.ts +35 -19
- package/src/providers/native-helper.ts +156 -5
- package/src/providers/unsupported.ts +67 -0
- package/src/service.ts +103 -16
- package/src/skill.ts +7 -1
- package/src/tools.ts +18 -0
- package/src/types.ts +21 -1
package/src/providers/macos.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
99
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|