screenhand 0.1.1 → 0.3.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 (241) hide show
  1. package/README.md +193 -109
  2. package/bin/darwin-arm64/macos-bridge +0 -0
  3. package/dist/mcp-desktop.js +5876 -0
  4. package/dist/scripts/codex-monitor-daemon.js +335 -0
  5. package/dist/scripts/export-help-center.js +112 -0
  6. package/dist/scripts/marketing-loop.js +117 -0
  7. package/dist/scripts/observer-daemon.js +288 -0
  8. package/dist/scripts/orchestrator-daemon.js +399 -0
  9. package/dist/scripts/supervisor-daemon.js +272 -0
  10. package/dist/scripts/threads-campaign.js +208 -0
  11. package/dist/scripts/worker-daemon.js +228 -0
  12. package/dist/src/agent/cli.js +82 -0
  13. package/dist/src/agent/loop.js +274 -0
  14. package/dist/src/community/fetcher.js +109 -0
  15. package/dist/src/community/index.js +6 -0
  16. package/dist/src/community/publisher.js +191 -0
  17. package/dist/src/community/remote-api.js +121 -0
  18. package/dist/src/community/types.js +3 -0
  19. package/dist/src/community/validator.js +95 -0
  20. package/{src/config.ts → dist/src/config.js} +5 -10
  21. package/dist/src/context-tracker.js +489 -0
  22. package/{src/index.ts → dist/src/index.js} +32 -52
  23. package/dist/src/ingestion/coverage-auditor.js +233 -0
  24. package/dist/src/ingestion/doc-parser.js +164 -0
  25. package/dist/src/ingestion/index.js +8 -0
  26. package/dist/src/ingestion/menu-scanner.js +152 -0
  27. package/dist/src/ingestion/reference-merger.js +186 -0
  28. package/dist/src/ingestion/shortcut-extractor.js +180 -0
  29. package/dist/src/ingestion/tutorial-extractor.js +170 -0
  30. package/dist/src/ingestion/types.js +3 -0
  31. package/dist/src/jobs/manager.js +305 -0
  32. package/dist/src/jobs/runner.js +806 -0
  33. package/dist/src/jobs/store.js +102 -0
  34. package/dist/src/jobs/types.js +30 -0
  35. package/dist/src/jobs/worker.js +97 -0
  36. package/dist/src/learning/engine.js +356 -0
  37. package/dist/src/learning/index.js +9 -0
  38. package/dist/src/learning/locator-policy.js +120 -0
  39. package/dist/src/learning/pattern-policy.js +89 -0
  40. package/dist/src/learning/recovery-policy.js +116 -0
  41. package/dist/src/learning/sensor-policy.js +115 -0
  42. package/dist/src/learning/timing-model.js +204 -0
  43. package/dist/src/learning/topology-policy.js +90 -0
  44. package/dist/src/learning/types.js +9 -0
  45. package/dist/src/logging/timeline-logger.js +48 -0
  46. package/dist/src/mcp/mcp-stdio-server.js +464 -0
  47. package/dist/src/mcp/server.js +363 -0
  48. package/dist/src/mcp-entry.js +60 -0
  49. package/dist/src/memory/playbook-seeds.js +200 -0
  50. package/dist/src/memory/recall.js +222 -0
  51. package/dist/src/memory/research.js +104 -0
  52. package/dist/src/memory/seeds.js +101 -0
  53. package/dist/src/memory/service.js +446 -0
  54. package/dist/src/memory/session.js +169 -0
  55. package/dist/src/memory/store.js +451 -0
  56. package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
  57. package/dist/src/monitor/codex-monitor.js +382 -0
  58. package/dist/src/monitor/task-queue.js +97 -0
  59. package/dist/src/monitor/types.js +62 -0
  60. package/dist/src/native/bridge-client.js +412 -0
  61. package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
  62. package/dist/src/observer/state.js +199 -0
  63. package/dist/src/observer/types.js +43 -0
  64. package/dist/src/orchestrator/state.js +68 -0
  65. package/dist/src/orchestrator/types.js +22 -0
  66. package/dist/src/perception/ax-source.js +162 -0
  67. package/dist/src/perception/cdp-source.js +162 -0
  68. package/dist/src/perception/coordinator.js +771 -0
  69. package/dist/src/perception/frame-differ.js +287 -0
  70. package/dist/src/perception/index.js +22 -0
  71. package/dist/src/perception/manager.js +199 -0
  72. package/dist/src/perception/types.js +47 -0
  73. package/dist/src/perception/vision-source.js +399 -0
  74. package/dist/src/planner/deterministic.js +298 -0
  75. package/dist/src/planner/executor.js +870 -0
  76. package/dist/src/planner/goal-store.js +92 -0
  77. package/dist/src/planner/index.js +21 -0
  78. package/dist/src/planner/planner.js +520 -0
  79. package/dist/src/planner/tool-registry.js +71 -0
  80. package/dist/src/planner/types.js +22 -0
  81. package/dist/src/platform/explorer.js +213 -0
  82. package/dist/src/platform/help-center-markdown.js +527 -0
  83. package/dist/src/platform/learner.js +257 -0
  84. package/dist/src/playbook/engine.js +486 -0
  85. package/dist/src/playbook/index.js +20 -0
  86. package/dist/src/playbook/mcp-recorder.js +204 -0
  87. package/dist/src/playbook/recorder.js +536 -0
  88. package/dist/src/playbook/runner.js +408 -0
  89. package/dist/src/playbook/store.js +312 -0
  90. package/dist/src/playbook/types.js +17 -0
  91. package/dist/src/recovery/detectors.js +156 -0
  92. package/dist/src/recovery/engine.js +327 -0
  93. package/dist/src/recovery/index.js +20 -0
  94. package/dist/src/recovery/strategies.js +274 -0
  95. package/dist/src/recovery/types.js +20 -0
  96. package/dist/src/runtime/accessibility-adapter.js +430 -0
  97. package/dist/src/runtime/app-adapter.js +64 -0
  98. package/dist/src/runtime/applescript-adapter.js +305 -0
  99. package/dist/src/runtime/ax-role-map.js +96 -0
  100. package/dist/src/runtime/browser-adapter.js +52 -0
  101. package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
  102. package/dist/src/runtime/composite-adapter.js +221 -0
  103. package/dist/src/runtime/execution-contract.js +159 -0
  104. package/dist/src/runtime/executor.js +286 -0
  105. package/dist/src/runtime/locator-cache.js +50 -0
  106. package/dist/src/runtime/planning-loop.js +63 -0
  107. package/dist/src/runtime/service.js +432 -0
  108. package/dist/src/runtime/session-manager.js +63 -0
  109. package/dist/src/runtime/state-observer.js +121 -0
  110. package/dist/src/runtime/vision-adapter.js +225 -0
  111. package/dist/src/state/app-map-types.js +72 -0
  112. package/dist/src/state/app-map.js +1974 -0
  113. package/dist/src/state/entity-tracker.js +108 -0
  114. package/dist/src/state/fusion.js +96 -0
  115. package/dist/src/state/index.js +21 -0
  116. package/dist/src/state/ladder-generator.js +236 -0
  117. package/dist/src/state/persistence.js +156 -0
  118. package/dist/src/state/types.js +17 -0
  119. package/dist/src/state/world-model.js +1456 -0
  120. package/dist/src/supervisor/locks.js +186 -0
  121. package/dist/src/supervisor/supervisor.js +403 -0
  122. package/dist/src/supervisor/types.js +30 -0
  123. package/dist/src/test-mcp-protocol.js +154 -0
  124. package/dist/src/types.js +17 -0
  125. package/dist/src/util/atomic-write.js +133 -0
  126. package/dist/src/util/sanitize.js +146 -0
  127. package/dist-app-maps/com.figma.Desktop.json +959 -0
  128. package/dist-app-maps/com.hnc.Discord.json +1146 -0
  129. package/dist-app-maps/notion.id.json +2831 -0
  130. package/dist-playbooks/canva-screenhand-carousel.json +445 -0
  131. package/dist-playbooks/codex-desktop.json +76 -0
  132. package/dist-playbooks/competitor-research-stack.json +122 -0
  133. package/dist-playbooks/davinci-color-grade.json +153 -0
  134. package/dist-playbooks/davinci-edit-timeline.json +162 -0
  135. package/dist-playbooks/davinci-render.json +114 -0
  136. package/dist-playbooks/devto.json +52 -0
  137. package/dist-playbooks/discord.json +41 -0
  138. package/dist-playbooks/google-flow-create-project.json +59 -0
  139. package/dist-playbooks/google-flow-edit-image.json +90 -0
  140. package/dist-playbooks/google-flow-edit-video.json +90 -0
  141. package/dist-playbooks/google-flow-generate-image.json +68 -0
  142. package/dist-playbooks/google-flow-generate-video.json +191 -0
  143. package/dist-playbooks/google-flow-open-project.json +48 -0
  144. package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
  145. package/dist-playbooks/google-flow-search-assets.json +64 -0
  146. package/dist-playbooks/instagram.json +57 -0
  147. package/dist-playbooks/linkedin.json +52 -0
  148. package/dist-playbooks/n8n.json +43 -0
  149. package/dist-playbooks/reddit.json +52 -0
  150. package/dist-playbooks/threads.json +59 -0
  151. package/dist-playbooks/x-twitter.json +59 -0
  152. package/dist-playbooks/youtube.json +59 -0
  153. package/dist-references/canva.json +646 -0
  154. package/dist-references/codex-desktop.json +305 -0
  155. package/dist-references/davinci-resolve-keyboard.json +594 -0
  156. package/dist-references/davinci-resolve-menu-map.json +1139 -0
  157. package/dist-references/davinci-resolve-menus-batch1.json +116 -0
  158. package/dist-references/davinci-resolve-menus-batch2.json +372 -0
  159. package/dist-references/davinci-resolve-menus-batch3.json +330 -0
  160. package/dist-references/davinci-resolve-menus-batch4.json +297 -0
  161. package/dist-references/davinci-resolve-shortcuts.json +333 -0
  162. package/dist-references/devto.json +317 -0
  163. package/dist-references/discord.json +549 -0
  164. package/dist-references/figma.json +1186 -0
  165. package/dist-references/finder.json +146 -0
  166. package/dist-references/google-ads-transparency.json +95 -0
  167. package/dist-references/google-flow.json +649 -0
  168. package/dist-references/instagram.json +341 -0
  169. package/dist-references/linkedin.json +324 -0
  170. package/dist-references/meta-ad-library.json +86 -0
  171. package/dist-references/n8n.json +387 -0
  172. package/dist-references/notes.json +27 -0
  173. package/dist-references/notion.json +163 -0
  174. package/dist-references/reddit.json +341 -0
  175. package/dist-references/threads.json +337 -0
  176. package/dist-references/x-twitter.json +403 -0
  177. package/dist-references/youtube.json +373 -0
  178. package/native/macos-bridge/Package.swift +1 -0
  179. package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
  180. package/native/macos-bridge/Sources/AppManagement.swift +212 -2
  181. package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
  182. package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
  183. package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
  184. package/native/macos-bridge/Sources/main.swift +169 -16
  185. package/native/windows-bridge/Program.cs +5 -0
  186. package/native/windows-bridge/ScreenCapture.cs +124 -0
  187. package/package.json +29 -4
  188. package/scripts/postinstall.cjs +127 -0
  189. package/.claude/commands/automate.md +0 -28
  190. package/.claude/commands/debug-ui.md +0 -19
  191. package/.claude/commands/screenshot.md +0 -15
  192. package/.github/FUNDING.yml +0 -1
  193. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
  194. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  195. package/.mcp.json +0 -8
  196. package/DESKTOP_MCP_GUIDE.md +0 -92
  197. package/SECURITY.md +0 -44
  198. package/docs/architecture.md +0 -47
  199. package/install-skills.sh +0 -19
  200. package/mcp-bridge.ts +0 -271
  201. package/mcp-desktop.ts +0 -1221
  202. package/playbooks/instagram.json +0 -41
  203. package/playbooks/instagram_v2.json +0 -201
  204. package/playbooks/x_v1.json +0 -211
  205. package/scripts/devpost-live-loop.mjs +0 -421
  206. package/src/logging/timeline-logger.ts +0 -55
  207. package/src/mcp/server.ts +0 -449
  208. package/src/memory/recall.ts +0 -191
  209. package/src/memory/research.ts +0 -146
  210. package/src/memory/seeds.ts +0 -123
  211. package/src/memory/session.ts +0 -201
  212. package/src/memory/store.ts +0 -434
  213. package/src/memory/types.ts +0 -69
  214. package/src/native/bridge-client.ts +0 -239
  215. package/src/runtime/accessibility-adapter.ts +0 -487
  216. package/src/runtime/app-adapter.ts +0 -169
  217. package/src/runtime/applescript-adapter.ts +0 -376
  218. package/src/runtime/ax-role-map.ts +0 -102
  219. package/src/runtime/browser-adapter.ts +0 -129
  220. package/src/runtime/cdp-chrome-adapter.ts +0 -676
  221. package/src/runtime/composite-adapter.ts +0 -274
  222. package/src/runtime/executor.ts +0 -396
  223. package/src/runtime/planning-loop.ts +0 -81
  224. package/src/runtime/service.ts +0 -448
  225. package/src/runtime/session-manager.ts +0 -50
  226. package/src/runtime/state-observer.ts +0 -136
  227. package/src/runtime/vision-adapter.ts +0 -297
  228. package/src/types.ts +0 -297
  229. package/tests/bridge-client.test.ts +0 -176
  230. package/tests/browser-stealth.test.ts +0 -210
  231. package/tests/composite-adapter.test.ts +0 -64
  232. package/tests/mcp-server.test.ts +0 -151
  233. package/tests/memory-recall.test.ts +0 -339
  234. package/tests/memory-research.test.ts +0 -159
  235. package/tests/memory-seeds.test.ts +0 -120
  236. package/tests/memory-store.test.ts +0 -392
  237. package/tests/types.test.ts +0 -92
  238. package/tsconfig.check.json +0 -17
  239. package/tsconfig.json +0 -19
  240. package/vitest.config.ts +0 -8
  241. /package/{playbooks → dist-references}/devpost.json +0 -0
