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.
- package/README.md +193 -109
- package/bin/darwin-arm64/macos-bridge +0 -0
- package/dist/mcp-desktop.js +5876 -0
- package/dist/scripts/codex-monitor-daemon.js +335 -0
- package/dist/scripts/export-help-center.js +112 -0
- package/dist/scripts/marketing-loop.js +117 -0
- package/dist/scripts/observer-daemon.js +288 -0
- package/dist/scripts/orchestrator-daemon.js +399 -0
- package/dist/scripts/supervisor-daemon.js +272 -0
- package/dist/scripts/threads-campaign.js +208 -0
- package/dist/scripts/worker-daemon.js +228 -0
- package/dist/src/agent/cli.js +82 -0
- package/dist/src/agent/loop.js +274 -0
- package/dist/src/community/fetcher.js +109 -0
- package/dist/src/community/index.js +6 -0
- package/dist/src/community/publisher.js +191 -0
- package/dist/src/community/remote-api.js +121 -0
- package/dist/src/community/types.js +3 -0
- package/dist/src/community/validator.js +95 -0
- package/{src/config.ts → dist/src/config.js} +5 -10
- package/dist/src/context-tracker.js +489 -0
- package/{src/index.ts → dist/src/index.js} +32 -52
- package/dist/src/ingestion/coverage-auditor.js +233 -0
- package/dist/src/ingestion/doc-parser.js +164 -0
- package/dist/src/ingestion/index.js +8 -0
- package/dist/src/ingestion/menu-scanner.js +152 -0
- package/dist/src/ingestion/reference-merger.js +186 -0
- package/dist/src/ingestion/shortcut-extractor.js +180 -0
- package/dist/src/ingestion/tutorial-extractor.js +170 -0
- package/dist/src/ingestion/types.js +3 -0
- package/dist/src/jobs/manager.js +305 -0
- package/dist/src/jobs/runner.js +806 -0
- package/dist/src/jobs/store.js +102 -0
- package/dist/src/jobs/types.js +30 -0
- package/dist/src/jobs/worker.js +97 -0
- package/dist/src/learning/engine.js +356 -0
- package/dist/src/learning/index.js +9 -0
- package/dist/src/learning/locator-policy.js +120 -0
- package/dist/src/learning/pattern-policy.js +89 -0
- package/dist/src/learning/recovery-policy.js +116 -0
- package/dist/src/learning/sensor-policy.js +115 -0
- package/dist/src/learning/timing-model.js +204 -0
- package/dist/src/learning/topology-policy.js +90 -0
- package/dist/src/learning/types.js +9 -0
- package/dist/src/logging/timeline-logger.js +48 -0
- package/dist/src/mcp/mcp-stdio-server.js +464 -0
- package/dist/src/mcp/server.js +363 -0
- package/dist/src/mcp-entry.js +60 -0
- package/dist/src/memory/playbook-seeds.js +200 -0
- package/dist/src/memory/recall.js +222 -0
- package/dist/src/memory/research.js +104 -0
- package/dist/src/memory/seeds.js +101 -0
- package/dist/src/memory/service.js +446 -0
- package/dist/src/memory/session.js +169 -0
- package/dist/src/memory/store.js +451 -0
- package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
- package/dist/src/monitor/codex-monitor.js +382 -0
- package/dist/src/monitor/task-queue.js +97 -0
- package/dist/src/monitor/types.js +62 -0
- package/dist/src/native/bridge-client.js +412 -0
- package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
- package/dist/src/observer/state.js +199 -0
- package/dist/src/observer/types.js +43 -0
- package/dist/src/orchestrator/state.js +68 -0
- package/dist/src/orchestrator/types.js +22 -0
- package/dist/src/perception/ax-source.js +162 -0
- package/dist/src/perception/cdp-source.js +162 -0
- package/dist/src/perception/coordinator.js +771 -0
- package/dist/src/perception/frame-differ.js +287 -0
- package/dist/src/perception/index.js +22 -0
- package/dist/src/perception/manager.js +199 -0
- package/dist/src/perception/types.js +47 -0
- package/dist/src/perception/vision-source.js +399 -0
- package/dist/src/planner/deterministic.js +298 -0
- package/dist/src/planner/executor.js +870 -0
- package/dist/src/planner/goal-store.js +92 -0
- package/dist/src/planner/index.js +21 -0
- package/dist/src/planner/planner.js +520 -0
- package/dist/src/planner/tool-registry.js +71 -0
- package/dist/src/planner/types.js +22 -0
- package/dist/src/platform/explorer.js +213 -0
- package/dist/src/platform/help-center-markdown.js +527 -0
- package/dist/src/platform/learner.js +257 -0
- package/dist/src/playbook/engine.js +486 -0
- package/dist/src/playbook/index.js +20 -0
- package/dist/src/playbook/mcp-recorder.js +204 -0
- package/dist/src/playbook/recorder.js +536 -0
- package/dist/src/playbook/runner.js +408 -0
- package/dist/src/playbook/store.js +312 -0
- package/dist/src/playbook/types.js +17 -0
- package/dist/src/recovery/detectors.js +156 -0
- package/dist/src/recovery/engine.js +327 -0
- package/dist/src/recovery/index.js +20 -0
- package/dist/src/recovery/strategies.js +274 -0
- package/dist/src/recovery/types.js +20 -0
- package/dist/src/runtime/accessibility-adapter.js +430 -0
- package/dist/src/runtime/app-adapter.js +64 -0
- package/dist/src/runtime/applescript-adapter.js +305 -0
- package/dist/src/runtime/ax-role-map.js +96 -0
- package/dist/src/runtime/browser-adapter.js +52 -0
- package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
- package/dist/src/runtime/composite-adapter.js +221 -0
- package/dist/src/runtime/execution-contract.js +159 -0
- package/dist/src/runtime/executor.js +286 -0
- package/dist/src/runtime/locator-cache.js +50 -0
- package/dist/src/runtime/planning-loop.js +63 -0
- package/dist/src/runtime/service.js +432 -0
- package/dist/src/runtime/session-manager.js +63 -0
- package/dist/src/runtime/state-observer.js +121 -0
- package/dist/src/runtime/vision-adapter.js +225 -0
- package/dist/src/state/app-map-types.js +72 -0
- package/dist/src/state/app-map.js +1974 -0
- package/dist/src/state/entity-tracker.js +108 -0
- package/dist/src/state/fusion.js +96 -0
- package/dist/src/state/index.js +21 -0
- package/dist/src/state/ladder-generator.js +236 -0
- package/dist/src/state/persistence.js +156 -0
- package/dist/src/state/types.js +17 -0
- package/dist/src/state/world-model.js +1456 -0
- package/dist/src/supervisor/locks.js +186 -0
- package/dist/src/supervisor/supervisor.js +403 -0
- package/dist/src/supervisor/types.js +30 -0
- package/dist/src/test-mcp-protocol.js +154 -0
- package/dist/src/types.js +17 -0
- package/dist/src/util/atomic-write.js +133 -0
- package/dist/src/util/sanitize.js +146 -0
- package/dist-app-maps/com.figma.Desktop.json +959 -0
- package/dist-app-maps/com.hnc.Discord.json +1146 -0
- package/dist-app-maps/notion.id.json +2831 -0
- package/dist-playbooks/canva-screenhand-carousel.json +445 -0
- package/dist-playbooks/codex-desktop.json +76 -0
- package/dist-playbooks/competitor-research-stack.json +122 -0
- package/dist-playbooks/davinci-color-grade.json +153 -0
- package/dist-playbooks/davinci-edit-timeline.json +162 -0
- package/dist-playbooks/davinci-render.json +114 -0
- package/dist-playbooks/devto.json +52 -0
- package/dist-playbooks/discord.json +41 -0
- package/dist-playbooks/google-flow-create-project.json +59 -0
- package/dist-playbooks/google-flow-edit-image.json +90 -0
- package/dist-playbooks/google-flow-edit-video.json +90 -0
- package/dist-playbooks/google-flow-generate-image.json +68 -0
- package/dist-playbooks/google-flow-generate-video.json +191 -0
- package/dist-playbooks/google-flow-open-project.json +48 -0
- package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
- package/dist-playbooks/google-flow-search-assets.json +64 -0
- package/dist-playbooks/instagram.json +57 -0
- package/dist-playbooks/linkedin.json +52 -0
- package/dist-playbooks/n8n.json +43 -0
- package/dist-playbooks/reddit.json +52 -0
- package/dist-playbooks/threads.json +59 -0
- package/dist-playbooks/x-twitter.json +59 -0
- package/dist-playbooks/youtube.json +59 -0
- package/dist-references/canva.json +646 -0
- package/dist-references/codex-desktop.json +305 -0
- package/dist-references/davinci-resolve-keyboard.json +594 -0
- package/dist-references/davinci-resolve-menu-map.json +1139 -0
- package/dist-references/davinci-resolve-menus-batch1.json +116 -0
- package/dist-references/davinci-resolve-menus-batch2.json +372 -0
- package/dist-references/davinci-resolve-menus-batch3.json +330 -0
- package/dist-references/davinci-resolve-menus-batch4.json +297 -0
- package/dist-references/davinci-resolve-shortcuts.json +333 -0
- package/dist-references/devto.json +317 -0
- package/dist-references/discord.json +549 -0
- package/dist-references/figma.json +1186 -0
- package/dist-references/finder.json +146 -0
- package/dist-references/google-ads-transparency.json +95 -0
- package/dist-references/google-flow.json +649 -0
- package/dist-references/instagram.json +341 -0
- package/dist-references/linkedin.json +324 -0
- package/dist-references/meta-ad-library.json +86 -0
- package/dist-references/n8n.json +387 -0
- package/dist-references/notes.json +27 -0
- package/dist-references/notion.json +163 -0
- package/dist-references/reddit.json +341 -0
- package/dist-references/threads.json +337 -0
- package/dist-references/x-twitter.json +403 -0
- package/dist-references/youtube.json +373 -0
- package/native/macos-bridge/Package.swift +1 -0
- package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
- package/native/macos-bridge/Sources/AppManagement.swift +212 -2
- package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
- package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
- package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
- package/native/macos-bridge/Sources/main.swift +169 -16
- package/native/windows-bridge/Program.cs +5 -0
- package/native/windows-bridge/ScreenCapture.cs +124 -0
- package/package.json +29 -4
- package/scripts/postinstall.cjs +127 -0
- package/.claude/commands/automate.md +0 -28
- package/.claude/commands/debug-ui.md +0 -19
- package/.claude/commands/screenshot.md +0 -15
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/.mcp.json +0 -8
- package/DESKTOP_MCP_GUIDE.md +0 -92
- package/SECURITY.md +0 -44
- package/docs/architecture.md +0 -47
- package/install-skills.sh +0 -19
- package/mcp-bridge.ts +0 -271
- package/mcp-desktop.ts +0 -1221
- package/playbooks/instagram.json +0 -41
- package/playbooks/instagram_v2.json +0 -201
- package/playbooks/x_v1.json +0 -211
- package/scripts/devpost-live-loop.mjs +0 -421
- package/src/logging/timeline-logger.ts +0 -55
- package/src/mcp/server.ts +0 -449
- package/src/memory/recall.ts +0 -191
- package/src/memory/research.ts +0 -146
- package/src/memory/seeds.ts +0 -123
- package/src/memory/session.ts +0 -201
- package/src/memory/store.ts +0 -434
- package/src/memory/types.ts +0 -69
- package/src/native/bridge-client.ts +0 -239
- package/src/runtime/accessibility-adapter.ts +0 -487
- package/src/runtime/app-adapter.ts +0 -169
- package/src/runtime/applescript-adapter.ts +0 -376
- package/src/runtime/ax-role-map.ts +0 -102
- package/src/runtime/browser-adapter.ts +0 -129
- package/src/runtime/cdp-chrome-adapter.ts +0 -676
- package/src/runtime/composite-adapter.ts +0 -274
- package/src/runtime/executor.ts +0 -396
- package/src/runtime/planning-loop.ts +0 -81
- package/src/runtime/service.ts +0 -448
- package/src/runtime/session-manager.ts +0 -50
- package/src/runtime/state-observer.ts +0 -136
- package/src/runtime/vision-adapter.ts +0 -297
- package/src/types.ts +0 -297
- package/tests/bridge-client.test.ts +0 -176
- package/tests/browser-stealth.test.ts +0 -210
- package/tests/composite-adapter.test.ts +0 -64
- package/tests/mcp-server.test.ts +0 -151
- package/tests/memory-recall.test.ts +0 -339
- package/tests/memory-research.test.ts +0 -159
- package/tests/memory-seeds.test.ts +0 -120
- package/tests/memory-store.test.ts +0 -392
- package/tests/types.test.ts +0 -92
- package/tsconfig.check.json +0 -17
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -8
- /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
|
+
}
|