@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/index.ts ADDED
@@ -0,0 +1,66 @@
1
+ /** DSH Computer Use model-facing consumer and progressive Skill bundle. */
2
+
3
+ import { Service, type Context } from '@deepseek-ai/cordis'
4
+ import type {} from '@deepseek-ai/dsh-agent'
5
+ import type {} from '@deepseek-ai/dsh-settings'
6
+ import type {} from '@deepseek-ai/dsh-skill'
7
+ import type {} from '@deepseek-ai/dsh-tools'
8
+ import { Config, type ComputerUseConfig } from './config.ts'
9
+ import { ComputerUseExposure } from './exposure.ts'
10
+ import { MacOSComputerUseProvider } from './providers/macos.ts'
11
+ import { COMPUTER_USE_SKILL } from './skill.ts'
12
+ import { createComputerUseTools } from './tools.ts'
13
+ import { installComputerUseWeb } from './web.ts'
14
+
15
+ export { Config } from './config.ts'
16
+ export * from './errors.ts'
17
+ export * from './service.ts'
18
+ export * from './types.ts'
19
+
20
+ /** Register the portable Skill, bootstrap, Agent-scoped Tools, and optional Web diagnostics. */
21
+ export function installComputerUseConsumer(ctx: Context): () => void {
22
+ const exposure = new ComputerUseExposure(ctx, () => createComputerUseTools(ctx.computerUse))
23
+ let activation: (() => void) | undefined
24
+ let skill: (() => void) | undefined
25
+ let exposureDispose: (() => void) | undefined
26
+ try {
27
+ activation = ctx.tools.register(exposure.activationTool)
28
+ skill = ctx.skills.register(COMPUTER_USE_SKILL)
29
+ exposureDispose = exposure.install()
30
+ installComputerUseWeb(ctx)
31
+ } catch (error) {
32
+ exposureDispose?.()
33
+ skill?.()
34
+ activation?.()
35
+ throw error
36
+ }
37
+ return () => {
38
+ exposureDispose?.()
39
+ activation?.()
40
+ skill?.()
41
+ }
42
+ }
43
+
44
+ /** macOS provider plus the portable Skill, scoped Tools, and optional Web diagnostics. */
45
+ export class ComputerUseBundle extends MacOSComputerUseProvider {
46
+ static override inject = ['subprocess', 'approval', 'settings', 'sessions', 'agents', 'tools', 'skills']
47
+ static override Config = Config
48
+
49
+ private consumerDispose: (() => void) | undefined
50
+
51
+ constructor(ctx: Context, config: ComputerUseConfig = {}) {
52
+ super(ctx, config)
53
+ ctx.effect(() => () => {
54
+ this.consumerDispose?.()
55
+ this.consumerDispose = undefined
56
+ }, 'dsh-computer-use: consumer lifecycle')
57
+ }
58
+
59
+ /** Publish model-facing capabilities only after provider integrity and health pass. */
60
+ protected override async [Service.init](): Promise<void> {
61
+ await super[Service.init]()
62
+ this.consumerDispose = installComputerUseConsumer(this.ctx)
63
+ }
64
+ }
65
+
66
+ export default ComputerUseBundle
package/src/leases.ts ADDED
@@ -0,0 +1,337 @@
1
+ /** Session-sidecar read leases, durable denials, and per-turn control leases. */
2
+
3
+ import { z } from 'zod'
4
+ import type { Agent } from '@deepseek-ai/dsh-agent'
5
+ import type { CallId } from '@deepseek-ai/dsh-llm'
6
+ import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
7
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
8
+ import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
9
+ import type { Context, Fiber } from '@deepseek-ai/cordis'
10
+ import { approvalPolicy } from './approval-policy.ts'
11
+ import type { ResolvedComputerUseConfig } from './config.ts'
12
+ import { ComputerUseError } from './errors.ts'
13
+ import type { ComputerAppIdentity } from './types.ts'
14
+
15
+ const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
16
+ const leaseScopeSchema = z.union([z.literal('read'), z.literal('control')])
17
+
18
+ const sessionIdentitySchema = z.object({
19
+ createdAt: nonNegativeSafeInteger,
20
+ cwd: z.string().optional(),
21
+ })
22
+
23
+ /** Session fields that fence one sidecar row to one exact Session lifecycle. */
24
+ export type ComputerUseSessionIdentity = z.infer<typeof sessionIdentitySchema>
25
+
26
+ const deniedLeaseSchema = z.object({
27
+ bundleId: z.string().min(1),
28
+ scope: leaseScopeSchema,
29
+ })
30
+
31
+ /** One application/scope rejection that remains final for the Session lifecycle. */
32
+ export type ComputerUseDeniedLease = z.infer<typeof deniedLeaseSchema>
33
+
34
+ /** Runtime validation for the whole-Session Computer Use sidecar row. */
35
+ export const computerUseSessionStateSchema = z.object({
36
+ session: sessionIdentitySchema,
37
+ readGrants: z.array(z.string().min(1)),
38
+ denied: z.array(deniedLeaseSchema),
39
+ }).superRefine((row, ctx) => {
40
+ const readGrants = new Set<string>()
41
+ row.readGrants.forEach((bundleId, index) => {
42
+ if (readGrants.has(bundleId)) {
43
+ ctx.addIssue({
44
+ code: 'custom',
45
+ path: ['readGrants', index],
46
+ message: `duplicate Computer Use read grant '${bundleId}'`,
47
+ })
48
+ }
49
+ readGrants.add(bundleId)
50
+ })
51
+
52
+ const denials = new Set<string>()
53
+ row.denied.forEach((denial, index) => {
54
+ const key = `${denial.scope}\0${denial.bundleId}`
55
+ if (denials.has(key)) {
56
+ ctx.addIssue({
57
+ code: 'custom',
58
+ path: ['denied', index],
59
+ message: `duplicate Computer Use ${denial.scope} denial '${denial.bundleId}'`,
60
+ })
61
+ }
62
+ denials.add(key)
63
+ })
64
+ })
65
+
66
+ /** Plugin-owned durable authorization state for one Session lifecycle. */
67
+ export type ComputerUseSessionState = z.infer<typeof computerUseSessionStateSchema>
68
+
69
+ /** One lifecycle-bound Computer Use sidecar record per Session id. */
70
+ export const computerUseStateDomainSpec = defineDomain({
71
+ name: 'computer_use_state',
72
+ version: 0,
73
+ tables: {
74
+ sessions: domainTable<SessionId, ComputerUseSessionState>(computerUseSessionStateSchema),
75
+ },
76
+ })
77
+
78
+ interface StorageBinding {
79
+ table: KvTable<SessionId, ComputerUseSessionState>
80
+ }
81
+
82
+ function currentTurn(events: readonly SessionEvent[]): number | undefined {
83
+ for (let index = events.length - 1; index >= 0; index -= 1) {
84
+ const event = events[index]
85
+ if (event?.type === 'turn/end') return undefined
86
+ if (event?.type === 'turn/start') return event.data.turn
87
+ }
88
+ return undefined
89
+ }
90
+
91
+ function identityOf(header: SessionHeader): ComputerUseSessionIdentity {
92
+ return Object.freeze({
93
+ createdAt: header.createdAt,
94
+ ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
95
+ })
96
+ }
97
+
98
+ function sameIdentity(row: ComputerUseSessionState, header: SessionHeader): boolean {
99
+ return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd
100
+ }
101
+
102
+ function stateSnapshot(
103
+ header: SessionHeader,
104
+ readGrants: Iterable<string>,
105
+ denied: Iterable<ComputerUseDeniedLease>,
106
+ ): ComputerUseSessionState {
107
+ const readGrantSnapshot = Object.freeze([...readGrants]) as unknown as string[]
108
+ const deniedSnapshot = Object.freeze([...denied].map(item => Object.freeze({ ...item }))) as unknown as ComputerUseDeniedLease[]
109
+ return Object.freeze({
110
+ session: identityOf(header),
111
+ readGrants: readGrantSnapshot,
112
+ denied: deniedSnapshot,
113
+ })
114
+ }
115
+
116
+ function configuredAccess(config: ResolvedComputerUseConfig, bundleId: string, scope: 'read' | 'control'): boolean {
117
+ if (config.allowAllApps) return true
118
+ return config.grants.find(grant => grant.bundleId === bundleId)?.[scope] === true
119
+ }
120
+
121
+ function boundedFailure(error: unknown): string {
122
+ return (error instanceof Error ? error.message : String(error)).slice(0, 500)
123
+ }
124
+
125
+ /** Source of the technical application lease used by an operation. */
126
+ export type ComputerLeaseSource = 'configured' | 'approved'
127
+
128
+ /** Applies configured app policy and routes missing leases through DSH approval. */
129
+ export class ComputerLeaseManager {
130
+ private storage: StorageBinding | undefined
131
+ private readonly storageFiber: Fiber & PromiseLike<Fiber>
132
+ private readonly decisionTails = new Map<SessionId, Promise<void>>()
133
+ private readonly mutationTails = new Map<SessionId, Promise<void>>()
134
+ private readonly controlGrants = new WeakMap<Agent, Map<string, number>>()
135
+
136
+ constructor(
137
+ private readonly ctx: Context,
138
+ private readonly config: () => ResolvedComputerUseConfig,
139
+ ) {
140
+ this.storageFiber = ctx.inject(['storageDomain'], async (storageCtx: Context) => {
141
+ const domain = await storageCtx.storageDomain.open(computerUseStateDomainSpec)
142
+ const binding: StorageBinding = { table: domain.table('sessions') }
143
+ this.storage = binding
144
+ return async () => {
145
+ if (this.storage === binding) this.storage = undefined
146
+ await Promise.all(this.mutationTails.values())
147
+ await domain.close()
148
+ }
149
+ })
150
+ ctx.effect(() => () => this.storageFiber.dispose(), 'dsh-computer-use: optional lease sidecar')
151
+ }
152
+
153
+ /** Wait for an already-composed storage-domain service to finish opening. */
154
+ async initialize(): Promise<void> {
155
+ await this.prepareStorage()
156
+ }
157
+
158
+ /** Ensure one Agent may read or control one exact running application. */
159
+ async ensure(
160
+ agent: Agent,
161
+ app: ComputerAppIdentity,
162
+ scope: 'read' | 'control',
163
+ toolName: string,
164
+ callId: CallId | undefined,
165
+ signal: AbortSignal,
166
+ ): Promise<ComputerLeaseSource> {
167
+ if (configuredAccess(this.config(), app.bundleId, scope)) return 'configured'
168
+ return await this.enqueueDecision(agent.session.id, async () => {
169
+ if (configuredAccess(this.config(), app.bundleId, scope)) return 'configured'
170
+ return await this.ensureInteractive(agent, app, scope, toolName, callId, signal)
171
+ })
172
+ }
173
+
174
+ /** Forget process-local control grants when their Agent is disposed. */
175
+ releaseAgent(agent: Agent): void {
176
+ this.controlGrants.delete(agent)
177
+ }
178
+
179
+ private async ensureInteractive(
180
+ agent: Agent,
181
+ app: ComputerAppIdentity,
182
+ scope: 'read' | 'control',
183
+ toolName: string,
184
+ callId: CallId | undefined,
185
+ signal: AbortSignal,
186
+ ): Promise<ComputerLeaseSource> {
187
+ await this.prepareStorage()
188
+ const turn = currentTurn(agent.session.events)
189
+ if (turn === undefined) {
190
+ throw new ComputerUseError('COMPUTER_PERMISSION_REQUIRED', `${scope} access for ${app.name} must be requested inside an open Agent turn`)
191
+ }
192
+
193
+ if (scope === 'control' && this.controlGrants.get(agent)?.get(app.bundleId) === turn) {
194
+ return 'approved'
195
+ }
196
+
197
+ const stored = this.currentState(agent)
198
+ if (scope === 'read' && stored?.readGrants.includes(app.bundleId) === true) {
199
+ return 'approved'
200
+ }
201
+ if (stored?.denied.some(denial => denial.bundleId === app.bundleId && denial.scope === scope) === true) {
202
+ throw new ComputerUseError(
203
+ 'COMPUTER_PERMISSION_REQUIRED',
204
+ `${scope} access for ${app.name} was rejected earlier in this Session; do not retry without new user instructions`,
205
+ )
206
+ }
207
+
208
+ if (approvalPolicy(this.ctx, agent) === 'never') {
209
+ throw new ComputerUseError(
210
+ 'COMPUTER_PERMISSION_REQUIRED',
211
+ `${scope} access for ${app.name} is blocked because approval prompts are disabled in this Session (approval/policy: never, e.g. the danger-full-access preset); add "${app.bundleId}" to the computer-use grants in Settings, or switch the permission preset to one with approval ask`,
212
+ )
213
+ }
214
+
215
+ if (scope === 'read' && this.storage === undefined) {
216
+ throw this.storageRequired(app, scope, 'a Session-wide interactive read grant')
217
+ }
218
+
219
+ const outcome = await this.ctx.approval.request({
220
+ agent,
221
+ toolName,
222
+ ...(callId === undefined ? {} : { callId }),
223
+ reason: scope === 'read'
224
+ ? `Allow this Agent to inspect the Accessibility state and requested screenshot of ${app.name} (${app.bundleId}) for this Session.`
225
+ : `Allow this Agent to send UI input to ${app.name} (${app.bundleId}) for the current turn.`,
226
+ signal,
227
+ })
228
+ if (outcome === 'cancelled') {
229
+ throw new ComputerUseError('COMPUTER_CANCELLED', `${scope} access request for ${app.name} was cancelled`)
230
+ }
231
+ if (outcome === 'rejected') {
232
+ if (this.storage === undefined) {
233
+ throw this.storageRequired(app, scope, 'the rejected interactive decision')
234
+ }
235
+ await this.persist(agent, app, { kind: 'denied', scope })
236
+ throw new ComputerUseError('COMPUTER_PERMISSION_REQUIRED', `${scope} access for ${app.name} was not granted (rejected); do not retry in this Session without new user instructions`)
237
+ }
238
+ if (outcome !== 'allowed-once') {
239
+ throw new ComputerUseError('COMPUTER_PERMISSION_REQUIRED', `${scope} access for ${app.name} was not granted (${outcome})`)
240
+ }
241
+
242
+ if (scope === 'control') {
243
+ let grants = this.controlGrants.get(agent)
244
+ if (grants === undefined) {
245
+ grants = new Map()
246
+ this.controlGrants.set(agent, grants)
247
+ }
248
+ grants.set(app.bundleId, turn)
249
+ return 'approved'
250
+ }
251
+
252
+ await this.persist(agent, app, { kind: 'read-granted' })
253
+ return 'approved'
254
+ }
255
+
256
+ private currentState(agent: Agent): ComputerUseSessionState | undefined {
257
+ const row = this.storage?.table.get(agent.session.id)
258
+ return row !== undefined && sameIdentity(row, agent.session.header) ? row : undefined
259
+ }
260
+
261
+ private async prepareStorage(): Promise<StorageBinding | undefined> {
262
+ if (this.storage !== undefined) return this.storage
263
+ if (this.ctx.get('storageDomain') === undefined) return undefined
264
+ await this.storageFiber
265
+ return this.storage
266
+ }
267
+
268
+ private async persist(
269
+ agent: Agent,
270
+ app: ComputerAppIdentity,
271
+ decision: { kind: 'read-granted' } | { kind: 'denied'; scope: 'read' | 'control' },
272
+ ): Promise<void> {
273
+ const purpose = decision.kind === 'read-granted'
274
+ ? 'the Session-wide read grant'
275
+ : `the Session-wide ${decision.scope} denial`
276
+ try {
277
+ const participated = await this.ctx.sessions.flush(agent.session)
278
+ if (!participated) {
279
+ throw new Error('no Session persistence listener participated in ctx.sessions.flush')
280
+ }
281
+ await this.enqueueMutation(agent.session.id, async () => {
282
+ const binding = this.storage
283
+ if (binding === undefined) throw this.storageRequired(app, decision.kind === 'read-granted' ? 'read' : decision.scope, purpose)
284
+ const current = binding.table.get(agent.session.id)
285
+ const row = current !== undefined && sameIdentity(current, agent.session.header)
286
+ ? current
287
+ : stateSnapshot(agent.session.header, [], [])
288
+ const readGrants = new Set(row.readGrants)
289
+ const denied = [...row.denied]
290
+ if (decision.kind === 'read-granted') {
291
+ readGrants.add(app.bundleId)
292
+ } else if (!denied.some(item => item.bundleId === app.bundleId && item.scope === decision.scope)) {
293
+ denied.push({ bundleId: app.bundleId, scope: decision.scope })
294
+ }
295
+ await binding.table.put(agent.session.id, stateSnapshot(agent.session.header, readGrants, denied))
296
+ })
297
+ } catch (error) {
298
+ if (error instanceof ComputerUseError) throw error
299
+ throw new ComputerUseError(
300
+ 'COMPUTER_PERMISSION_REQUIRED',
301
+ `${purpose} for ${app.name} could not be persisted after the approval audit: ${boundedFailure(error)}; configure working Session persistence and @deepseek-ai/dsh-storage-domain before retrying`,
302
+ { cause: error },
303
+ )
304
+ }
305
+ }
306
+
307
+ private storageRequired(
308
+ app: ComputerAppIdentity,
309
+ scope: 'read' | 'control',
310
+ purpose: string,
311
+ ): ComputerUseError {
312
+ return new ComputerUseError(
313
+ 'COMPUTER_PERMISSION_REQUIRED',
314
+ `${scope} access for ${app.name} requires ctx.storageDomain to persist ${purpose}; compose @deepseek-ai/dsh-storage-domain or add an exact static grant for "${app.bundleId}" before retrying`,
315
+ )
316
+ }
317
+
318
+ private enqueueDecision<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
319
+ const previous = this.decisionTails.get(sessionId) ?? Promise.resolve()
320
+ const result = previous.then(operation)
321
+ const tail = result.then(() => undefined, () => undefined)
322
+ this.decisionTails.set(sessionId, tail)
323
+ return result.finally(() => {
324
+ if (this.decisionTails.get(sessionId) === tail) this.decisionTails.delete(sessionId)
325
+ })
326
+ }
327
+
328
+ private enqueueMutation<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
329
+ const previous = this.mutationTails.get(sessionId) ?? Promise.resolve()
330
+ const result = previous.then(operation)
331
+ const tail = result.then(() => undefined, () => undefined)
332
+ this.mutationTails.set(sessionId, tail)
333
+ return result.finally(() => {
334
+ if (this.mutationTails.get(sessionId) === tail) this.mutationTails.delete(sessionId)
335
+ })
336
+ }
337
+ }
@@ -0,0 +1,185 @@
1
+ /** macOS Accessibility/CoreGraphics/ScreenCaptureKit provider for `ctx.computerUse`. */
2
+
3
+ import { setTimeout as delay } from 'node:timers/promises'
4
+ import { Service, type Context } from '@deepseek-ai/cordis'
5
+ import type {} from '@deepseek-ai/dsh-agent'
6
+ import type {} from '@deepseek-ai/dsh-settings'
7
+ import type {} from '@deepseek-ai/dsh-subprocess'
8
+ import type {} from '@deepseek-ai/dsh-user-approval'
9
+ import type {
10
+ BackendActionRequest,
11
+ BackendActionResult,
12
+ BackendCursorAction,
13
+ BackendHealth,
14
+ BackendObservation,
15
+ BackendObserveOptions,
16
+ ComputerUseBackend,
17
+ } from '../backend.ts'
18
+ import {
19
+ Config,
20
+ COMPUTER_USE_SETTINGS_NAMESPACE,
21
+ resolveConfig,
22
+ type ComputerUseConfig,
23
+ type ResolvedComputerUseConfig,
24
+ } from '../config.ts'
25
+ import { ComputerUseError } from '../errors.ts'
26
+ import { ComputerUseService } from '../service.ts'
27
+ import type { ComputerAppIdentity, ComputerAppSelector, ComputerAppSummary } from '../types.ts'
28
+ import { NativeHelperClient } from './native-helper.ts'
29
+
30
+ interface NativeHealth {
31
+ helperVersion: string
32
+ accessibility: BackendHealth['accessibility']
33
+ screenRecording: BackendHealth['screenRecording']
34
+ }
35
+
36
+ interface NativeObservation extends BackendObservation {}
37
+
38
+ /** Fixed-command native backend. */
39
+ class MacOSBackend implements ComputerUseBackend {
40
+ readonly name = 'macos-ax' as const
41
+ readonly client: NativeHelperClient
42
+
43
+ constructor(ctx: Context, private readonly config: ResolvedComputerUseConfig) {
44
+ this.client = new NativeHelperClient(ctx, config)
45
+ }
46
+
47
+ get helperPath(): string {
48
+ return this.client.helperPath
49
+ }
50
+
51
+ async resolveApp(selector: ComputerAppSelector, signal: AbortSignal): Promise<ComputerAppIdentity> {
52
+ return await this.client.invoke<ComputerAppIdentity>({ command: 'resolve-app', selector }, signal)
53
+ }
54
+
55
+ async listApps(signal: AbortSignal): Promise<ComputerAppSummary[]> {
56
+ return await this.client.invoke<ComputerAppSummary[]>({ command: 'list-apps' }, signal)
57
+ }
58
+
59
+ async observe(app: ComputerAppIdentity, options: BackendObserveOptions, signal: AbortSignal): Promise<BackendObservation> {
60
+ return await this.client.invoke<NativeObservation>({
61
+ command: 'observe',
62
+ app,
63
+ options,
64
+ }, signal)
65
+ }
66
+
67
+ async act(request: BackendActionRequest, signal: AbortSignal): Promise<BackendActionResult> {
68
+ return await this.client.invoke<BackendActionResult>({
69
+ command: 'act',
70
+ request: {
71
+ ...request,
72
+ actionTimeoutMs: this.config.actionTimeoutMs,
73
+ limits: {
74
+ maxNodes: this.config.maxNodes,
75
+ maxDepth: this.config.maxDepth,
76
+ maxTextBytes: this.config.maxTextBytes,
77
+ },
78
+ },
79
+ }, signal)
80
+ }
81
+
82
+ async visualizeCursor(action: BackendCursorAction, phase: 'before' | 'after', signal: AbortSignal): Promise<void> {
83
+ if (this.config.interaction.cursorVisualization !== 'visible') return
84
+ const autoHideMs = this.config.interaction.cursorAutoHideMs
85
+ const move = async (point: { x: number; y: number }, durationMs: number): Promise<void> => {
86
+ await this.client.cursorCommand({
87
+ op: 'move',
88
+ x: point.x,
89
+ y: point.y,
90
+ durationMs,
91
+ autoHideMs,
92
+ targetPid: action.targetPid,
93
+ targetWindowNumber: action.targetWindowNumber,
94
+ targetWindowFrame: action.targetWindowFrame,
95
+ }, signal)
96
+ }
97
+ if (phase === 'after') {
98
+ if (action.kind === 'drag') await this.client.cursorCommand({
99
+ op: 'release',
100
+ autoHideMs,
101
+ targetPid: action.targetPid,
102
+ targetWindowNumber: action.targetWindowNumber,
103
+ targetWindowFrame: action.targetWindowFrame,
104
+ }, signal)
105
+ return
106
+ }
107
+ const start = action.kind === 'drag' ? action.from : action.to
108
+ if (start === undefined) return
109
+ await move(start, this.config.interaction.cursorMotionMs)
110
+ if (this.config.interaction.cursorMotionMs > 0) {
111
+ await delay(this.config.interaction.cursorMotionMs, undefined, { signal })
112
+ }
113
+ if (action.kind === 'scroll') return
114
+ await this.client.cursorCommand({
115
+ op: 'press',
116
+ autoHideMs,
117
+ targetPid: action.targetPid,
118
+ targetWindowNumber: action.targetWindowNumber,
119
+ targetWindowFrame: action.targetWindowFrame,
120
+ sustainedPress: action.kind === 'drag',
121
+ }, signal)
122
+ if (action.kind === 'drag') {
123
+ await move(action.to, Math.max(this.config.interaction.cursorMotionMs, 240))
124
+ }
125
+ }
126
+
127
+ async dispose(): Promise<void> {
128
+ await this.client.dispose()
129
+ }
130
+
131
+ async health(signal: AbortSignal): Promise<BackendHealth> {
132
+ const prepared = await this.client.prepare(signal)
133
+ const health = await this.client.invoke<NativeHealth>({ command: 'health' }, signal)
134
+ return {
135
+ helperVersion: health.helperVersion || prepared.version,
136
+ helperSha256: prepared.sha256,
137
+ accessibility: health.accessibility,
138
+ screenRecording: health.screenRecording,
139
+ }
140
+ }
141
+
142
+ async openSettings(kind: 'accessibility' | 'screen-recording', signal: AbortSignal): Promise<void> {
143
+ await this.client.invoke<null>({ command: 'open-settings', kind }, signal)
144
+ }
145
+ }
146
+
147
+ /** Cordis Service provider loaded by the Bundle before the model-facing consumer. */
148
+ export class MacOSComputerUseProvider extends ComputerUseService {
149
+ static inject = ['subprocess', 'approval', 'settings', 'sessions', 'agents']
150
+ static Config = Config
151
+
152
+ private readonly settings
153
+
154
+ 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
+ const settings = ctx.settings.register(COMPUTER_USE_SETTINGS_NAMESPACE, Config, {
159
+ base: config,
160
+ applies: 'live',
161
+ validate: (value) => { resolveConfig(value) },
162
+ })
163
+ const resolved = resolveConfig(settings.get())
164
+ super(ctx, new MacOSBackend(ctx, resolved), resolved)
165
+ this.settings = settings
166
+ ctx.effect(() => this.settings.watch(async (next) => {
167
+ const candidate = resolveConfig(next)
168
+ const backend = new MacOSBackend(ctx, candidate)
169
+ try {
170
+ await this.reconfigure(backend, candidate)
171
+ } catch (error) {
172
+ await backend.dispose()
173
+ throw error
174
+ }
175
+ }), 'dsh-computer-use: Settings watch')
176
+ ctx.effect(() => ctx.on('agent/disposed', ({ agent }) => { this.releaseAgent(agent) }), 'dsh-computer-use: Agent cleanup')
177
+ }
178
+
179
+ /** Verify helper integrity and permissions before the service is injectable. */
180
+ protected async [Service.init](): Promise<void> {
181
+ await this.initialize()
182
+ }
183
+ }
184
+
185
+ export default MacOSComputerUseProvider