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.
- package/config/skills/_common/desktop-guards.sh +485 -0
- package/config/skills/_common/desktop-guards.test.sh +242 -0
- package/config/skills/_common/desktop-perceive.swift +530 -0
- package/config/skills/_common/desktop-presence.swift +343 -0
- package/config/skills/agent/_common/desktop-guards.sh +4 -0
- package/config/skills/agent/computer-use/SKILL.md +88 -0
- package/config/skills/agent/computer-use/execute.sh +249 -3
- package/config/skills/agent/desktop-app-control/SKILL.md +19 -0
- package/config/skills/agent/remote-browser/SKILL.md +19 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts +105 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.controller.js +278 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts +21 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.routes.js +31 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.routes.js.map +1 -0
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +3 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +9 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +18 -0
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +33 -6
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +60 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.d.ts +18 -1
- package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.js +38 -2
- package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/backend/backend/src/types/slack.types.d.ts +10 -0
- package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/slack.types.js.map +1 -1
- package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
- package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
- package/dist/backend/backend/src/utils/incomplete-turn.utils.js +4 -0
- package/dist/backend/backend/src/utils/incomplete-turn.utils.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +18 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +33 -6
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +60 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.d.ts +18 -1
- package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.js +38 -2
- package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/cli/backend/src/types/slack.types.d.ts +10 -0
- package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/slack.types.js.map +1 -1
- package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
- package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
- package/dist/cli/backend/src/utils/incomplete-turn.utils.js +4 -0
- package/dist/cli/backend/src/utils/incomplete-turn.utils.js.map +1 -1
- package/package.json +1 -1
- package/packages/crewly-agent/src/eval/desktop/desktop-tasks.test.ts +96 -0
- package/packages/crewly-agent/src/eval/desktop/desktop-tasks.ts +226 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.ts +20 -1
- package/packages/crewly-agent/src/runtime/computer.tool.test.ts +219 -0
- package/packages/crewly-agent/src/runtime/computer.tool.ts +405 -0
- package/packages/crewly-agent/src/runtime/desktop-checkpoint.test.ts +136 -0
- package/packages/crewly-agent/src/runtime/desktop-checkpoint.ts +231 -0
- package/packages/crewly-agent/src/runtime/desktop-recovery.test.ts +100 -0
- package/packages/crewly-agent/src/runtime/desktop-recovery.ts +195 -0
- package/packages/crewly-agent/src/runtime/desktop-task-runtime.test.ts +251 -0
- package/packages/crewly-agent/src/runtime/desktop-task-runtime.ts +423 -0
- package/packages/crewly-agent/src/runtime/desktop-task.tool.test.ts +218 -0
- package/packages/crewly-agent/src/runtime/desktop-task.tool.ts +343 -0
- package/packages/crewly-agent/src/runtime/tool-registry.test.ts +17 -0
- package/packages/crewly-agent/src/runtime/tool-registry.ts +54 -0
- package/packages/crewly-agent/src/runtime/types.ts +10 -1
- 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
|
+
}
|
|
@@ -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.
|