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,343 @@
1
+ // =============================================================================
2
+ // desktop-presence — telling the owner an agent has the mouse, and letting
3
+ // them take it back.
4
+ //
5
+ // Phase 5 of docs/research/computer-use-capability-assessment.md. The browser
6
+ // line has had a takeover banner since April: when an agent drives Chrome the
7
+ // user sees who it is and what they are trying to do. The desktop had nothing
8
+ // — the pointer simply started moving. That is the difference between
9
+ // automation and a haunting, and it is why desktop control could not be
10
+ // handed to anyone.
11
+ //
12
+ // Three things, all of which need one long-lived process:
13
+ //
14
+ // A banner — who is acting, at what, with Pause and Stop.
15
+ // A hotkey — ⌃⌥⌘. stops everything from anywhere, including while an
16
+ // agent holds the keyboard.
17
+ // An eye — real mouse or keyboard input from the owner pauses the
18
+ // agent immediately, because someone reaching for the mouse
19
+ // is not asking politely.
20
+ //
21
+ // It owns no policy. It writes the same two files the shell rails already
22
+ // read (`desktop.stop`, `desktop.pause`), so there is one enforcement point
23
+ // and this process can die without anything being left un-enforced.
24
+ //
25
+ // Subcommands:
26
+ // begin --agent NAME --goal TEXT show/refresh the banner (starts if idle)
27
+ // end hide the banner and exit
28
+ // status JSON: running, paused, stopped
29
+ //
30
+ // =============================================================================
31
+
32
+ import AppKit
33
+ import ApplicationServices
34
+ import Foundation
35
+
36
+ // MARK: - Paths
37
+
38
+ let crewlyHome: String = ProcessInfo.processInfo.environment["CREWLY_HOME"]
39
+ ?? (NSHomeDirectory() as NSString).appendingPathComponent(".crewly")
40
+
41
+ let stateURL = URL(fileURLWithPath: (crewlyHome as NSString).appendingPathComponent("desktop-presence.json"))
42
+ let stopURL = URL(fileURLWithPath: (crewlyHome as NSString).appendingPathComponent("desktop.stop"))
43
+ let pauseURL = URL(fileURLWithPath: (crewlyHome as NSString).appendingPathComponent("desktop.pause"))
44
+
45
+ /// How long after the last `begin` the banner gives up and exits.
46
+ ///
47
+ /// The shell refreshes it before each action, so this only fires when the
48
+ /// agent has stopped — a crashed agent must not leave a banner on screen
49
+ /// claiming it is still working.
50
+ let IDLE_EXIT_SECONDS: TimeInterval = 45
51
+
52
+ // MARK: - Shared state
53
+
54
+ /// What the banner is currently saying, written by `begin`.
55
+ struct Presence: Codable {
56
+ var agent: String
57
+ var goal: String
58
+ var updatedAt: TimeInterval
59
+ }
60
+
61
+ func readPresence() -> Presence? {
62
+ guard let data = try? Data(contentsOf: stateURL) else { return nil }
63
+ return try? JSONDecoder().decode(Presence.self, from: data)
64
+ }
65
+
66
+ func writePresence(_ presence: Presence) {
67
+ try? FileManager.default.createDirectory(atPath: crewlyHome, withIntermediateDirectories: true)
68
+ try? JSONEncoder().encode(presence).write(to: stateURL)
69
+ }
70
+
71
+ func emit(_ object: [String: Any]) -> Never {
72
+ let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
73
+ FileHandle.standardOutput.write(data ?? Data("{}".utf8))
74
+ FileHandle.standardOutput.write(Data("\n".utf8))
75
+ exit(0)
76
+ }
77
+
78
+ // MARK: - The banner
79
+
80
+ /// A floating strip that never takes focus.
81
+ ///
82
+ /// Non-activating and `.statusBar` level so it sits over full-screen apps and
83
+ /// cannot steal a keystroke the agent is in the middle of sending; it joins
84
+ /// every Space so switching desktops does not hide the fact that something is
85
+ /// driving the machine.
86
+ final class BannerWindow: NSPanel {
87
+ private let label = NSTextField(labelWithString: "")
88
+ private let pauseButton = NSButton()
89
+
90
+ init() {
91
+ super.init(
92
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 44),
93
+ styleMask: [.borderless, .nonactivatingPanel],
94
+ backing: .buffered,
95
+ defer: false
96
+ )
97
+ isFloatingPanel = true
98
+ level = .statusBar
99
+ collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle]
100
+ backgroundColor = .clear
101
+ isOpaque = false
102
+ hasShadow = true
103
+ hidesOnDeactivate = false
104
+
105
+ let container = NSVisualEffectView(frame: contentRect(forFrameRect: frame))
106
+ container.material = .hudWindow
107
+ container.blendingMode = .behindWindow
108
+ container.state = .active
109
+ container.wantsLayer = true
110
+ container.layer?.cornerRadius = 10
111
+ container.autoresizingMask = [.width, .height]
112
+
113
+ label.font = .systemFont(ofSize: 13, weight: .medium)
114
+ label.textColor = .white
115
+ label.lineBreakMode = .byTruncatingTail
116
+ label.frame = NSRect(x: 14, y: 12, width: 330, height: 20)
117
+ label.autoresizingMask = [.width]
118
+
119
+ pauseButton.title = "Pause"
120
+ pauseButton.bezelStyle = .rounded
121
+ pauseButton.frame = NSRect(x: 352, y: 8, width: 70, height: 28)
122
+ pauseButton.target = self
123
+ pauseButton.action = #selector(togglePause)
124
+ pauseButton.autoresizingMask = [.minXMargin]
125
+
126
+ let stopButton = NSButton(title: "Stop", target: self, action: #selector(stopAll))
127
+ stopButton.bezelStyle = .rounded
128
+ stopButton.frame = NSRect(x: 430, y: 8, width: 70, height: 28)
129
+ stopButton.contentTintColor = .systemRed
130
+ stopButton.autoresizingMask = [.minXMargin]
131
+
132
+ container.addSubview(label)
133
+ container.addSubview(pauseButton)
134
+ container.addSubview(stopButton)
135
+ contentView = container
136
+ place()
137
+ }
138
+
139
+ /// Top centre of the main screen — out of the way of most work, and the
140
+ /// first place someone looks when the pointer starts moving on its own.
141
+ private func place() {
142
+ guard let screen = NSScreen.main else { return }
143
+ let visible = screen.visibleFrame
144
+ setFrameOrigin(NSPoint(x: visible.midX - frame.width / 2, y: visible.maxY - frame.height - 8))
145
+ }
146
+
147
+ func show(agent: String, goal: String) {
148
+ let trimmed = goal.isEmpty ? "working on your desktop" : goal
149
+ label.stringValue = "\(agent): \(trimmed)"
150
+ refreshPauseTitle()
151
+ place()
152
+ orderFrontRegardless()
153
+ }
154
+
155
+ func refreshPauseTitle() {
156
+ let paused = FileManager.default.fileExists(atPath: pauseURL.path)
157
+ pauseButton.title = paused ? "Resume" : "Pause"
158
+ label.alphaValue = paused ? 0.6 : 1.0
159
+ }
160
+
161
+ @objc private func togglePause() {
162
+ if FileManager.default.fileExists(atPath: pauseURL.path) {
163
+ try? FileManager.default.removeItem(at: pauseURL)
164
+ } else {
165
+ FileManager.default.createFile(atPath: pauseURL.path, contents: Data("owner\n".utf8))
166
+ }
167
+ refreshPauseTitle()
168
+ }
169
+
170
+ @objc private func stopAll() {
171
+ // Stop, not pause: the rails refuse every action while this exists,
172
+ // and only the owner removing it lets anything run again.
173
+ FileManager.default.createFile(atPath: stopURL.path, contents: Data("banner\n".utf8))
174
+ NSApp.terminate(nil)
175
+ }
176
+ }
177
+
178
+ // MARK: - The eye
179
+ //
180
+ // A listen-only tap. The discriminator is the event's source state: real
181
+ // input carries the HID system state, while an event posted by the agent
182
+ // (CGEventCreateMouseEvent with a null source) does not. Without that check
183
+ // the agent would pause itself on its own first click.
184
+
185
+ let HID_SOURCE_STATE: Int64 = Int64(CGEventSourceStateID.hidSystemState.rawValue)
186
+
187
+ final class UserInputWatcher {
188
+ private var tap: CFMachPort?
189
+ private let onUserInput: () -> Void
190
+
191
+ init(onUserInput: @escaping () -> Void) {
192
+ self.onUserInput = onUserInput
193
+ }
194
+
195
+ func start() {
196
+ let mask =
197
+ (1 << CGEventType.mouseMoved.rawValue) |
198
+ (1 << CGEventType.leftMouseDown.rawValue) |
199
+ (1 << CGEventType.rightMouseDown.rawValue) |
200
+ (1 << CGEventType.keyDown.rawValue) |
201
+ (1 << CGEventType.scrollWheel.rawValue)
202
+
203
+ let callback: CGEventTapCallBack = { _, _, event, refcon in
204
+ guard let refcon else { return Unmanaged.passUnretained(event) }
205
+ let watcher = Unmanaged<UserInputWatcher>.fromOpaque(refcon).takeUnretainedValue()
206
+ let source = event.getIntegerValueField(.eventSourceStateID)
207
+ if source == HID_SOURCE_STATE {
208
+ watcher.onUserInput()
209
+ }
210
+ // Listen only: never swallow the owner's input.
211
+ return Unmanaged.passUnretained(event)
212
+ }
213
+
214
+ tap = CGEvent.tapCreate(
215
+ tap: .cgSessionEventTap,
216
+ place: .tailAppendEventTap,
217
+ options: .listenOnly,
218
+ eventsOfInterest: CGEventMask(mask),
219
+ callback: callback,
220
+ userInfo: Unmanaged.passUnretained(self).toOpaque()
221
+ )
222
+ guard let tap else { return }
223
+ let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
224
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
225
+ CGEvent.tapEnable(tap: tap, enable: true)
226
+ }
227
+ }
228
+
229
+ // MARK: - Application
230
+
231
+ final class PresenceApp: NSObject, NSApplicationDelegate {
232
+ private var banner: BannerWindow?
233
+ private var hotkeyMonitor: Any?
234
+ private var watcher: UserInputWatcher?
235
+ /// Ignore our own noise: the agent moving the mouse is not the owner.
236
+ private var lastUserInput: TimeInterval = 0
237
+
238
+ func applicationDidFinishLaunching(_ notification: Notification) {
239
+ let window = BannerWindow()
240
+ banner = window
241
+ if let presence = readPresence() {
242
+ window.show(agent: presence.agent, goal: presence.goal)
243
+ }
244
+
245
+ // ⌃⌥⌘. from anywhere. A global monitor needs accessibility, which
246
+ // desktop control already requires, so this costs no extra prompt.
247
+ hotkeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
248
+ let wanted: NSEvent.ModifierFlags = [.control, .option, .command]
249
+ guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == wanted,
250
+ event.charactersIgnoringModifiers == "." else { return }
251
+ FileManager.default.createFile(atPath: stopURL.path, contents: Data("hotkey\n".utf8))
252
+ NSApp.terminate(nil)
253
+ }
254
+
255
+ watcher = UserInputWatcher { [weak self] in self?.userTouchedTheMachine() }
256
+ watcher?.start()
257
+
258
+ // Poll rather than watch the file: the shell writes it with a plain
259
+ // redirect, and a rename-based watcher would miss that.
260
+ Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
261
+ self?.tick()
262
+ }
263
+ }
264
+
265
+ /// The owner reached for the mouse. Pause immediately and say so.
266
+ private func userTouchedTheMachine() {
267
+ let now = Date().timeIntervalSince1970
268
+ // One pause per burst; a moving mouse fires hundreds of events.
269
+ guard now - lastUserInput > 2 else { return }
270
+ lastUserInput = now
271
+ guard !FileManager.default.fileExists(atPath: pauseURL.path) else { return }
272
+ FileManager.default.createFile(atPath: pauseURL.path, contents: Data("user-input\n".utf8))
273
+ DispatchQueue.main.async { [weak self] in self?.banner?.refreshPauseTitle() }
274
+ }
275
+
276
+ private func tick() {
277
+ guard let banner else { return }
278
+ banner.refreshPauseTitle()
279
+
280
+ if FileManager.default.fileExists(atPath: stopURL.path) {
281
+ NSApp.terminate(nil)
282
+ return
283
+ }
284
+ guard let presence = readPresence() else {
285
+ NSApp.terminate(nil)
286
+ return
287
+ }
288
+ banner.show(agent: presence.agent, goal: presence.goal)
289
+ if Date().timeIntervalSince1970 - presence.updatedAt > IDLE_EXIT_SECONDS {
290
+ // The agent stopped refreshing — it finished, or it died. Either
291
+ // way the banner must not keep claiming work is in progress.
292
+ try? FileManager.default.removeItem(at: stateURL)
293
+ NSApp.terminate(nil)
294
+ }
295
+ }
296
+ }
297
+
298
+ // MARK: - Entry
299
+
300
+ var args = Array(CommandLine.arguments.dropFirst())
301
+ let command = args.first ?? "status"
302
+ args = Array(args.dropFirst())
303
+
304
+ func option(_ name: String) -> String? {
305
+ guard let i = args.firstIndex(of: name), i + 1 < args.count else { return nil }
306
+ return args[i + 1]
307
+ }
308
+
309
+ switch command {
310
+ case "begin":
311
+ writePresence(Presence(
312
+ agent: option("--agent") ?? "An agent",
313
+ goal: option("--goal") ?? "",
314
+ updatedAt: Date().timeIntervalSince1970
315
+ ))
316
+ if args.contains("--foreground") {
317
+ let app = NSApplication.shared
318
+ app.setActivationPolicy(.accessory) // no Dock icon, no menu bar
319
+ let delegate = PresenceApp()
320
+ app.delegate = delegate
321
+ app.run()
322
+ }
323
+ emit(["success": true, "action": "begin"])
324
+
325
+ case "end":
326
+ try? FileManager.default.removeItem(at: stateURL)
327
+ try? FileManager.default.removeItem(at: pauseURL)
328
+ emit(["success": true, "action": "end"])
329
+
330
+ case "status":
331
+ let presence = readPresence()
332
+ emit([
333
+ "success": true,
334
+ "showing": presence != nil,
335
+ "agent": presence?.agent ?? "",
336
+ "goal": presence?.goal ?? "",
337
+ "paused": FileManager.default.fileExists(atPath: pauseURL.path),
338
+ "stopped": FileManager.default.fileExists(atPath: stopURL.path),
339
+ ])
340
+
341
+ default:
342
+ emit(["success": false, "reason": "usage", "message": "Use begin, end or status."])
343
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Agent Skills desktop guards - delegates to shared library
3
+ SHARED_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/_common"
4
+ source "${SHARED_LIB_DIR}/desktop-guards.sh"
@@ -195,3 +195,91 @@ Returns screen coordinates of found elements. Use with click to interact.
195
195
  - `python3` + `PIL`/`Pillow` (for grid overlay, crop, and find action)
196
196
  - Accessibility permission for click, type, key, scroll, drag, focus
197
197
  - Screen Recording permission for screenshot and find
198
+
199
+ ## Working by element, not by coordinate
200
+
201
+ Guessing coordinates from a screenshot misses, costs an image every step, and
202
+ breaks whenever the theme, resolution or window position changes. Prefer this
203
+ loop:
204
+
205
+ ```bash
206
+ # 1. See what is there. Refs are @e1, @e2 … with role, name and frame.
207
+ bash execute.sh '{"action":"snapshot","app":"Numbers"}'
208
+
209
+ # 2. Act on a ref. Goes through the accessibility action, so it reaches a
210
+ # control that is scrolled out of view or covered by another window.
211
+ bash execute.sh '{"action":"click-ref","ref":"@e12"}'
212
+ bash execute.sh '{"action":"fill-ref","ref":"@e7","text":"hello"}'
213
+
214
+ # 3. Wait for the result instead of sleeping and hoping.
215
+ bash execute.sh '{"action":"wait-for","text":"Saved"}'
216
+ ```
217
+
218
+ | Action | What it does |
219
+ |---|---|
220
+ | `snapshot` | Elements of an app: `@ref`, role, name, value, frame, enabled. `--app` picks one, `menus:true` includes the menu bar (excluded by default — it would otherwise fill the whole list), `allWindows:true` covers every window. |
221
+ | `click-ref` | Press an element by ref (AXPress, falling back to a click at its centre). |
222
+ | `fill-ref` | Set a field's value directly — no dependence on focus or keyboard layout. Refuses password fields. |
223
+ | `resolve` | What is at a ref now, and whether it still matches the snapshot. |
224
+ | `ocr` | Text on screen with boxes (macOS Vision, local and free, Chinese and English). Covers canvases, PDFs and apps with no accessibility tree. |
225
+ | `wait-for` | Block until an app is frontmost, a ref resolves, text appears, or the screen stops changing. |
226
+ | `displays` | Every screen and its frame, so coordinates are unambiguous. |
227
+
228
+ Refs come from the last snapshot and are only valid next to it. If the window
229
+ changed, `resolve` says so and `click-ref` refuses rather than clicking
230
+ whatever moved into that position — take a fresh snapshot.
231
+
232
+ Coordinate actions (`click`, `type`, `key`, `drag`) remain for everything with
233
+ no accessibility tree: games, canvases, custom-drawn UI.
234
+
235
+ ## When you need a person
236
+
237
+ Some things an agent must not do: a CAPTCHA, a two-factor code, a sign-in, a
238
+ judgement the owner has to make. Hand the machine back rather than trying to
239
+ be clever — for a login, "clever" means typing a password that must never be
240
+ typed.
241
+
242
+ ```bash
243
+ bash execute.sh '{"action":"request-human","reason":"needs your 2FA code","detail":"Google sign-in page"}'
244
+ ```
245
+
246
+ It pauses desktop control (it does not cancel the task), captures what you are
247
+ stuck on so the owner can see it from their phone, and puts the reason on the
248
+ banner. The owner resumes with the banner's Resume button. Do not work around
249
+ a pause.
250
+
251
+ ## Reaching this machine from elsewhere
252
+
253
+ `/api/desktop/*` exposes the same actions over HTTP, and the portal and phone
254
+ reach it over the relay:
255
+
256
+ | Route | Over relay? |
257
+ |---|---|
258
+ | `GET /api/desktop/status` — usable, busy, paused, stopped | yes |
259
+ | `POST /api/desktop/look` — snapshot, ocr, screenshot, displays | yes |
260
+ | `POST /api/desktop/stop` — halt everything (`{resume:true}` lifts it) | yes |
261
+ | `POST /api/desktop/act` — move the mouse or keyboard | **no** |
262
+
263
+ Acting is deliberately local-only. Watching a machine and being able to stop
264
+ it are what an owner away from their desk needs, and neither can do harm;
265
+ driving a real keyboard should not be reachable from the internet just
266
+ because the phone app can reach everything else.
267
+
268
+ ## Which control surface to use
269
+
270
+ Crewly has three ways to act on a screen. Pick the **lowest** one that can do
271
+ the job — each step down costs more tokens, breaks more easily, and disturbs
272
+ the user more.
273
+
274
+ | Need | Use | Why |
275
+ |---|---|---|
276
+ | Anything a Crewly skill or connector already does (mail, Drive, Slack, calendar, git, files) | that skill | No screen at all. Fastest and cannot misclick. |
277
+ | Content of a web page, or acting as the signed-in user in Chrome | `remote-browser` | Real Chrome, real session, per-agent bound tab, and the user sees a takeover banner. |
278
+ | An Electron app (VS Code, Slack, Notion, Figma) | `desktop-app-control` | Accessibility snapshot with element refs — no coordinates. Needs the app started with a debug port. |
279
+ | Native macOS apps, system dialogs, anything the above cannot reach | `computer-use` | Last resort: screen coordinates and pixels. Slowest and most fragile. |
280
+
281
+ `computer-use` refuses destructive key combos, typing into password fields and
282
+ driving credential apps, holds a machine-wide lock while it works, and logs
283
+ every action to `~/.crewly/desktop-actions.jsonl`. Run
284
+ `{"action":"check-permissions"}` first — without Screen Recording and
285
+ Accessibility every action fails, and the refusal tells you what to grant.