@@ -0,0 +1,806 @@
1
+ // Copyright (C) 2025 Clazro Technology Private Limited
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ //
4
+ // This file is part of ScreenHand.
5
+ //
6
+ // ScreenHand is free software: you can redistribute it and/or modify
7
+ // it under the terms of the GNU Affero General Public License as
8
+ // published by the Free Software Foundation, version 3.
9
+ //
10
+ // ScreenHand is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
17
+ import { planExecution, executeWithFallback, DEFAULT_RETRY_POLICY, } from "../runtime/execution-contract.js";
18
+ /** Patterns that indicate a blocker requiring human intervention. */
19
+ const HUMAN_BLOCKER_PATTERNS = [
20
+ "captcha", "recaptcha", "hcaptcha",
21
+ "2fa", "two-factor", "verification code",
22
+ "sign in", "log in", "login required",
23
+ "permission denied", "access denied",
24
+ "approve this", "confirm your identity",
25
+ ];
26
+ /** Patterns that indicate a transient blocker (auto-recoverable). */
27
+ const TRANSIENT_BLOCKER_PATTERNS = [
28
+ "rate limit", "too many requests", "try again later",
29
+ "loading", "please wait",
30
+ "timed out", "timeout",
31
+ "network error", "connection refused",
32
+ ];
33
+ const DEFAULT_CONFIG = {
34
+ heartbeatMs: 30_000,
35
+ stepDelayMs: 500,
36
+ maxConsecutiveFailures: 3,
37
+ hasCDP: false,
38
+ onLog: (msg) => console.error(`[JobRunner] ${msg}`),
39
+ };
40
+ export class JobRunner {
41
+ bridge;
42
+ jobs;
43
+ leaseManager;
44
+ supervisor;
45
+ config;
46
+ heartbeatTimer = null;
47
+ stopped = false;
48
+ /** PID of the currently focused target app, resolved during focusTargetApp */
49
+ activePid = 0;
50
+ constructor(bridge, jobs, leaseManager, supervisor, config) {
51
+ this.bridge = bridge;
52
+ this.jobs = jobs;
53
+ this.leaseManager = leaseManager;
54
+ this.supervisor = supervisor;
55
+ this.config = { ...DEFAULT_CONFIG, ...config };
56
+ }
57
+ log(msg) {
58
+ this.config.onLog?.(msg);
59
+ }
60
+ /**
61
+ * Run a single job cycle: dequeue → execute → finalize.
62
+ * Returns null if no jobs are queued.
63
+ */
64
+ async run() {
65
+ // 1. Dequeue
66
+ const job = this.jobs.dequeue();
67
+ if (!job)
68
+ return null;
69
+ const start = Date.now();
70
+ this.log(`Dequeued job ${job.id}: "${job.task}" (${job.steps.length} steps, resume from ${job.lastStep + 1})`);
71
+ // 2. Claim session
72
+ const sessionId = await this.claimSession(job);
73
+ if (!sessionId) {
74
+ this.jobs.transition(job.id, "failed", { error: "Failed to claim supervisor session" });
75
+ return { jobId: job.id, finalState: "failed", stepsCompleted: 0, totalSteps: job.steps.length, durationMs: Date.now() - start, error: "Failed to claim session" };
76
+ }
77
+ // 3. Start heartbeat
78
+ this.startHeartbeat(sessionId);
79
+ try {
80
+ // 4. Route: playbook engine or free-form steps
81
+ if (job.playbookId) {
82
+ if (!this.config.playbookEngine || !this.config.playbookStore) {
83
+ const err = `Job requires playbook "${job.playbookId}" but no playbook engine is configured`;
84
+ this.jobs.transition(job.id, "failed", { error: err });
85
+ return this.finalize(job, start, 0, err);
86
+ }
87
+ return await this.runViaPlaybookEngine(job, sessionId, start);
88
+ }
89
+ return await this.runFreeFormSteps(job, start);
90
+ }
91
+ catch (err) {
92
+ const msg = err instanceof Error ? err.message : String(err);
93
+ this.jobs.transition(job.id, "failed", { error: msg });
94
+ this.log(`Job ${job.id} → failed (unhandled): ${msg}`);
95
+ return this.finalize(job, start, 0, msg);
96
+ }
97
+ finally {
98
+ this.stopHeartbeat();
99
+ this.releaseSession(sessionId);
100
+ }
101
+ }
102
+ // ── Playbook engine path ──────────────────────
103
+ async runViaPlaybookEngine(job, sessionId, start) {
104
+ const engine = this.config.playbookEngine;
105
+ const store = this.config.playbookStore;
106
+ const playbook = store.get(job.playbookId);
107
+ if (!playbook) {
108
+ this.jobs.transition(job.id, "failed", { error: `Playbook "${job.playbookId}" not found` });
109
+ return this.finalize(job, start, 0, `Playbook "${job.playbookId}" not found`);
110
+ }
111
+ this.log(` Using playbook engine: "${playbook.name}" (${playbook.steps.length} steps)`);
112
+ // If job has no steps yet, populate from playbook so step tracking works
113
+ if (job.steps.length === 0 && playbook.steps.length > 0) {
114
+ for (let i = 0; i < playbook.steps.length; i++) {
115
+ const ps = playbook.steps[i];
116
+ const step = { index: i, action: ps.action, status: "pending" };
117
+ const target = typeof ps.target === "string" ? ps.target : ps.target ? JSON.stringify(ps.target) : undefined;
118
+ if (target !== undefined)
119
+ step.target = target;
120
+ if (ps.description !== undefined)
121
+ step.description = ps.description;
122
+ if (ps.text !== undefined)
123
+ step.text = ps.text;
124
+ if (ps.keys)
125
+ step.keys = ps.keys.join("+");
126
+ job.steps.push(step);
127
+ }
128
+ }
129
+ // Build remaining-steps playbook (resume from lastStep+1)
130
+ const resumeIdx = job.lastStep + 1;
131
+ const remainingSteps = playbook.steps.slice(resumeIdx);
132
+ if (remainingSteps.length === 0) {
133
+ this.jobs.transition(job.id, "done");
134
+ return this.finalize(job, start, playbook.steps.length, null);
135
+ }
136
+ const remainingPlaybook = {
137
+ ...playbook,
138
+ id: `${playbook.id}_job_${job.id}`,
139
+ steps: remainingSteps,
140
+ };
141
+ // Create a runtime session if available, focus target app
142
+ let runtimeSessionId = null;
143
+ if (this.config.runtimeService) {
144
+ try {
145
+ const session = await this.config.runtimeService.sessionStart("jobrunner");
146
+ runtimeSessionId = session.sessionId;
147
+ // Focus target app if specified
148
+ if (job.bundleId) {
149
+ await this.config.runtimeService.appFocus({ sessionId: session.sessionId, bundleId: job.bundleId });
150
+ }
151
+ }
152
+ catch (err) {
153
+ this.log(` Warning: failed to create runtime session: ${err instanceof Error ? err.message : String(err)}`);
154
+ }
155
+ }
156
+ const engineSessionId = runtimeSessionId ?? sessionId;
157
+ let stepsCompleted = 0;
158
+ const result = await engine.run(engineSessionId, remainingPlaybook, {
159
+ ...(job.vars ? { vars: job.vars } : {}),
160
+ onStep: (i, step, res) => {
161
+ const globalIdx = resumeIdx + i;
162
+ this.jobs.completeStep(job.id, globalIdx, { durationMs: 0, output: res });
163
+ stepsCompleted++;
164
+ this.log(` Step ${globalIdx}/${playbook.steps.length - 1}: ${step.description ?? step.action} → ${res.substring(0, 200)}`);
165
+ },
166
+ });
167
+ if (result.success) {
168
+ store.recordOutcome(playbook.id, true);
169
+ this.jobs.transition(job.id, "done");
170
+ this.log(`Job ${job.id} → done via playbook engine (${stepsCompleted} steps in ${result.durationMs}ms)`);
171
+ }
172
+ else {
173
+ store.recordOutcome(playbook.id, false);
174
+ const error = result.error ?? `Playbook failed at step ${result.failedAtStep}`;
175
+ // Mark the failed step
176
+ if (result.failedAtStep >= 0) {
177
+ const globalFailIdx = resumeIdx + result.failedAtStep;
178
+ this.jobs.failStep(job.id, globalFailIdx, error);
179
+ }
180
+ // Classify blocker from the error
181
+ const blocker = this.classifyBlocker(error);
182
+ if (blocker === "human") {
183
+ this.jobs.transition(job.id, "waiting_human", { blockReason: error });
184
+ }
185
+ else if (blocker === "transient") {
186
+ this.jobs.transition(job.id, "blocked", { blockReason: error });
187
+ }
188
+ else {
189
+ this.jobs.transition(job.id, "failed", { error });
190
+ }
191
+ this.log(`Job ${job.id} → ${blocker === "human" ? "waiting_human" : blocker === "transient" ? "blocked" : "failed"}: ${error}`);
192
+ }
193
+ return this.finalize(job, start, stepsCompleted, result.success ? null : (result.error ?? null));
194
+ }
195
+ // ── Free-form step execution path ─────────────
196
+ async runFreeFormSteps(job, start) {
197
+ let consecutiveFailures = 0;
198
+ let stepsCompleted = 0;
199
+ let lastError = null;
200
+ const resumeIdx = job.lastStep + 1;
201
+ for (let i = resumeIdx; i < job.steps.length; i++) {
202
+ if (this.stopped) {
203
+ this.log(`Runner stopped — pausing job ${job.id} at step ${i}`);
204
+ break;
205
+ }
206
+ const step = job.steps[i];
207
+ if (step.status === "done" || step.status === "skipped") {
208
+ stepsCompleted++;
209
+ continue;
210
+ }
211
+ // Focus/validate target app before each step
212
+ await this.focusTargetApp(job);
213
+ this.log(` Step ${i}/${job.steps.length - 1}: ${step.description ?? step.action}${step.target ? ` → "${step.target}"` : ""}`);
214
+ const stepStart = Date.now();
215
+ const result = await this.executeStep(step);
216
+ if (result.ok) {
217
+ const stepOpts = { durationMs: Date.now() - stepStart };
218
+ if (result.target)
219
+ stepOpts.output = result.target;
220
+ this.jobs.completeStep(job.id, i, stepOpts);
221
+ stepsCompleted++;
222
+ consecutiveFailures = 0;
223
+ this.log(` ✓ ${result.method} in ${result.durationMs}ms${result.fallbackFrom ? ` (fallback from ${result.fallbackFrom})` : ""}`);
224
+ }
225
+ else {
226
+ consecutiveFailures++;
227
+ lastError = result.error ?? "Unknown error";
228
+ this.jobs.failStep(job.id, i, lastError);
229
+ this.log(` ✗ ${lastError}`);
230
+ // Check for blocker patterns across all errors from the fallback chain
231
+ const { type: blocker, matchedError: blockerError } = this.classifyBlockerFromErrors(this.lastStepErrors);
232
+ if (blocker === "human") {
233
+ const reason = blockerError ?? lastError;
234
+ this.jobs.transition(job.id, "waiting_human", { blockReason: reason });
235
+ this.log(` → waiting_human: ${reason}`);
236
+ return this.finalize(job, start, stepsCompleted, reason);
237
+ }
238
+ if (blocker === "transient") {
239
+ const reason = blockerError ?? lastError;
240
+ this.jobs.transition(job.id, "blocked", { blockReason: reason });
241
+ this.log(` → blocked (transient): ${reason}`);
242
+ return this.finalize(job, start, stepsCompleted, reason);
243
+ }
244
+ // L2-72 fix: Use job's maxRetries if set, otherwise use runner's maxConsecutiveFailures
245
+ const maxFails = job.maxRetries ?? this.config.maxConsecutiveFailures;
246
+ if (consecutiveFailures >= maxFails) {
247
+ this.jobs.transition(job.id, "failed", { error: `${consecutiveFailures} consecutive step failures. Last: ${lastError}` });
248
+ this.log(` → failed: ${consecutiveFailures} consecutive failures`);
249
+ return this.finalize(job, start, stepsCompleted, lastError);
250
+ }
251
+ // L2-72 fix: A failed step blocks subsequent steps — break out of the loop
252
+ // (the job will be marked as failed in the allAttempted check below)
253
+ this.log(` Step failed — stopping execution (${consecutiveFailures}/${maxFails} consecutive failures)`);
254
+ break;
255
+ }
256
+ // Delay between steps
257
+ if (i < job.steps.length - 1) {
258
+ await delay(this.config.stepDelayMs);
259
+ }
260
+ }
261
+ // Check if all steps complete
262
+ const updated = this.jobs.get(job.id);
263
+ if (!updated) {
264
+ return this.finalize(job, start, stepsCompleted, "Job disappeared");
265
+ }
266
+ const allDone = updated.steps.every((s) => s.status === "done" || s.status === "skipped");
267
+ const allAttempted = updated.steps.every((s) => s.status === "done" || s.status === "skipped" || s.status === "failed");
268
+ if (allDone && !this.stopped) {
269
+ this.jobs.transition(job.id, "done");
270
+ this.log(`Job ${job.id} → done (${stepsCompleted} steps in ${Date.now() - start}ms)`);
271
+ }
272
+ else if (allAttempted && !this.stopped) {
273
+ // L2-72 fix: All steps attempted but some failed → mark as failed, not done
274
+ const failedCount = updated.steps.filter((s) => s.status === "failed").length;
275
+ this.jobs.transition(job.id, "failed", { error: `${failedCount} step(s) failed` });
276
+ this.log(`Job ${job.id} → failed with ${failedCount} failed step(s) (${stepsCompleted}/${updated.steps.length} in ${Date.now() - start}ms)`);
277
+ }
278
+ else if (!this.stopped && lastError) {
279
+ // L2-72 fix: Broke out of loop due to a failed step with pending steps remaining
280
+ const failedCount = updated.steps.filter((s) => s.status === "failed").length;
281
+ this.jobs.transition(job.id, "failed", { error: `Step failed, ${updated.steps.length - stepsCompleted - failedCount} step(s) skipped. Last: ${lastError}` });
282
+ this.log(`Job ${job.id} → failed at step (${stepsCompleted}/${updated.steps.length}, ${failedCount} failed)`);
283
+ }
284
+ else if (this.stopped) {
285
+ this.log(`Job ${job.id} paused at step ${updated.lastStep + 1}`);
286
+ }
287
+ return this.finalize(job, start, stepsCompleted, lastError);
288
+ }
289
+ // ── Target app focus ──────────────────────────
290
+ /**
291
+ * Focus the job's target bundleId/windowId before acting.
292
+ * Validates the app is still running. Skips if no bundleId set.
293
+ */
294
+ async focusTargetApp(job) {
295
+ if (!job.bundleId)
296
+ return;
297
+ try {
298
+ // Verify the app is running
299
+ const apps = await this.bridge.call("app.list");
300
+ const target = apps.find((a) => a.bundleId === job.bundleId);
301
+ if (!target) {
302
+ throw new Error(`Target app ${job.bundleId} is not running`);
303
+ }
304
+ // Store resolved PID for use in execClick/execType AX calls
305
+ this.activePid = target.pid;
306
+ // Focus the app
307
+ await this.bridge.call("app.focus", { bundleId: job.bundleId });
308
+ // If windowId specified, validate it exists
309
+ if (job.windowId != null) {
310
+ const wins = await this.bridge.call("app.windows");
311
+ const targetWin = wins.find((w) => w.windowId === job.windowId && w.pid === target.pid);
312
+ if (!targetWin) {
313
+ this.log(` Warning: window ${job.windowId} not found for ${job.bundleId}, using frontmost`);
314
+ }
315
+ }
316
+ }
317
+ catch (err) {
318
+ const msg = err instanceof Error ? err.message : String(err);
319
+ // If the target app is dead, this is fatal — don't silently continue
320
+ // sending keystrokes to the wrong app
321
+ if (msg.includes("is not running")) {
322
+ throw new Error(`Target app killed: ${msg}`);
323
+ }
324
+ // Other focus errors (e.g. window not found) are non-fatal
325
+ this.log(` Warning: focus target app failed: ${msg}`);
326
+ }
327
+ }
328
+ /**
329
+ * Continuous loop: process jobs until stop() is called or queue is empty.
330
+ * Returns when stopped or no more queued jobs.
331
+ */
332
+ async runLoop() {
333
+ const results = [];
334
+ this.stopped = false;
335
+ while (!this.stopped) {
336
+ const result = await this.run();
337
+ if (!result)
338
+ break; // Queue empty
339
+ results.push(result);
340
+ }
341
+ return results;
342
+ }
343
+ /** Signal the runner to stop after the current step. */
344
+ stop() {
345
+ this.stopped = true;
346
+ }
347
+ // ── Session management ──────────────────────────
348
+ async claimSession(job) {
349
+ // If job already has a session, verify it's still valid
350
+ if (job.sessionId) {
351
+ const ok = this.supervisor
352
+ ? this.supervisor.heartbeat(job.sessionId)
353
+ : this.leaseManager.heartbeat(job.sessionId);
354
+ if (ok)
355
+ return job.sessionId;
356
+ // Session expired — claim a new one
357
+ }
358
+ const client = { id: `jobrunner_${job.id}`, type: "jobrunner", startedAt: new Date().toISOString() };
359
+ const app = job.bundleId ?? "com.screenhand.jobrunner";
360
+ const windowId = job.windowId ?? 0;
361
+ try {
362
+ let sessionId = null;
363
+ if (this.supervisor) {
364
+ // Use supervisor path — inherits stall detection + recovery
365
+ const lease = this.supervisor.registerSession(client, app, windowId);
366
+ sessionId = lease?.sessionId ?? null;
367
+ }
368
+ else {
369
+ // Fallback to raw lease manager
370
+ const lease = this.leaseManager.claim(client, app, windowId);
371
+ sessionId = lease?.sessionId ?? null;
372
+ }
373
+ if (!sessionId)
374
+ return null;
375
+ // Bind session to job
376
+ this.jobs.transition(job.id, "running", { sessionId });
377
+ return sessionId;
378
+ }
379
+ catch {
380
+ return null;
381
+ }
382
+ }
383
+ startHeartbeat(sessionId) {
384
+ this.stopHeartbeat();
385
+ this.heartbeatTimer = setInterval(() => {
386
+ if (this.supervisor) {
387
+ this.supervisor.heartbeat(sessionId);
388
+ }
389
+ else {
390
+ this.leaseManager.heartbeat(sessionId);
391
+ }
392
+ }, this.config.heartbeatMs);
393
+ }
394
+ stopHeartbeat() {
395
+ if (this.heartbeatTimer) {
396
+ clearInterval(this.heartbeatTimer);
397
+ this.heartbeatTimer = null;
398
+ }
399
+ }
400
+ releaseSession(sessionId) {
401
+ try {
402
+ if (this.supervisor) {
403
+ this.supervisor.releaseSession(sessionId);
404
+ }
405
+ else {
406
+ this.leaseManager.release(sessionId);
407
+ }
408
+ }
409
+ catch {
410
+ // Best-effort
411
+ }
412
+ }
413
+ // ── Step execution ──────────────────────────────
414
+ /** All errors collected during the last executeStep call (across fallback methods). */
415
+ lastStepErrors = [];
416
+ async executeStep(step) {
417
+ const actionType = this.mapActionType(step.action);
418
+ const infra = { hasBridge: true, hasCDP: this.config.hasCDP };
419
+ const plan = planExecution(actionType, infra);
420
+ this.lastStepErrors = [];
421
+ if (plan.length === 0) {
422
+ return { ok: false, method: "ax", durationMs: 0, fallbackFrom: null, retries: 0, error: `No execution method available for "${step.action}"`, target: step.target ?? null };
423
+ }
424
+ return executeWithFallback(step.action, plan, DEFAULT_RETRY_POLICY, async (method, attempt) => {
425
+ const result = await this.executeViaMethod(method, step, attempt);
426
+ if (!result.ok && result.error)
427
+ this.lastStepErrors.push(result.error);
428
+ return result;
429
+ });
430
+ }
431
+ async executeViaMethod(method, step, attempt) {
432
+ const start = Date.now();
433
+ const target = step.target ?? null;
434
+ try {
435
+ switch (step.action) {
436
+ case "click":
437
+ case "press":
438
+ return await this.execClick(method, target, start, attempt);
439
+ case "type_text":
440
+ case "type_into":
441
+ case "type":
442
+ return await this.execType(method, target, step.text ?? step.description ?? "", start, attempt);
443
+ case "navigate":
444
+ return await this.execNavigate(target, start, attempt);
445
+ case "screenshot":
446
+ return await this.execScreenshot(start, attempt);
447
+ case "scroll":
448
+ return await this.execScroll(method, step.description ?? "down", start, attempt);
449
+ case "wait":
450
+ await delay(1000);
451
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: "wait" };
452
+ case "key_combo":
453
+ case "key":
454
+ return await this.execKey(step.keys ?? target ?? "", start, attempt);
455
+ case "read":
456
+ case "extract":
457
+ return await this.execRead(method, target, start, attempt);
458
+ case "focus":
459
+ case "launch":
460
+ return await this.execFocus(target, start, attempt);
461
+ case "browser_js":
462
+ return await this.execBrowserJs(step.description ?? "", start, attempt);
463
+ case "cdp_key_event":
464
+ return await this.execCdpKeyEvent(step.keys ?? "", start, attempt);
465
+ default:
466
+ // Try as a generic click on the target text
467
+ if (target)
468
+ return await this.execClick(method, target, start, attempt);
469
+ return { ok: false, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: `Unknown action: ${step.action}`, target };
470
+ }
471
+ }
472
+ catch (err) {
473
+ return { ok: false, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: err instanceof Error ? err.message : String(err), target };
474
+ }
475
+ }
476
+ // ── Bridge execution methods ────────────────────
477
+ async execClick(method, target, start, attempt) {
478
+ if (!target)
479
+ return { ok: false, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: "Click requires a target", target };
480
+ switch (method) {
481
+ case "ax": {
482
+ const found = await this.bridge.call("ax.findElement", { pid: this.activePid, title: target, exact: false });
483
+ await this.bridge.call("ax.performAction", { pid: this.activePid, elementPath: found.elementPath, action: "AXPress" });
484
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target };
485
+ }
486
+ case "cdp": {
487
+ if (!this.config.cdpConnect)
488
+ throw new Error("CDP not available");
489
+ const client = await this.config.cdpConnect();
490
+ try {
491
+ const evalResult = await client.Runtime.evaluate({
492
+ expression: `(() => { const el = Array.from(document.querySelectorAll('*')).find(e => e.textContent?.trim() === ${JSON.stringify(target)} || e.getAttribute('aria-label') === ${JSON.stringify(target)}); if (el) { el.click(); return 'clicked'; } return null; })()`,
493
+ returnByValue: true,
494
+ });
495
+ if (evalResult.result?.value !== "clicked")
496
+ throw new Error("Element not found via CDP");
497
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target };
498
+ }
499
+ finally {
500
+ await client.close();
501
+ }
502
+ }
503
+ case "ocr": {
504
+ const shot = await this.bridge.call("cg.captureScreen", {});
505
+ const matches = await this.bridge.call("vision.findText", { imagePath: shot.path, searchText: target });
506
+ const match = Array.isArray(matches) ? matches[0] : null;
507
+ if (!match?.bounds)
508
+ throw new Error("Target not found via OCR");
509
+ const x = match.bounds.x + match.bounds.width / 2;
510
+ const y = match.bounds.y + match.bounds.height / 2;
511
+ await this.bridge.call("cg.mouseClick", { x, y });
512
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target };
513
+ }
514
+ case "coordinates": {
515
+ // Can't click by text with coordinates alone — need a prior locate
516
+ throw new Error("Coordinate click requires explicit x,y — not available for text target");
517
+ }
518
+ }
519
+ throw new Error(`Unknown method: ${method}`);
520
+ }
521
+ async execType(method, target, text, start, attempt) {
522
+ switch (method) {
523
+ case "ax": {
524
+ if (target) {
525
+ const found = await this.bridge.call("ax.findElement", { pid: this.activePid, title: target, exact: false });
526
+ await this.bridge.call("ax.setElementValue", { pid: this.activePid, elementPath: found.elementPath, value: text });
527
+ }
528
+ else {
529
+ // Type into focused element via key events — use pid for targeting
530
+ await this.bridge.call("cg.typeText", { text, pid: this.activePid });
531
+ }
532
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target };
533
+ }
534
+ case "cdp": {
535
+ if (!this.config.cdpConnect)
536
+ throw new Error("CDP not available");
537
+ const client = await this.config.cdpConnect();
538
+ try {
539
+ if (target) {
540
+ const evalResult = await client.Runtime.evaluate({
541
+ expression: `(() => { const el = Array.from(document.querySelectorAll('input, textarea, [contenteditable]')).find(e => e.getAttribute('placeholder') === ${JSON.stringify(target)} || e.getAttribute('aria-label') === ${JSON.stringify(target)} || e.getAttribute('name') === ${JSON.stringify(target)}); if (el) { el.focus(); return true; } return false; })()`,
542
+ returnByValue: true,
543
+ });
544
+ if (!evalResult.result?.value)
545
+ throw new Error("Field not found via CDP");
546
+ }
547
+ for (const char of text) {
548
+ await client.Input.dispatchKeyEvent({ type: "keyDown", key: char, text: char });
549
+ await client.Input.dispatchKeyEvent({ type: "keyUp", key: char });
550
+ }
551
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target };
552
+ }
553
+ finally {
554
+ await client.close();
555
+ }
556
+ }
557
+ }
558
+ throw new Error(`Method ${method} does not support type`);
559
+ }
560
+ async execNavigate(url, start, attempt) {
561
+ if (!url)
562
+ return { ok: false, method: "ax", durationMs: 0, fallbackFrom: null, retries: attempt, error: "Navigate requires a URL target", target: null };
563
+ // L2-74 fix: Block dangerous URL protocols (mirrors L2-71 fix in mcp-desktop.ts)
564
+ const BLOCKED_PROTOCOLS = ["javascript:", "data:", "blob:", "vbscript:"];
565
+ const urlLower = url.trim().toLowerCase();
566
+ for (const proto of BLOCKED_PROTOCOLS) {
567
+ if (urlLower.startsWith(proto)) {
568
+ return { ok: false, method: "ax", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: `Blocked: "${proto}" URLs are not allowed for security reasons`, target: url };
569
+ }
570
+ }
571
+ if (this.config.cdpConnect) {
572
+ const client = await this.config.cdpConnect();
573
+ try {
574
+ await client.Runtime.evaluate({ expression: `window.location.href = ${JSON.stringify(url)}` });
575
+ return { ok: true, method: "cdp", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: url };
576
+ }
577
+ finally {
578
+ await client.close();
579
+ }
580
+ }
581
+ // Fallback: try bridge openURL, then macOS `open` command
582
+ try {
583
+ await this.bridge.call("app.openURL", { url });
584
+ return { ok: true, method: "ax", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: url };
585
+ }
586
+ catch {
587
+ const { execSync } = await import("node:child_process");
588
+ execSync(`open ${JSON.stringify(url)}`, { timeout: 10_000 });
589
+ return { ok: true, method: "ax", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: url };
590
+ }
591
+ }
592
+ async execScreenshot(start, attempt) {
593
+ const shot = await this.bridge.call("cg.captureScreen", {});
594
+ return { ok: true, method: "ax", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: shot.path };
595
+ }
596
+ async execScroll(method, direction, start, attempt) {
597
+ const amount = 300;
598
+ const deltaX = direction === "left" ? -amount : direction === "right" ? amount : 0;
599
+ const deltaY = direction === "up" ? -amount : direction === "down" ? amount : 0;
600
+ switch (method) {
601
+ case "ax":
602
+ case "coordinates":
603
+ await this.bridge.call("cg.scroll", { deltaX, deltaY });
604
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: `${direction} ${amount}px` };
605
+ case "cdp": {
606
+ if (!this.config.cdpConnect)
607
+ throw new Error("CDP not available");
608
+ const client = await this.config.cdpConnect();
609
+ try {
610
+ await client.Runtime.evaluate({ expression: `window.scrollBy(${deltaX}, ${deltaY})` });
611
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: `${direction} ${amount}px` };
612
+ }
613
+ finally {
614
+ await client.close();
615
+ }
616
+ }
617
+ }
618
+ throw new Error(`Method ${method} does not support scroll`);
619
+ }
620
+ async execFocus(bundleId, start, attempt) {
621
+ if (!bundleId)
622
+ return { ok: false, method: "ax", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: "Focus/launch requires a bundleId target", target: null };
623
+ try {
624
+ await this.bridge.call("app.focus", { bundleId });
625
+ return { ok: true, method: "ax", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: bundleId };
626
+ }
627
+ catch {
628
+ // Fallback: try launch (app might not be running)
629
+ try {
630
+ await this.bridge.call("app.launch", { bundleId });
631
+ return { ok: true, method: "ax", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: bundleId };
632
+ }
633
+ catch (err) {
634
+ return { ok: false, method: "ax", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: err instanceof Error ? err.message : String(err), target: bundleId };
635
+ }
636
+ }
637
+ }
638
+ async execKey(keys, start, attempt) {
639
+ // keys is a "+" separated combo like "cmd+a"
640
+ const parts = keys.split("+").map((k) => k.trim());
641
+ await this.bridge.call("cg.keyCombo", { keys: parts });
642
+ return { ok: true, method: "ax", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: keys };
643
+ }
644
+ async execRead(method, target, start, attempt) {
645
+ switch (method) {
646
+ case "ax": {
647
+ if (target) {
648
+ const found = await this.bridge.call("ax.findElement", { pid: 0, title: target, exact: false });
649
+ const val = await this.bridge.call("ax.getElementValue", { pid: 0, elementPath: found.elementPath });
650
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: val.value ?? "" };
651
+ }
652
+ const tree = await this.bridge.call("ax.getElementTree", { pid: 0, maxDepth: 4 });
653
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: tree.description ?? "" };
654
+ }
655
+ case "cdp": {
656
+ if (!this.config.cdpConnect)
657
+ throw new Error("CDP not available");
658
+ const client = await this.config.cdpConnect();
659
+ try {
660
+ if (target) {
661
+ const evalResult = await client.Runtime.evaluate({
662
+ expression: `(() => { const el = Array.from(document.querySelectorAll('*')).find(e => e.getAttribute('aria-label') === ${JSON.stringify(target)} || e.textContent?.trim() === ${JSON.stringify(target)}); return el ? (el.value ?? el.textContent ?? '').trim() : null; })()`,
663
+ returnByValue: true,
664
+ });
665
+ if (evalResult.result?.value == null)
666
+ throw new Error("Element not found via CDP");
667
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: String(evalResult.result.value) };
668
+ }
669
+ const evalResult = await client.Runtime.evaluate({
670
+ expression: "document.body?.innerText?.slice(0, 4000) ?? ''",
671
+ returnByValue: true,
672
+ });
673
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: String(evalResult.result?.value ?? "") };
674
+ }
675
+ finally {
676
+ await client.close();
677
+ }
678
+ }
679
+ case "ocr": {
680
+ const shot = await this.bridge.call("cg.captureScreen", {});
681
+ if (target) {
682
+ const matches = await this.bridge.call("vision.findText", { imagePath: shot.path, searchText: target });
683
+ const match = Array.isArray(matches) ? matches[0] : null;
684
+ if (!match)
685
+ throw new Error("Text not found via OCR");
686
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: match.text };
687
+ }
688
+ const ocr = await this.bridge.call("vision.ocr", { imagePath: shot.path });
689
+ return { ok: true, method, durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: ocr.text?.slice(0, 4000) ?? "" };
690
+ }
691
+ }
692
+ throw new Error(`Method ${method} does not support read`);
693
+ }
694
+ async execBrowserJs(code, start, attempt) {
695
+ if (!this.config.cdpConnect)
696
+ throw new Error("browser_js requires CDP");
697
+ const client = await this.config.cdpConnect();
698
+ try {
699
+ const result = await client.Runtime.evaluate({
700
+ expression: code,
701
+ awaitPromise: true,
702
+ returnByValue: true,
703
+ });
704
+ if (result.exceptionDetails) {
705
+ throw new Error(`JS Error: ${result.exceptionDetails.text ?? result.exceptionDetails.exception?.description ?? "unknown"}`);
706
+ }
707
+ return { ok: true, method: "cdp", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: String(result.result?.value ?? "") };
708
+ }
709
+ finally {
710
+ await client.close();
711
+ }
712
+ }
713
+ async execCdpKeyEvent(keys, start, attempt) {
714
+ if (!this.config.cdpConnect)
715
+ throw new Error("cdp_key_event requires CDP");
716
+ const client = await this.config.cdpConnect();
717
+ try {
718
+ // Parse "mod4+Enter" format or raw key
719
+ const parts = keys.split("+").map(k => k.trim());
720
+ let modifiers = 0;
721
+ let key = parts[parts.length - 1] ?? "";
722
+ for (const p of parts.slice(0, -1)) {
723
+ if (p.startsWith("mod"))
724
+ modifiers = parseInt(p.replace("mod", ""), 10) || 0;
725
+ if (p === "Meta" || p === "Cmd")
726
+ modifiers = 4;
727
+ if (p === "Shift")
728
+ modifiers |= 8;
729
+ if (p === "Ctrl")
730
+ modifiers |= 2;
731
+ if (p === "Alt")
732
+ modifiers |= 1;
733
+ }
734
+ const keyCode = key === "Enter" ? 13 : key === "Tab" ? 9 : key === "Escape" ? 27 : 0;
735
+ const baseParams = { key, code: key, modifiers, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode };
736
+ await client.Input.dispatchKeyEvent({ type: "keyDown", ...baseParams });
737
+ await client.Input.dispatchKeyEvent({ type: "keyUp", ...baseParams });
738
+ return { ok: true, method: "cdp", durationMs: Date.now() - start, fallbackFrom: null, retries: attempt, error: null, target: keys };
739
+ }
740
+ finally {
741
+ await client.close();
742
+ }
743
+ }
744
+ // ── Blocker classification ──────────────────────
745
+ /** Check a single error string for blocker patterns. */
746
+ classifyBlocker(error) {
747
+ const lower = error.toLowerCase();
748
+ for (const pattern of HUMAN_BLOCKER_PATTERNS) {
749
+ if (lower.includes(pattern))
750
+ return "human";
751
+ }
752
+ for (const pattern of TRANSIENT_BLOCKER_PATTERNS) {
753
+ if (lower.includes(pattern))
754
+ return "transient";
755
+ }
756
+ return null;
757
+ }
758
+ /** Check all errors from a fallback chain — return the highest-priority blocker found with the matched error. */
759
+ classifyBlockerFromErrors(errors) {
760
+ let transientError = null;
761
+ for (const err of errors) {
762
+ const result = this.classifyBlocker(err);
763
+ if (result === "human")
764
+ return { type: "human", matchedError: err };
765
+ if (result === "transient" && !transientError)
766
+ transientError = err;
767
+ }
768
+ if (transientError)
769
+ return { type: "transient", matchedError: transientError };
770
+ return { type: null, matchedError: null };
771
+ }
772
+ // ── Helpers ─────────────────────────────────────
773
+ mapActionType(action) {
774
+ switch (action) {
775
+ case "click":
776
+ case "press": return "click";
777
+ case "type_text":
778
+ case "type_into":
779
+ case "type": return "type";
780
+ case "read":
781
+ case "extract": return "read";
782
+ case "scroll": return "scroll";
783
+ case "navigate":
784
+ case "screenshot":
785
+ case "wait":
786
+ case "key_combo":
787
+ case "key":
788
+ return "click"; // These don't go through the fallback chain — handled specially
789
+ default: return "click";
790
+ }
791
+ }
792
+ finalize(job, start, stepsCompleted, lastError) {
793
+ const updated = this.jobs.get(job.id);
794
+ return {
795
+ jobId: job.id,
796
+ finalState: updated?.state ?? "failed",
797
+ stepsCompleted,
798
+ totalSteps: job.steps.length,
799
+ durationMs: Date.now() - start,
800
+ error: lastError,
801
+ };
802
+ }
803
+ }
804
+ function delay(ms) {
805
+ return new Promise((resolve) => setTimeout(resolve, ms));
806
+ }