crewly 1.20.40 → 1.20.48

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 (80) hide show
  1. package/config/skills/_common/desktop-guards.sh +485 -0
  2. package/config/skills/_common/desktop-guards.test.sh +242 -0
  3. package/config/skills/_common/desktop-perceive.swift +530 -0
  4. package/config/skills/_common/desktop-presence.swift +343 -0
  5. package/config/skills/agent/_common/desktop-guards.sh +4 -0
  6. package/config/skills/agent/computer-use/SKILL.md +88 -0
  7. package/config/skills/agent/computer-use/execute.sh +249 -3
  8. package/config/skills/agent/desktop-app-control/SKILL.md +19 -0
  9. package/config/skills/agent/remote-browser/SKILL.md +19 -0
  10. package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts +105 -0
  11. package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts.map +1 -0
  12. package/dist/backend/backend/src/controllers/desktop/desktop.controller.js +278 -0
  13. package/dist/backend/backend/src/controllers/desktop/desktop.controller.js.map +1 -0
  14. package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts +21 -0
  15. package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts.map +1 -0
  16. package/dist/backend/backend/src/controllers/desktop/desktop.routes.js +31 -0
  17. package/dist/backend/backend/src/controllers/desktop/desktop.routes.js.map +1 -0
  18. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  19. package/dist/backend/backend/src/routes/api.routes.js +3 -0
  20. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  21. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
  22. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +9 -0
  23. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
  24. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +18 -0
  25. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  26. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +33 -6
  27. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  28. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
  29. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +60 -1
  31. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  32. package/dist/backend/backend/src/services/slack/slack.service.d.ts +18 -1
  33. package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
  34. package/dist/backend/backend/src/services/slack/slack.service.js +38 -2
  35. package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
  36. package/dist/backend/backend/src/types/slack.types.d.ts +10 -0
  37. package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
  38. package/dist/backend/backend/src/types/slack.types.js.map +1 -1
  39. package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
  40. package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
  41. package/dist/backend/backend/src/utils/incomplete-turn.utils.js +4 -0
  42. package/dist/backend/backend/src/utils/incomplete-turn.utils.js.map +1 -1
  43. package/dist/backend/build-info.json +2 -2
  44. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +18 -0
  45. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  46. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +33 -6
  47. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  48. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
  49. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  50. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +60 -1
  51. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  52. package/dist/cli/backend/src/services/slack/slack.service.d.ts +18 -1
  53. package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
  54. package/dist/cli/backend/src/services/slack/slack.service.js +38 -2
  55. package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
  56. package/dist/cli/backend/src/types/slack.types.d.ts +10 -0
  57. package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
  58. package/dist/cli/backend/src/types/slack.types.js.map +1 -1
  59. package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
  60. package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
  61. package/dist/cli/backend/src/utils/incomplete-turn.utils.js +4 -0
  62. package/dist/cli/backend/src/utils/incomplete-turn.utils.js.map +1 -1
  63. package/package.json +1 -1
  64. package/packages/crewly-agent/src/eval/desktop/desktop-tasks.test.ts +96 -0
  65. package/packages/crewly-agent/src/eval/desktop/desktop-tasks.ts +226 -0
  66. package/packages/crewly-agent/src/runtime/agent-runner.service.ts +20 -1
  67. package/packages/crewly-agent/src/runtime/computer.tool.test.ts +219 -0
  68. package/packages/crewly-agent/src/runtime/computer.tool.ts +405 -0
  69. package/packages/crewly-agent/src/runtime/desktop-checkpoint.test.ts +136 -0
  70. package/packages/crewly-agent/src/runtime/desktop-checkpoint.ts +231 -0
  71. package/packages/crewly-agent/src/runtime/desktop-recovery.test.ts +100 -0
  72. package/packages/crewly-agent/src/runtime/desktop-recovery.ts +195 -0
  73. package/packages/crewly-agent/src/runtime/desktop-task-runtime.test.ts +251 -0
  74. package/packages/crewly-agent/src/runtime/desktop-task-runtime.ts +423 -0
  75. package/packages/crewly-agent/src/runtime/desktop-task.tool.test.ts +218 -0
  76. package/packages/crewly-agent/src/runtime/desktop-task.tool.ts +343 -0
  77. package/packages/crewly-agent/src/runtime/tool-registry.test.ts +17 -0
  78. package/packages/crewly-agent/src/runtime/tool-registry.ts +54 -0
  79. package/packages/crewly-agent/src/runtime/types.ts +10 -1
  80. package/config/skills/agent/vnc-browser/SKILL.md +0 -140
