sf-builder-agent 0.7.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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * SSE client — listens for browser-automation commands from the server.
3
+ */
4
+ import { EventSource } from "eventsource";
5
+ export function connectSSE(baseUrl, sessionId, token, onCommand, onError) {
6
+ const url = `${baseUrl}/api/sessions/${sessionId}/agent/commands?token=${encodeURIComponent(token)}`;
7
+ let es = null;
8
+ let closed = false;
9
+ let retryDelay = 1_000;
10
+ const MAX_RETRY = 30_000;
11
+ function connect() {
12
+ if (closed)
13
+ return;
14
+ es = new EventSource(url);
15
+ es.addEventListener("command", (evt) => {
16
+ try {
17
+ const data = JSON.parse(evt.data);
18
+ onCommand(data);
19
+ // Reset backoff on successful message
20
+ retryDelay = 1_000;
21
+ }
22
+ catch (err) {
23
+ console.error("\u274C Failed to parse command:", err instanceof Error ? err.message : err);
24
+ }
25
+ });
26
+ es.addEventListener("ping", () => {
27
+ // Keep-alive — nothing to do
28
+ });
29
+ es.addEventListener("open", () => {
30
+ console.log("\u2705 Connected to command stream");
31
+ retryDelay = 1_000;
32
+ });
33
+ es.onerror = (err) => {
34
+ if (closed)
35
+ return;
36
+ const message = err && typeof err === "object" && "message" in err
37
+ ? err.message
38
+ : "SSE connection error";
39
+ console.error(`\u274C SSE error: ${message} — reconnecting in ${retryDelay / 1_000}s`);
40
+ onError(new Error(message));
41
+ // Close the broken connection and schedule reconnect
42
+ es?.close();
43
+ es = null;
44
+ setTimeout(() => {
45
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY);
46
+ connect();
47
+ }, retryDelay);
48
+ };
49
+ }
50
+ connect();
51
+ return {
52
+ close() {
53
+ closed = true;
54
+ es?.close();
55
+ es = null;
56
+ },
57
+ };
58
+ }
59
+ //# sourceMappingURL=sse-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse-client.js","sourceRoot":"","sources":["../src/sse-client.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAS1C,MAAM,UAAU,UAAU,CACxB,OAAe,EACf,SAAiB,EACjB,KAAa,EACb,SAA6C,EAC7C,OAA+B;IAE/B,MAAM,GAAG,GAAG,GAAG,OAAO,iBAAiB,SAAS,yBAAyB,kBAAkB,CAAC,KAAK,CAAC,EAAE,CAAC;IAErG,IAAI,EAAE,GAAuB,IAAI,CAAC;IAClC,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,IAAI,UAAU,GAAG,KAAK,CAAC;IACvB,MAAM,SAAS,GAAG,MAAM,CAAC;IAEzB,SAAS,OAAO;QACd,IAAI,MAAM;YAAE,OAAO;QAEnB,EAAE,GAAG,IAAI,WAAW,CAAC,GAAG,CAAC,CAAC;QAE1B,EAAE,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,GAAiB,EAAE,EAAE;YACnD,IAAI,CAAC;gBACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAoB,CAAC;gBACrD,SAAS,CAAC,IAAI,CAAC,CAAC;gBAChB,sCAAsC;gBACtC,UAAU,GAAG,KAAK,CAAC;YACrB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,OAAO,CAAC,KAAK,CACX,iCAAiC,EACjC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CACzC,CAAC;YACJ,CAAC;QACH,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE;YAC/B,6BAA6B;QAC/B,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE;YAC/B,OAAO,CAAC,GAAG,CAAC,oCAAoC,CAAC,CAAC;YAClD,UAAU,GAAG,KAAK,CAAC;QACrB,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,OAAO,GAAG,CAAC,GAAU,EAAE,EAAE;YAC1B,IAAI,MAAM;gBAAE,OAAO;YAEnB,MAAM,OAAO,GACX,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,SAAS,IAAI,GAAG;gBAChD,CAAC,CAAE,GAAsC,CAAC,OAAO;gBACjD,CAAC,CAAC,sBAAsB,CAAC;YAE7B,OAAO,CAAC,KAAK,CAAC,qBAAqB,OAAO,uBAAuB,UAAU,GAAG,KAAK,GAAG,CAAC,CAAC;YACxF,OAAO,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;YAE5B,qDAAqD;YACrD,EAAE,EAAE,KAAK,EAAE,CAAC;YACZ,EAAE,GAAG,IAAI,CAAC;YAEV,UAAU,CAAC,GAAG,EAAE;gBACd,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,GAAG,CAAC,EAAE,SAAS,CAAC,CAAC;gBACjD,OAAO,EAAE,CAAC;YACZ,CAAC,EAAE,UAAU,CAAC,CAAC;QACjB,CAAC,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,CAAC;IAEV,OAAO;QACL,KAAK;YACH,MAAM,GAAG,IAAI,CAAC;YACd,EAAE,EAAE,KAAK,EAAE,CAAC;YACZ,EAAE,GAAG,IAAI,CAAC;QACZ,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "sf-builder-agent",
3
+ "version": "0.7.0",
4
+ "description": "Desktop agent for SalesForce Agent Creator — executes browser automation commands",
5
+ "type": "module",
6
+ "bin": {
7
+ "sf-builder-agent": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "start": "node dist/index.js",
12
+ "dev": "tsx src/index.ts"
13
+ },
14
+ "dependencies": {
15
+ "puppeteer": "^23.0.0",
16
+ "eventsource": "^3.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.0.0",
20
+ "typescript": "^5.8.0",
21
+ "tsx": "^4.19.0"
22
+ }
23
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Browser — Puppeteer launcher and command executor.
3
+ *
4
+ * Handles all CommandType operations including shadow-DOM traversal,
5
+ * label-based element lookup, and screenshot capture.
6
+ */
7
+
8
+ import puppeteer, { type Browser, type Page, type ElementHandle } from "puppeteer";
9
+
10
+ // ─── Launch ──────────────────────────────────────────────────────────
11
+
12
+ export async function launchBrowser(): Promise<{ browser: Browser; page: Page }> {
13
+ const browser = await puppeteer.launch({
14
+ headless: false,
15
+ defaultViewport: { width: 1440, height: 900 },
16
+ args: [
17
+ "--start-maximized",
18
+ "--disable-blink-features=AutomationControlled",
19
+ ],
20
+ });
21
+
22
+ const pages = await browser.pages();
23
+ const page = pages[0] ?? (await browser.newPage());
24
+
25
+ return { browser, page };
26
+ }
27
+
28
+ // ─── Helpers ─────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Walk through shadow roots and resolve the final selector.
32
+ */
33
+ async function findElement(
34
+ page: Page,
35
+ selector: string,
36
+ shadowPath?: string[]
37
+ ): Promise<ElementHandle<Element>> {
38
+ if (shadowPath && shadowPath.length > 0) {
39
+ // Walk shadow DOM tree
40
+ let root = await page.evaluateHandle(() => document as unknown as Element);
41
+
42
+ for (const pathSelector of shadowPath) {
43
+ root = await page.evaluateHandle(
44
+ (el: Element, sel: string) => {
45
+ const host = el.querySelector(sel);
46
+ if (!host) throw new Error(`Shadow host not found: ${sel}`);
47
+ return (host.shadowRoot as unknown as Element) ?? host;
48
+ },
49
+ root,
50
+ pathSelector
51
+ );
52
+ }
53
+
54
+ const handle = await page.evaluateHandle(
55
+ (el: Element, sel: string) => {
56
+ const found = el.querySelector(sel);
57
+ if (!found) throw new Error(`Element not found in shadow DOM: ${sel}`);
58
+ return found;
59
+ },
60
+ root,
61
+ selector
62
+ );
63
+
64
+ return handle as ElementHandle<Element>;
65
+ }
66
+
67
+ const el = await page.$(selector);
68
+ if (!el) throw new Error(`Element not found: ${selector}`);
69
+ return el;
70
+ }
71
+
72
+ /**
73
+ * Try to locate an input/textarea/select by its associated <label> text.
74
+ */
75
+ async function findByLabel(
76
+ page: Page,
77
+ label: string
78
+ ): Promise<ElementHandle<Element> | null> {
79
+ const handle = await page.evaluateHandle((labelText: string) => {
80
+ const labels = [...document.querySelectorAll("label")];
81
+ const match = labels.find((l) =>
82
+ l.textContent?.trim().includes(labelText)
83
+ );
84
+ if (!match) return null;
85
+
86
+ // If the label has a `for` attribute, use it
87
+ if (match.htmlFor) {
88
+ return document.getElementById(match.htmlFor);
89
+ }
90
+ // Otherwise look for an input nested inside the label
91
+ return match.querySelector("input, textarea, select");
92
+ }, label);
93
+
94
+ // evaluateHandle returns a JSHandle — check if the inner value is null
95
+ const element = handle.asElement();
96
+ if (!element) {
97
+ await handle.dispose();
98
+ return null;
99
+ }
100
+
101
+ return element as ElementHandle<Element>;
102
+ }
103
+
104
+ /**
105
+ * Find an element by its visible text content.
106
+ */
107
+ async function findByText(
108
+ page: Page,
109
+ selector: string,
110
+ text: string
111
+ ): Promise<ElementHandle<Element>> {
112
+ const handle = await page.evaluateHandle(
113
+ (sel: string, txt: string) => {
114
+ const candidates = [...document.querySelectorAll(sel || "*")];
115
+ const match = candidates.find((el) =>
116
+ el.textContent?.trim().includes(txt)
117
+ );
118
+ if (!match) throw new Error(`No element with text "${txt}" found`);
119
+ return match;
120
+ },
121
+ selector,
122
+ text
123
+ );
124
+
125
+ return handle as ElementHandle<Element>;
126
+ }
127
+
128
+ // ─── Command Executor ────────────────────────────────────────────────
129
+
130
+ export interface CommandResult {
131
+ success: boolean;
132
+ result?: unknown;
133
+ error?: string;
134
+ screenshot?: string;
135
+ shutdown?: boolean;
136
+ }
137
+
138
+ export async function executeCommand(
139
+ page: Page,
140
+ commandType: string,
141
+ payload: Record<string, unknown>
142
+ ): Promise<CommandResult> {
143
+ try {
144
+ switch (commandType) {
145
+ // ── navigate ──
146
+ case "navigate": {
147
+ const url = payload.url as string;
148
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
149
+ return { success: true, result: { url } };
150
+ }
151
+
152
+ // ── click ──
153
+ case "click": {
154
+ const selector = payload.selector as string;
155
+ const shadowPath = payload.shadowPath as string[] | undefined;
156
+ const text = payload.text as string | undefined;
157
+
158
+ let el: ElementHandle<Element>;
159
+ if (text) {
160
+ el = await findByText(page, selector, text);
161
+ } else {
162
+ el = await findElement(page, selector, shadowPath);
163
+ }
164
+ await el.click();
165
+ return { success: true };
166
+ }
167
+
168
+ // ── fill ──
169
+ case "fill": {
170
+ const selector = payload.selector as string;
171
+ const value = payload.value as string;
172
+ const shadowPath = payload.shadowPath as string[] | undefined;
173
+ const label = payload.label as string | undefined;
174
+
175
+ let el: ElementHandle<Element> | null = null;
176
+
177
+ // Try label-based lookup first
178
+ if (label) {
179
+ el = await findByLabel(page, label);
180
+ }
181
+
182
+ // Fall back to selector / shadow path
183
+ if (!el) {
184
+ el = await findElement(page, selector, shadowPath);
185
+ }
186
+
187
+ // Clear existing value then type the new one
188
+ await el.click({ clickCount: 3 }); // select all
189
+ await el.press("Backspace");
190
+ await el.type(value);
191
+ return { success: true };
192
+ }
193
+
194
+ // ── select ──
195
+ case "select": {
196
+ const selector = payload.selector as string;
197
+ const value = payload.value as string;
198
+ const shadowPath = payload.shadowPath as string[] | undefined;
199
+
200
+ if (shadowPath && shadowPath.length > 0) {
201
+ const el = await findElement(page, selector, shadowPath);
202
+ await page.evaluate(
203
+ (selectEl: Element, val: string) => {
204
+ (selectEl as HTMLSelectElement).value = val;
205
+ selectEl.dispatchEvent(new Event("change", { bubbles: true }));
206
+ },
207
+ el,
208
+ value
209
+ );
210
+ } else {
211
+ await page.select(selector, value);
212
+ }
213
+ return { success: true };
214
+ }
215
+
216
+ // ── screenshot ──
217
+ case "screenshot": {
218
+ const fullPage = (payload.fullPage as boolean) ?? false;
219
+ const buf = await page.screenshot({
220
+ fullPage,
221
+ encoding: "base64",
222
+ });
223
+ return { success: true, screenshot: buf as string };
224
+ }
225
+
226
+ // ── evaluate ──
227
+ case "evaluate": {
228
+ const script = payload.script as string;
229
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
230
+ const result = await page.evaluate(script);
231
+ return { success: true, result };
232
+ }
233
+
234
+ // ── waitForSelector ──
235
+ case "waitForSelector": {
236
+ const selector = payload.selector as string;
237
+ const timeout = (payload.timeout as number) ?? 10_000;
238
+ const shadowPath = payload.shadowPath as string[] | undefined;
239
+
240
+ if (shadowPath && shadowPath.length > 0) {
241
+ // Poll for element inside shadow DOM
242
+ const deadline = Date.now() + timeout;
243
+ let found = false;
244
+ while (Date.now() < deadline) {
245
+ try {
246
+ await findElement(page, selector, shadowPath);
247
+ found = true;
248
+ break;
249
+ } catch {
250
+ await new Promise((r) => setTimeout(r, 250));
251
+ }
252
+ }
253
+ if (!found) {
254
+ throw new Error(
255
+ `Timed out waiting for ${selector} in shadow DOM`
256
+ );
257
+ }
258
+ } else {
259
+ await page.waitForSelector(selector, { timeout });
260
+ }
261
+ return { success: true };
262
+ }
263
+
264
+ // ── waitForNavigation ──
265
+ case "waitForNavigation": {
266
+ const timeout = (payload.timeout as number) ?? 15_000;
267
+ await page.waitForNavigation({ timeout });
268
+ return { success: true };
269
+ }
270
+
271
+ // ── keyboard ──
272
+ case "keyboard": {
273
+ const key = payload.key as string;
274
+ await page.keyboard.press(key as Parameters<typeof page.keyboard.press>[0]);
275
+ return { success: true };
276
+ }
277
+
278
+ // ── scroll ──
279
+ case "scroll": {
280
+ const selector = (payload.selector as string) ?? null;
281
+ const direction = payload.direction as "up" | "down";
282
+ const amount = (payload.amount as number) ?? 300;
283
+
284
+ await page.evaluate(
285
+ (sel: string | null, dir: string, amt: number) => {
286
+ const target = sel ? document.querySelector(sel) : window;
287
+ if (!target) throw new Error(`Scroll target not found: ${sel}`);
288
+
289
+ const delta = dir === "down" ? amt : -amt;
290
+ if (target === window) {
291
+ window.scrollBy(0, delta);
292
+ } else {
293
+ (target as Element).scrollBy(0, delta);
294
+ }
295
+ },
296
+ selector,
297
+ direction,
298
+ amount
299
+ );
300
+ return { success: true };
301
+ }
302
+
303
+ // ── hover ──
304
+ case "hover": {
305
+ const selector = payload.selector as string;
306
+ const shadowPath = payload.shadowPath as string[] | undefined;
307
+ const el = await findElement(page, selector, shadowPath);
308
+ await el.hover();
309
+ return { success: true };
310
+ }
311
+
312
+ // ── delay ──
313
+ case "delay": {
314
+ const ms = (payload.ms as number) ?? 1_000;
315
+ await new Promise((r) => setTimeout(r, ms));
316
+ return { success: true };
317
+ }
318
+
319
+ // ── ping ──
320
+ case "ping": {
321
+ return { success: true };
322
+ }
323
+
324
+ // ── shutdown ──
325
+ case "shutdown": {
326
+ return { success: true, shutdown: true };
327
+ }
328
+
329
+ default:
330
+ return { success: false, error: `Unknown command type: ${commandType}` };
331
+ }
332
+ } catch (err) {
333
+ return {
334
+ success: false,
335
+ error: err instanceof Error ? err.message : String(err),
336
+ };
337
+ }
338
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Heartbeat — periodically pings the server so it knows we're alive.
3
+ */
4
+
5
+ export function startHeartbeat(
6
+ baseUrl: string,
7
+ sessionId: string,
8
+ token: string,
9
+ intervalMs = 10_000
10
+ ): { stop: () => void } {
11
+ let timer: ReturnType<typeof setInterval> | null = null;
12
+
13
+ const tick = async () => {
14
+ try {
15
+ const res = await fetch(
16
+ `${baseUrl}/api/sessions/${sessionId}/agent/heartbeat`,
17
+ {
18
+ method: "POST",
19
+ headers: { Authorization: `Bearer ${token}` },
20
+ }
21
+ );
22
+ if (res.ok) {
23
+ console.log("\uD83D\uDC93 Heartbeat sent");
24
+ } else {
25
+ console.error(`\u274C Heartbeat rejected (${res.status})`);
26
+ }
27
+ } catch (err) {
28
+ console.error(
29
+ "\u274C Heartbeat error:",
30
+ err instanceof Error ? err.message : err
31
+ );
32
+ }
33
+ };
34
+
35
+ timer = setInterval(tick, intervalMs);
36
+
37
+ // Send an initial heartbeat immediately
38
+ tick();
39
+
40
+ return {
41
+ stop() {
42
+ if (timer) {
43
+ clearInterval(timer);
44
+ timer = null;
45
+ }
46
+ },
47
+ };
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * sf-builder-agent — Desktop CLI for SalesForce Agent Creator.
5
+ *
6
+ * Connects to the web app, receives browser automation commands via SSE,
7
+ * executes them in a visible Chrome window, and reports results back.
8
+ */
9
+
10
+ import { createInterface } from "node:readline/promises";
11
+ import { stdin, stdout } from "node:process";
12
+ import { pair } from "./pairing.js";
13
+ import { connectSSE } from "./sse-client.js";
14
+ import { launchBrowser, executeCommand } from "./browser.js";
15
+ import { reportResult } from "./reporter.js";
16
+ import { startHeartbeat } from "./heartbeat.js";
17
+ import type { Browser } from "puppeteer";
18
+
19
+ // ─── Arg Parsing ─────────────────────────────────────────────────────
20
+
21
+ function parseArgs(argv: string[]): {
22
+ code?: string;
23
+ session?: string;
24
+ url: string;
25
+ } {
26
+ const args: Record<string, string> = {};
27
+ for (let i = 2; i < argv.length; i++) {
28
+ const key = argv[i];
29
+ const val = argv[i + 1];
30
+ if (key === "--code" && val) {
31
+ args.code = val;
32
+ i++;
33
+ } else if (key === "--session" && val) {
34
+ args.session = val;
35
+ i++;
36
+ } else if (key === "--url" && val) {
37
+ args.url = val;
38
+ i++;
39
+ }
40
+ }
41
+ return {
42
+ code: args.code,
43
+ session: args.session,
44
+ url: args.url ?? "https://sf-builder.cmgfinancial.ai",
45
+ };
46
+ }
47
+
48
+ // ─── Prompt Helper ───────────────────────────────────────────────────
49
+
50
+ async function prompt(question: string): Promise<string> {
51
+ const rl = createInterface({ input: stdin, output: stdout });
52
+ try {
53
+ const answer = await rl.question(question);
54
+ return answer.trim();
55
+ } finally {
56
+ rl.close();
57
+ }
58
+ }
59
+
60
+ // ─── Main ────────────────────────────────────────────────────────────
61
+
62
+ async function main() {
63
+ const args = parseArgs(process.argv);
64
+
65
+ let code = args.code;
66
+ let sessionId = args.session;
67
+ const baseUrl = args.url;
68
+
69
+ // Interactive prompts if args are missing
70
+ if (!sessionId) {
71
+ sessionId = await prompt("Enter session ID: ");
72
+ if (!sessionId) {
73
+ console.error("\u274C Session ID is required");
74
+ process.exit(1);
75
+ }
76
+ }
77
+ if (!code) {
78
+ code = await prompt("Enter pairing code: ");
79
+ if (!code) {
80
+ console.error("\u274C Pairing code is required");
81
+ process.exit(1);
82
+ }
83
+ }
84
+
85
+ // ── Step 1: Pair ──
86
+ console.log("\uD83D\uDD17 Pairing...");
87
+ let token: string;
88
+ try {
89
+ const pairResult = await pair(baseUrl, sessionId, code);
90
+ token = pairResult.token;
91
+ sessionId = pairResult.sessionId;
92
+ console.log("\u2705 Paired successfully");
93
+ } catch (err) {
94
+ console.error(
95
+ "\u274C Pairing failed:",
96
+ err instanceof Error ? err.message : err
97
+ );
98
+ process.exit(1);
99
+ }
100
+
101
+ // ── Step 2: Launch Browser ──
102
+ console.log("\uD83C\uDF10 Launching browser...");
103
+ let browser: Browser;
104
+ let page: Awaited<ReturnType<typeof launchBrowser>>["page"];
105
+ try {
106
+ const launched = await launchBrowser();
107
+ browser = launched.browser;
108
+ page = launched.page;
109
+ console.log("\u2705 Browser ready");
110
+ } catch (err) {
111
+ console.error(
112
+ "\u274C Browser launch failed:",
113
+ err instanceof Error ? err.message : err
114
+ );
115
+ process.exit(1);
116
+ }
117
+
118
+ // ── Step 3: Start Heartbeat ──
119
+ const heartbeat = startHeartbeat(baseUrl, sessionId, token);
120
+
121
+ // ── Step 4: Connect to command stream ──
122
+ console.log("\uD83D\uDCE1 Connecting to command stream...");
123
+
124
+ const sse = connectSSE(
125
+ baseUrl,
126
+ sessionId,
127
+ token,
128
+ async (command) => {
129
+ const label = command.stepKey
130
+ ? `${command.commandType} (step: ${command.stepKey})`
131
+ : command.commandType;
132
+
133
+ const payloadHint =
134
+ command.commandType === "navigate"
135
+ ? ` \u2192 ${command.payload.url}`
136
+ : "";
137
+
138
+ console.log(`\u26A1 Executing: ${label}${payloadHint}`);
139
+
140
+ const result = await executeCommand(page, command.commandType, command.payload);
141
+
142
+ if (result.success) {
143
+ console.log(`\u2705 Command complete: ${command.commandType}`);
144
+ } else {
145
+ console.error(
146
+ `\u274C Command failed: ${command.commandType} \u2014 ${result.error}`
147
+ );
148
+ }
149
+
150
+ // Report back to server
151
+ const resultPayload: Record<string, unknown> = {};
152
+ if (result.result !== undefined) resultPayload.result = result.result;
153
+ if (result.screenshot) resultPayload.screenshot = result.screenshot;
154
+ if (result.error) resultPayload.error = result.error;
155
+
156
+ await reportResult(
157
+ baseUrl,
158
+ sessionId!,
159
+ token,
160
+ command.id,
161
+ result.success ? "success" : "error",
162
+ Object.keys(resultPayload).length > 0 ? resultPayload : undefined
163
+ );
164
+
165
+ // Handle shutdown command
166
+ if (result.shutdown) {
167
+ console.log("\uD83D\uDC4B Shutting down (server requested)...");
168
+ shutdown();
169
+ }
170
+ },
171
+ (err) => {
172
+ console.error(`\u274C SSE error: ${err.message}`);
173
+ }
174
+ );
175
+
176
+ // ── Graceful Shutdown ──
177
+ let shuttingDown = false;
178
+
179
+ function shutdown() {
180
+ if (shuttingDown) return;
181
+ shuttingDown = true;
182
+
183
+ console.log("\uD83D\uDC4B Shutting down...");
184
+
185
+ sse.close();
186
+ heartbeat.stop();
187
+
188
+ browser
189
+ .close()
190
+ .catch(() => {})
191
+ .finally(() => {
192
+ process.exit(0);
193
+ });
194
+ }
195
+
196
+ process.on("SIGINT", shutdown);
197
+ process.on("SIGTERM", shutdown);
198
+
199
+ // Keep alive
200
+ process.stdin.resume();
201
+ }
202
+
203
+ main().catch((err) => {
204
+ console.error("\u274C Fatal error:", err instanceof Error ? err.message : err);
205
+ process.exit(1);
206
+ });