@@ -0,0 +1,530 @@
1
+ // =============================================================================
2
+ // desktop-perceive — the eyes of Crewly's desktop control.
3
+ //
4
+ // Phase 2 of docs/research/computer-use-capability-assessment.md. Until now an
5
+ // agent driving the desktop saw pixels: it took a screenshot and guessed
6
+ // coordinates, so it misclicked, it re-sent a whole image every step, and any
7
+ // change of theme, resolution or window position broke it.
8
+ //
9
+ // This gives it elements instead. One accessibility snapshot returns a flat
10
+ // list of refs (@e1, @e2 …) an agent can act on by name, the way the browser
11
+ // line already works with DOM selectors and agent-browser works with its own
12
+ // refs — so an agent learns one idea, not three.
13
+ //
14
+ // Written in Swift rather than JXA because the scripting bridge costs a round
15
+ // trip per attribute: reading one mid-sized window took seconds, which is too
16
+ // slow to do before every action. Raw AXUIElement reads the same tree in
17
+ // milliseconds, and Vision (for OCR) lives in the same binary.
18
+ //
19
+ // Subcommands:
20
+ // snapshot [--app NAME] [--max N] [--all-windows] [--menus] elements of an app
21
+ // resolve --ref @eN re-find one element
22
+ // ocr [--region x,y,w,h] [--image PATH] text and its boxes
23
+ // displays screens and their frames
24
+ //
25
+ // Every subcommand prints one JSON object on stdout.
26
+ // =============================================================================
27
+
28
+ import AppKit
29
+ import ApplicationServices
30
+ import Foundation
31
+ import Vision
32
+
33
+ // MARK: - Output
34
+
35
+ /// Print a JSON object and exit. All output goes through here so a caller can
36
+ /// always parse stdout, including on failure.
37
+ func emit(_ object: [String: Any], exitCode: Int32 = 0) -> Never {
38
+ let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
39
+ FileHandle.standardOutput.write(data ?? Data("{}".utf8))
40
+ FileHandle.standardOutput.write(Data("\n".utf8))
41
+ exit(exitCode)
42
+ }
43
+
44
+ /// Fail in the same shape the shell guards use, so an agent parses one format.
45
+ func fail(_ reason: String, _ message: String, extra: [String: Any] = [:]) -> Never {
46
+ var out: [String: Any] = ["success": false, "reason": reason, "message": message]
47
+ out.merge(extra) { a, _ in a }
48
+ emit(out, exitCode: 1)
49
+ }
50
+
51
+ // MARK: - Accessibility helpers
52
+
53
+ /// Read one attribute, returning nil rather than throwing — most elements are
54
+ /// missing most attributes and that is normal, not an error.
55
+ func attr(_ element: AXUIElement, _ name: String) -> CFTypeRef? {
56
+ var value: CFTypeRef?
57
+ return AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success ? value : nil
58
+ }
59
+
60
+ func stringAttr(_ element: AXUIElement, _ name: String) -> String? {
61
+ guard let raw = attr(element, name) else { return nil }
62
+ if let s = raw as? String { return s.isEmpty ? nil : s }
63
+ if let n = raw as? NSNumber { return n.stringValue }
64
+ return nil
65
+ }
66
+
67
+ func boolAttr(_ element: AXUIElement, _ name: String) -> Bool? {
68
+ (attr(element, name) as? NSNumber)?.boolValue
69
+ }
70
+
71
+ /// Screen frame of an element, in the top-left origin coordinates the rest of
72
+ /// the skill uses (AX already reports top-left, unlike AppKit).
73
+ func frameOf(_ element: AXUIElement) -> CGRect? {
74
+ guard let posRaw = attr(element, kAXPositionAttribute as String),
75
+ let sizeRaw = attr(element, kAXSizeAttribute as String) else { return nil }
76
+ var point = CGPoint.zero
77
+ var size = CGSize.zero
78
+ // swiftlint:disable:next force_cast
79
+ guard AXValueGetValue(posRaw as! AXValue, .cgPoint, &point),
80
+ AXValueGetValue(sizeRaw as! AXValue, .cgSize, &size) else { return nil }
81
+ return CGRect(origin: point, size: size)
82
+ }
83
+
84
+ func childrenOf(_ element: AXUIElement) -> [AXUIElement] {
85
+ (attr(element, kAXChildrenAttribute as String) as? [AXUIElement]) ?? []
86
+ }
87
+
88
+ // MARK: - Snapshot
89
+
90
+ /// One element as an agent sees it.
91
+ ///
92
+ /// `path` is how the element is found again in a later process: the child
93
+ /// indices from the application root. AXUIElement handles cannot cross a
94
+ /// process boundary, so the ref must be re-resolved, and the role/title/frame
95
+ /// travel with it so re-resolution can be checked rather than assumed.
96
+ struct Node {
97
+ let ref: String
98
+ let role: String
99
+ let subrole: String?
100
+ let name: String?
101
+ let value: String?
102
+ let frame: CGRect?
103
+ let enabled: Bool?
104
+ let focused: Bool?
105
+ let path: [Int]
106
+
107
+ var json: [String: Any] {
108
+ var out: [String: Any] = ["ref": ref, "role": role, "path": path]
109
+ if let subrole { out["subrole"] = subrole }
110
+ if let name { out["name"] = name }
111
+ if let value { out["value"] = value }
112
+ if let frame {
113
+ out["frame"] = [Int(frame.origin.x), Int(frame.origin.y), Int(frame.width), Int(frame.height)]
114
+ }
115
+ if let enabled { out["enabled"] = enabled }
116
+ if focused == true { out["focused"] = true }
117
+ return out
118
+ }
119
+ }
120
+
121
+ /// Roles that carry no information on their own. Keeping them would triple the
122
+ /// list an agent has to read for nothing — an agent acts on buttons and fields,
123
+ /// not on the groups that hold them.
124
+ let skeletalRoles: Set<String> = [
125
+ "AXGroup", "AXSplitGroup", "AXScrollArea", "AXLayoutArea", "AXLayoutItem",
126
+ "AXUnknown", "AXSplitter", "AXGrowArea",
127
+ ]
128
+
129
+ /// Roles worth reporting even with no title — an agent may still need to click
130
+ /// or read them.
131
+ let alwaysKeepRoles: Set<String> = [
132
+ "AXTextField", "AXTextArea", "AXSecureTextField", "AXComboBox", "AXSlider",
133
+ "AXCheckBox", "AXRadioButton", "AXPopUpButton", "AXWindow", "AXSheet", "AXTable", "AXList",
134
+ ]
135
+
136
+ /// Walk an application's accessibility tree into a flat list.
137
+ ///
138
+ /// Flat, not nested, because an agent has to name one element, not navigate a
139
+ /// structure; the `path` field keeps the structure available where it matters.
140
+ func snapshot(app: AXUIElement, limit: Int, allWindows: Bool, includeMenus: Bool) -> [Node] {
141
+ var out: [Node] = []
142
+ var counter = 0
143
+
144
+ // Depth cap alongside the node cap: a runaway tree (a huge table) would
145
+ // otherwise spend the whole budget on one branch and miss the toolbar.
146
+ func walk(_ element: AXUIElement, path: [Int], depth: Int) {
147
+ if out.count >= limit || depth > 24 { return }
148
+
149
+ let role = stringAttr(element, kAXRoleAttribute as String) ?? "AXUnknown"
150
+ let name = stringAttr(element, kAXTitleAttribute as String)
151
+ ?? stringAttr(element, kAXDescriptionAttribute as String)
152
+ ?? stringAttr(element, "AXLabel")
153
+ let rawValue = stringAttr(element, kAXValueAttribute as String)
154
+ // A long text area would otherwise dominate the output.
155
+ let value = rawValue.map { $0.count > 200 ? String($0.prefix(200)) + "…" : $0 }
156
+ let frame = frameOf(element)
157
+
158
+ // Zero-sized and offscreen elements are real in the tree but cannot be
159
+ // clicked, so reporting them only invites the agent to try.
160
+ let visible = frame.map { $0.width > 1 && $0.height > 1 } ?? false
161
+ let informative = !skeletalRoles.contains(role) && (name != nil || value != nil || alwaysKeepRoles.contains(role))
162
+
163
+ if visible && informative {
164
+ counter += 1
165
+ out.append(Node(
166
+ ref: "@e\(counter)",
167
+ role: role,
168
+ subrole: stringAttr(element, kAXSubroleAttribute as String),
169
+ name: name,
170
+ value: value,
171
+ frame: frame,
172
+ enabled: boolAttr(element, kAXEnabledAttribute as String),
173
+ focused: boolAttr(element, kAXFocusedAttribute as String),
174
+ path: path
175
+ ))
176
+ }
177
+
178
+ for (index, child) in childrenOf(element).enumerated() {
179
+ walk(child, path: path + [index], depth: depth + 1)
180
+ }
181
+ }
182
+
183
+ // Which subtrees to walk, and in what order. This matters more than it
184
+ // looks: the menu bar is an app's first child and carries every menu of
185
+ // every menu title, so walking the app root spends the whole budget on
186
+ // "Apple / File / Edit / View" and never reaches the window the agent
187
+ // asked about. Windows come first, and the menu bar only when asked for.
188
+ let appChildren = childrenOf(app)
189
+ func indexOf(_ element: AXUIElement) -> Int {
190
+ appChildren.firstIndex { CFEqual($0, element) } ?? 0
191
+ }
192
+
193
+ let windows = (attr(app, kAXWindowsAttribute as String) as? [AXUIElement]) ?? []
194
+ let focused = attr(app, kAXFocusedWindowAttribute as String).map { unsafeBitCast($0, to: AXUIElement.self) }
195
+
196
+ var roots: [AXUIElement] = []
197
+ if allWindows {
198
+ roots = windows
199
+ } else if let focused {
200
+ roots = [focused]
201
+ } else if let first = windows.first {
202
+ roots = [first]
203
+ }
204
+ if includeMenus || roots.isEmpty {
205
+ // No window at all (a menu-bar-only app, or everything minimised):
206
+ // the menu bar is then the only thing there is to act on.
207
+ if let menuBar = attr(app, kAXMenuBarAttribute as String).map({ unsafeBitCast($0, to: AXUIElement.self) }) {
208
+ roots.append(menuBar)
209
+ }
210
+ }
211
+ if roots.isEmpty { roots = [app] }
212
+
213
+ for root in roots { walk(root, path: [indexOf(root)], depth: 0) }
214
+ return out
215
+ }
216
+
217
+ /// The application to snapshot: the one named, else whatever is in front.
218
+ func targetApp(named: String?) -> (AXUIElement, String, pid_t) {
219
+ let running = NSWorkspace.shared.runningApplications
220
+ let chosen: NSRunningApplication?
221
+ if let named {
222
+ chosen = running.first {
223
+ $0.localizedName?.compare(named, options: .caseInsensitive) == .orderedSame
224
+ || $0.bundleIdentifier?.compare(named, options: .caseInsensitive) == .orderedSame
225
+ }
226
+ } else {
227
+ chosen = NSWorkspace.shared.frontmostApplication
228
+ }
229
+ guard let appProcess = chosen, let pid = Optional(appProcess.processIdentifier) else {
230
+ fail("app_not_found",
231
+ named.map { "No running application called \($0). Use list-apps to see what is open." }
232
+ ?? "Could not determine the frontmost application.")
233
+ }
234
+ return (AXUIElementCreateApplication(pid), appProcess.localizedName ?? "unknown", pid)
235
+ }
236
+
237
+ // MARK: - Ref cache
238
+ //
239
+ // A ref is only meaningful next to the snapshot that minted it, so the cache
240
+ // records which app and which snapshot, and `resolve` refuses a ref from a
241
+ // different app rather than acting on whatever happens to sit at that path.
242
+
243
+ let cacheURL: URL = {
244
+ let home = ProcessInfo.processInfo.environment["CREWLY_HOME"]
245
+ ?? (NSHomeDirectory() as NSString).appendingPathComponent(".crewly")
246
+ try? FileManager.default.createDirectory(atPath: home, withIntermediateDirectories: true)
247
+ return URL(fileURLWithPath: (home as NSString).appendingPathComponent("desktop-refs.json"))
248
+ }()
249
+
250
+ func writeCache(app: String, pid: pid_t, nodes: [Node]) {
251
+ var refs: [String: Any] = [:]
252
+ for node in nodes {
253
+ refs[node.ref] = [
254
+ "path": node.path, "role": node.role,
255
+ "name": node.name ?? "", "frame": node.frame.map { [$0.midX, $0.midY] } ?? [],
256
+ ]
257
+ }
258
+ let payload: [String: Any] = ["app": app, "pid": Int(pid), "at": Date().timeIntervalSince1970, "refs": refs]
259
+ try? JSONSerialization.data(withJSONObject: payload).write(to: cacheURL)
260
+ }
261
+
262
+ /// Re-find a ref in the live tree.
263
+ ///
264
+ /// Walking the recorded path can land on a different element if the window
265
+ /// changed, so the role is checked and the name compared. A mismatch is
266
+ /// reported rather than hidden: the caller decides whether to click the last
267
+ /// known position or take a fresh snapshot.
268
+ func resolve(ref: String) -> [String: Any] {
269
+ guard let data = try? Data(contentsOf: cacheURL),
270
+ let cache = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
271
+ let refs = cache["refs"] as? [String: Any],
272
+ let entry = refs[ref] as? [String: Any] else {
273
+ fail("unknown_ref", "\(ref) is not in the last snapshot. Take a snapshot first.")
274
+ }
275
+
276
+ let expectedRole = entry["role"] as? String ?? ""
277
+ let expectedName = entry["name"] as? String ?? ""
278
+ let path = entry["path"] as? [Int] ?? []
279
+ let lastKnown = entry["frame"] as? [Double] ?? []
280
+ let appName = cache["app"] as? String ?? ""
281
+
282
+ let (app, liveName, _) = targetApp(named: appName)
283
+ if liveName.compare(appName, options: .caseInsensitive) != .orderedSame {
284
+ fail("app_changed",
285
+ "\(ref) was captured in \(appName) but \(liveName) is in front now. Take a new snapshot.",
286
+ extra: ["expectedApp": appName, "actualApp": liveName])
287
+ }
288
+
289
+ var element = app
290
+ for index in path {
291
+ let kids = childrenOf(element)
292
+ guard index < kids.count else {
293
+ return ["success": false, "reason": "ref_stale", "ref": ref,
294
+ "message": "\(ref) no longer exists — the window changed. Take a new snapshot.",
295
+ "lastKnownCenter": lastKnown]
296
+ }
297
+ element = kids[index]
298
+ }
299
+
300
+ let role = stringAttr(element, kAXRoleAttribute as String) ?? ""
301
+ let name = stringAttr(element, kAXTitleAttribute as String)
302
+ ?? stringAttr(element, kAXDescriptionAttribute as String) ?? ""
303
+ let frame = frameOf(element)
304
+ let matches = role == expectedRole && (expectedName.isEmpty || name == expectedName)
305
+
306
+ var out: [String: Any] = [
307
+ "success": true, "ref": ref, "role": role, "name": name,
308
+ "matches": matches,
309
+ "center": frame.map { [Int($0.midX), Int($0.midY)] } ?? lastKnown.map { Int($0) },
310
+ ]
311
+ if !matches {
312
+ out["warning"] = "The element at this position is now \(role) \"\(name)\", not \(expectedRole) \"\(expectedName)\". Take a new snapshot before acting."
313
+ }
314
+ // Whether it can be pressed directly decides if the caller needs a click.
315
+ var actions: CFArray?
316
+ if AXUIElementCopyActionNames(element, &actions) == .success,
317
+ let names = actions as? [String] {
318
+ out["actions"] = names
319
+ out["pressable"] = names.contains(kAXPressAction as String)
320
+ }
321
+ return out
322
+ }
323
+
324
+ /// Perform AXPress on a ref. Returns false when the element has no press
325
+ /// action, so the caller can fall back to clicking its centre.
326
+ func press(ref: String) -> [String: Any] {
327
+ let info = resolve(ref: ref)
328
+ guard info["success"] as? Bool == true else { return info }
329
+ guard let cacheData = try? Data(contentsOf: cacheURL),
330
+ let cache = try? JSONSerialization.jsonObject(with: cacheData) as? [String: Any],
331
+ let refs = cache["refs"] as? [String: Any],
332
+ let entry = refs[ref] as? [String: Any],
333
+ let path = entry["path"] as? [Int] else {
334
+ return ["success": false, "reason": "unknown_ref", "ref": ref]
335
+ }
336
+ let (app, _, _) = targetApp(named: cache["app"] as? String)
337
+ var element = app
338
+ for index in path {
339
+ let kids = childrenOf(element)
340
+ guard index < kids.count else { return ["success": false, "reason": "ref_stale", "ref": ref] }
341
+ element = kids[index]
342
+ }
343
+ let result = AXUIElementPerformAction(element, kAXPressAction as CFString)
344
+ return ["success": result == .success, "ref": ref, "method": "AXPress",
345
+ "center": info["center"] ?? [], "axError": result == .success ? 0 : Int(result.rawValue)]
346
+ }
347
+
348
+ /// Set a text field's value directly. Far more reliable than typing, which
349
+ /// depends on focus and on the keyboard layout.
350
+ func setValue(ref: String, text: String) -> [String: Any] {
351
+ guard let cacheData = try? Data(contentsOf: cacheURL),
352
+ let cache = try? JSONSerialization.jsonObject(with: cacheData) as? [String: Any],
353
+ let refs = cache["refs"] as? [String: Any],
354
+ let entry = refs[ref] as? [String: Any],
355
+ let path = entry["path"] as? [Int] else {
356
+ fail("unknown_ref", "\(ref) is not in the last snapshot. Take a snapshot first.")
357
+ }
358
+ let (app, _, _) = targetApp(named: cache["app"] as? String)
359
+ var element = app
360
+ for index in path {
361
+ let kids = childrenOf(element)
362
+ guard index < kids.count else { return ["success": false, "reason": "ref_stale", "ref": ref] }
363
+ element = kids[index]
364
+ }
365
+ let role = stringAttr(element, kAXRoleAttribute as String) ?? ""
366
+ if role == "AXSecureTextField" {
367
+ fail("secure_field", "\(ref) is a password field. Desktop control never fills one — ask the owner.")
368
+ }
369
+ let result = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef)
370
+ return ["success": result == .success, "ref": ref, "role": role,
371
+ "axError": result == .success ? 0 : Int(result.rawValue)]
372
+ }
373
+
374
+ // MARK: - OCR
375
+
376
+ /// Read the text on screen, with a box for each piece.
377
+ ///
378
+ /// Vision runs locally and free, handles Chinese and English, and covers what
379
+ /// the accessibility tree does not expose — a canvas, a PDF page, an app that
380
+ /// simply does not implement AX.
381
+ func ocr(region: CGRect?, imagePath: String?) -> [String: Any] {
382
+ // Capture via `screencapture` rather than CoreGraphics: CGDisplayCreateImage
383
+ // was obsoleted in macOS 15 and its replacement, ScreenCaptureKit, is async
384
+ // and far heavier than this needs. The shell tool is also what the rest of
385
+ // the skill already uses, so one TCC grant covers both.
386
+ let path: String
387
+ var temporary: String?
388
+ if let imagePath {
389
+ path = imagePath
390
+ } else {
391
+ let scratch = NSTemporaryDirectory() + "crewly-ocr-\(getpid()).png"
392
+ var args = ["-x"]
393
+ if let region {
394
+ args += ["-R", "\(Int(region.origin.x)),\(Int(region.origin.y)),\(Int(region.width)),\(Int(region.height))"]
395
+ }
396
+ args.append(scratch)
397
+ let task = Process()
398
+ task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
399
+ task.arguments = args
400
+ try? task.run()
401
+ task.waitUntilExit()
402
+ guard FileManager.default.fileExists(atPath: scratch) else {
403
+ fail("screenshot_failed",
404
+ "screencapture produced nothing. Screen Recording is probably not granted to this process.",
405
+ extra: ["permission": "screen-recording"])
406
+ }
407
+ path = scratch
408
+ temporary = scratch
409
+ }
410
+ defer { if let temporary { try? FileManager.default.removeItem(atPath: temporary) } }
411
+
412
+ guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil),
413
+ let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
414
+ fail("bad_image", "Could not read the image at \(path).")
415
+ }
416
+
417
+ let request = VNRecognizeTextRequest()
418
+ request.recognitionLevel = .accurate
419
+ request.usesLanguageCorrection = true
420
+ request.recognitionLanguages = ["zh-Hans", "en-US"]
421
+
422
+ do {
423
+ try VNImageRequestHandler(cgImage: image, options: [:]).perform([request])
424
+ } catch {
425
+ fail("ocr_failed", "Vision could not read the image: \(error.localizedDescription)")
426
+ }
427
+
428
+ // screencapture writes backing pixels; the rest of the skill speaks screen
429
+ // points, so divide by the scale of the display the region came from.
430
+ let scale = NSScreen.main.map { Double($0.backingScaleFactor) } ?? 2.0
431
+ let widthPoints = Double(image.width) / scale
432
+ let heightPoints = Double(image.height) / scale
433
+ let originX = Double(region?.origin.x ?? 0)
434
+ let originY = Double(region?.origin.y ?? 0)
435
+
436
+ var items: [[String: Any]] = []
437
+ for observation in request.results ?? [] {
438
+ guard let candidate = observation.topCandidates(1).first else { continue }
439
+ // Vision reports a unit box with a bottom-left origin; screen points
440
+ // run from the top left.
441
+ let box = observation.boundingBox
442
+ let x = originX + Double(box.origin.x) * widthPoints
443
+ let y = originY + (1 - Double(box.origin.y) - Double(box.height)) * heightPoints
444
+ let w = Double(box.width) * widthPoints
445
+ let h = Double(box.height) * heightPoints
446
+ items.append([
447
+ "text": candidate.string,
448
+ "confidence": Double(round(candidate.confidence * 100) / 100),
449
+ "frame": [Int(x), Int(y), Int(w), Int(h)],
450
+ "center": [Int(x + w / 2), Int(y + h / 2)],
451
+ ])
452
+ }
453
+ return ["success": true, "action": "ocr", "count": items.count, "items": items]
454
+ }
455
+
456
+ // MARK: - Displays
457
+
458
+ func displays() -> [String: Any] {
459
+ let screens = NSScreen.screens.enumerated().map { index, screen -> [String: Any] in
460
+ let frame = screen.frame
461
+ return [
462
+ "index": index,
463
+ "frame": [Int(frame.origin.x), Int(frame.origin.y), Int(frame.width), Int(frame.height)],
464
+ "scale": Double(screen.backingScaleFactor),
465
+ "main": screen == NSScreen.main,
466
+ ]
467
+ }
468
+ return ["success": true, "action": "displays", "count": screens.count, "displays": screens]
469
+ }
470
+
471
+ // MARK: - Entry
472
+
473
+ var args = Array(CommandLine.arguments.dropFirst())
474
+ guard let command = args.first else {
475
+ fail("usage", "Usage: desktop-perceive snapshot|resolve|press|set-value|ocr|displays [options]")
476
+ }
477
+ args = Array(args.dropFirst())
478
+
479
+ func option(_ name: String) -> String? {
480
+ guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil }
481
+ return args[index + 1]
482
+ }
483
+
484
+ // Every subcommand but `displays` reads the accessibility tree or the screen.
485
+ if command != "displays" && !AXIsProcessTrusted() {
486
+ fail("permission_required",
487
+ "Accessibility is not granted to this process, so the element tree is invisible.",
488
+ extra: ["permission": "accessibility",
489
+ "howTo": "System Settings → Privacy & Security → Accessibility"])
490
+ }
491
+
492
+ switch command {
493
+ case "snapshot":
494
+ let limit = Int(option("--max") ?? "") ?? 200
495
+ let (app, name, pid) = targetApp(named: option("--app"))
496
+ let nodes = snapshot(app: app, limit: limit, allWindows: args.contains("--all-windows"),
497
+ includeMenus: args.contains("--menus"))
498
+ writeCache(app: name, pid: pid, nodes: nodes)
499
+ emit(["success": true, "action": "snapshot", "app": name, "count": nodes.count,
500
+ "truncated": nodes.count >= limit, "elements": nodes.map(\.json)])
501
+
502
+ case "resolve":
503
+ guard let ref = option("--ref") else { fail("usage", "resolve needs --ref @eN") }
504
+ emit(resolve(ref: ref))
505
+
506
+ case "press":
507
+ guard let ref = option("--ref") else { fail("usage", "press needs --ref @eN") }
508
+ emit(press(ref: ref))
509
+
510
+ case "set-value":
511
+ guard let ref = option("--ref"), let text = option("--text") else {
512
+ fail("usage", "set-value needs --ref @eN --text STRING")
513
+ }
514
+ emit(setValue(ref: ref, text: text))
515
+
516
+ case "ocr":
517
+ var region: CGRect?
518
+ if let spec = option("--region") {
519
+ let parts = spec.split(separator: ",").compactMap { Double($0) }
520
+ guard parts.count == 4 else { fail("usage", "--region takes x,y,w,h") }
521
+ region = CGRect(x: parts[0], y: parts[1], width: parts[2], height: parts[3])
522
+ }
523
+ emit(ocr(region: region, imagePath: option("--image")))
524
+
525
+ case "displays":
526
+ emit(displays())
527
+
528
+ default:
529
+ fail("usage", "Unknown subcommand \(command). Use snapshot, resolve, press, set-value, ocr or displays.")
530
+ }