agent-browser-loop 0.1.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/src/daemon.ts ADDED
@@ -0,0 +1,626 @@
1
+ import * as fs from "node:fs";
2
+ import * as net from "node:net";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { z } from "zod";
6
+ import { type AgentBrowserOptions, createBrowser } from "./browser";
7
+ import {
8
+ type Command,
9
+ commandSchema,
10
+ executeActions,
11
+ executeCommand,
12
+ executeWait,
13
+ formatStepText,
14
+ formatWaitText,
15
+ getStateOptionsSchema,
16
+ type StepAction,
17
+ stepActionSchema,
18
+ type WaitCondition,
19
+ waitConditionSchema,
20
+ } from "./commands";
21
+ import { formatStateText } from "./state";
22
+
23
+ // ============================================================================
24
+ // Daemon Protocol
25
+ // ============================================================================
26
+
27
+ const requestSchema = z.discriminatedUnion("type", [
28
+ z.object({
29
+ type: z.literal("command"),
30
+ id: z.string(),
31
+ command: commandSchema,
32
+ }),
33
+ z.object({
34
+ type: z.literal("act"),
35
+ id: z.string(),
36
+ actions: z.array(stepActionSchema),
37
+ haltOnError: z.boolean().optional(),
38
+ includeState: z.boolean().optional(),
39
+ includeStateText: z.boolean().optional(),
40
+ stateOptions: getStateOptionsSchema.optional(),
41
+ }),
42
+ z.object({
43
+ type: z.literal("wait"),
44
+ id: z.string(),
45
+ condition: waitConditionSchema,
46
+ timeoutMs: z.number().optional(),
47
+ includeState: z.boolean().optional(),
48
+ includeStateText: z.boolean().optional(),
49
+ stateOptions: getStateOptionsSchema.optional(),
50
+ }),
51
+ z.object({
52
+ type: z.literal("state"),
53
+ id: z.string(),
54
+ options: getStateOptionsSchema.optional(),
55
+ format: z.enum(["json", "text"]).optional(),
56
+ }),
57
+ z.object({
58
+ type: z.literal("ping"),
59
+ id: z.string(),
60
+ }),
61
+ z.object({
62
+ type: z.literal("shutdown"),
63
+ id: z.string(),
64
+ }),
65
+ ]);
66
+
67
+ type DaemonRequest = z.infer<typeof requestSchema>;
68
+
69
+ interface DaemonResponse {
70
+ id: string;
71
+ success: boolean;
72
+ data?: unknown;
73
+ error?: string;
74
+ }
75
+
76
+ // ============================================================================
77
+ // Path Utilities
78
+ // ============================================================================
79
+
80
+ const DAEMON_DIR = path.join(os.tmpdir(), "agent-browser");
81
+
82
+ function ensureDaemonDir(): void {
83
+ if (!fs.existsSync(DAEMON_DIR)) {
84
+ fs.mkdirSync(DAEMON_DIR, { recursive: true });
85
+ }
86
+ }
87
+
88
+ export function getSocketPath(session = "default"): string {
89
+ return path.join(DAEMON_DIR, `${session}.sock`);
90
+ }
91
+
92
+ export function getPidPath(session = "default"): string {
93
+ return path.join(DAEMON_DIR, `${session}.pid`);
94
+ }
95
+
96
+ export function getConfigPath(session = "default"): string {
97
+ return path.join(DAEMON_DIR, `${session}.config.json`);
98
+ }
99
+
100
+ // ============================================================================
101
+ // Daemon Status
102
+ // ============================================================================
103
+
104
+ export function isDaemonRunning(session = "default"): boolean {
105
+ const pidPath = getPidPath(session);
106
+ if (!fs.existsSync(pidPath)) {
107
+ return false;
108
+ }
109
+
110
+ try {
111
+ const pid = Number.parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10);
112
+ // Check if process exists
113
+ process.kill(pid, 0);
114
+ return true;
115
+ } catch {
116
+ // Process doesn't exist, clean up stale files
117
+ cleanupDaemonFiles(session);
118
+ return false;
119
+ }
120
+ }
121
+
122
+ export function cleanupDaemonFiles(session = "default"): void {
123
+ const socketPath = getSocketPath(session);
124
+ const pidPath = getPidPath(session);
125
+ const configPath = getConfigPath(session);
126
+
127
+ try {
128
+ if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
129
+ } catch {}
130
+ try {
131
+ if (fs.existsSync(pidPath)) fs.unlinkSync(pidPath);
132
+ } catch {}
133
+ try {
134
+ if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
135
+ } catch {}
136
+ }
137
+
138
+ // ============================================================================
139
+ // Daemon Server
140
+ // ============================================================================
141
+
142
+ export interface DaemonOptions {
143
+ session?: string;
144
+ browserOptions?: AgentBrowserOptions;
145
+ }
146
+
147
+ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
148
+ const session = options.session ?? "default";
149
+ const socketPath = getSocketPath(session);
150
+ const pidPath = getPidPath(session);
151
+ const configPath = getConfigPath(session);
152
+
153
+ ensureDaemonDir();
154
+ cleanupDaemonFiles(session);
155
+
156
+ // Create and start browser
157
+ const browser = createBrowser(options.browserOptions);
158
+ await browser.start();
159
+
160
+ let shuttingDown = false;
161
+ // biome-ignore lint/style/useConst: assigned separately for hoisting in shutdown()
162
+ let server: net.Server;
163
+
164
+ const shutdown = () => {
165
+ if (shuttingDown) return;
166
+ shuttingDown = true;
167
+ server.close();
168
+ cleanupDaemonFiles(session);
169
+ browser.stop().finally(() => {
170
+ process.kill(process.pid, "SIGKILL");
171
+ });
172
+ // Failsafe: force kill after 2 seconds
173
+ setTimeout(() => {
174
+ process.kill(process.pid, "SIGKILL");
175
+ }, 2000);
176
+ };
177
+
178
+ server = net.createServer((socket) => {
179
+ let buffer = "";
180
+
181
+ socket.on("data", async (data) => {
182
+ buffer += data.toString();
183
+
184
+ // Process complete lines (newline-delimited JSON)
185
+ while (buffer.includes("\n")) {
186
+ const newlineIdx = buffer.indexOf("\n");
187
+ const line = buffer.substring(0, newlineIdx);
188
+ buffer = buffer.substring(newlineIdx + 1);
189
+
190
+ if (!line.trim()) continue;
191
+
192
+ let response: DaemonResponse;
193
+
194
+ try {
195
+ const json = JSON.parse(line);
196
+ const parseResult = requestSchema.safeParse(json);
197
+
198
+ if (!parseResult.success) {
199
+ response = {
200
+ id: json.id ?? "unknown",
201
+ success: false,
202
+ error: `Invalid request: ${parseResult.error.message}`,
203
+ };
204
+ } else {
205
+ response = await handleRequest(browser, parseResult.data);
206
+
207
+ // Handle shutdown
208
+ if (parseResult.data.type === "shutdown") {
209
+ socket.write(JSON.stringify(response) + "\n");
210
+ shutdown();
211
+ return;
212
+ }
213
+
214
+ // Handle close command - shutdown daemon
215
+ if (
216
+ parseResult.data.type === "command" &&
217
+ parseResult.data.command.type === "close"
218
+ ) {
219
+ socket.write(JSON.stringify(response) + "\n");
220
+ if (!shuttingDown) {
221
+ shuttingDown = true;
222
+ setTimeout(() => shutdown(), 100);
223
+ }
224
+ return;
225
+ }
226
+ }
227
+ } catch (err) {
228
+ response = {
229
+ id: "error",
230
+ success: false,
231
+ error: err instanceof Error ? err.message : String(err),
232
+ };
233
+ }
234
+
235
+ socket.write(JSON.stringify(response) + "\n");
236
+ }
237
+ });
238
+
239
+ socket.on("error", () => {
240
+ // Client disconnected, ignore
241
+ });
242
+ });
243
+
244
+ // Write PID and config
245
+ fs.writeFileSync(pidPath, process.pid.toString());
246
+ fs.writeFileSync(configPath, JSON.stringify(options.browserOptions ?? {}));
247
+
248
+ // Start listening
249
+ server.listen(socketPath, () => {
250
+ // Ready
251
+ });
252
+
253
+ server.on("error", (err) => {
254
+ console.error("Daemon server error:", err);
255
+ cleanupDaemonFiles(session);
256
+ process.exit(1);
257
+ });
258
+
259
+ // Handle shutdown signals
260
+ process.on("SIGINT", shutdown);
261
+ process.on("SIGTERM", shutdown);
262
+ process.on("SIGHUP", shutdown);
263
+
264
+ process.on("uncaughtException", (err) => {
265
+ console.error("Uncaught exception:", err);
266
+ cleanupDaemonFiles(session);
267
+ process.exit(1);
268
+ });
269
+
270
+ process.on("unhandledRejection", (reason) => {
271
+ console.error("Unhandled rejection:", reason);
272
+ cleanupDaemonFiles(session);
273
+ process.exit(1);
274
+ });
275
+
276
+ process.on("exit", () => {
277
+ cleanupDaemonFiles(session);
278
+ });
279
+
280
+ // Keep alive
281
+ process.stdin.resume();
282
+ }
283
+
284
+ async function handleRequest(
285
+ browser: ReturnType<typeof createBrowser>,
286
+ request: DaemonRequest,
287
+ ): Promise<DaemonResponse> {
288
+ const { id } = request;
289
+
290
+ try {
291
+ switch (request.type) {
292
+ case "ping": {
293
+ return { id, success: true, data: { status: "ok" } };
294
+ }
295
+
296
+ case "shutdown": {
297
+ return { id, success: true, data: { status: "shutting_down" } };
298
+ }
299
+
300
+ case "command": {
301
+ const result = await executeCommand(browser, request.command);
302
+ return { id, success: true, data: result };
303
+ }
304
+
305
+ case "act": {
306
+ const results = await executeActions(browser, request.actions, {
307
+ haltOnError: request.haltOnError ?? true,
308
+ });
309
+
310
+ let state: unknown;
311
+ let stateText: string | undefined;
312
+
313
+ if (request.includeState || request.includeStateText !== false) {
314
+ const currentState = await browser.getState(request.stateOptions);
315
+ if (request.includeState) {
316
+ state = currentState;
317
+ }
318
+ if (request.includeStateText !== false) {
319
+ stateText = formatStateText(currentState);
320
+ }
321
+ }
322
+
323
+ const hasError = results.some((r) => r.error != null);
324
+
325
+ return {
326
+ id,
327
+ success: true,
328
+ data: {
329
+ results,
330
+ state,
331
+ stateText,
332
+ text: formatStepText({ results, stateText }),
333
+ error: hasError ? "One or more actions failed" : undefined,
334
+ },
335
+ };
336
+ }
337
+
338
+ case "wait": {
339
+ await executeWait(browser, request.condition, {
340
+ timeoutMs: request.timeoutMs,
341
+ });
342
+
343
+ let state: unknown;
344
+ let stateText: string | undefined;
345
+
346
+ if (request.includeState || request.includeStateText !== false) {
347
+ const currentState = await browser.getState(request.stateOptions);
348
+ if (request.includeState) {
349
+ state = currentState;
350
+ }
351
+ if (request.includeStateText !== false) {
352
+ stateText = formatStateText(currentState);
353
+ }
354
+ }
355
+
356
+ return {
357
+ id,
358
+ success: true,
359
+ data: {
360
+ state,
361
+ stateText,
362
+ text: formatWaitText({ condition: request.condition, stateText }),
363
+ },
364
+ };
365
+ }
366
+
367
+ case "state": {
368
+ const currentState = await browser.getState(request.options);
369
+ const format = request.format ?? "text";
370
+
371
+ if (format === "text") {
372
+ return {
373
+ id,
374
+ success: true,
375
+ data: { text: formatStateText(currentState) },
376
+ };
377
+ }
378
+
379
+ return { id, success: true, data: { state: currentState } };
380
+ }
381
+ }
382
+ } catch (err) {
383
+ return {
384
+ id,
385
+ success: false,
386
+ error: err instanceof Error ? err.message : String(err),
387
+ };
388
+ }
389
+ }
390
+
391
+ // ============================================================================
392
+ // Daemon Client
393
+ // ============================================================================
394
+
395
+ export class DaemonClient {
396
+ private socketPath: string;
397
+
398
+ constructor(session = "default") {
399
+ this.socketPath = getSocketPath(session);
400
+ }
401
+
402
+ private async send(request: DaemonRequest): Promise<DaemonResponse> {
403
+ return new Promise((resolve, reject) => {
404
+ const socket = net.createConnection(this.socketPath);
405
+ let buffer = "";
406
+
407
+ socket.on("connect", () => {
408
+ socket.write(JSON.stringify(request) + "\n");
409
+ });
410
+
411
+ socket.on("data", (data) => {
412
+ buffer += data.toString();
413
+ const newlineIdx = buffer.indexOf("\n");
414
+ if (newlineIdx !== -1) {
415
+ const line = buffer.substring(0, newlineIdx);
416
+ socket.end();
417
+ try {
418
+ resolve(JSON.parse(line));
419
+ } catch {
420
+ reject(new Error(`Invalid response: ${line}`));
421
+ }
422
+ }
423
+ });
424
+
425
+ socket.on("error", (err) => {
426
+ reject(err);
427
+ });
428
+
429
+ socket.on("timeout", () => {
430
+ socket.destroy();
431
+ reject(new Error("Connection timeout"));
432
+ });
433
+
434
+ socket.setTimeout(60000);
435
+ });
436
+ }
437
+
438
+ async ping(): Promise<boolean> {
439
+ try {
440
+ const response = await this.send({ type: "ping", id: "ping" });
441
+ return response.success;
442
+ } catch {
443
+ return false;
444
+ }
445
+ }
446
+
447
+ async command(command: Command): Promise<DaemonResponse> {
448
+ return this.send({
449
+ type: "command",
450
+ id: `cmd-${Date.now()}`,
451
+ command,
452
+ });
453
+ }
454
+
455
+ async act(
456
+ actions: StepAction[],
457
+ options: {
458
+ haltOnError?: boolean;
459
+ includeState?: boolean;
460
+ includeStateText?: boolean;
461
+ stateOptions?: z.infer<typeof getStateOptionsSchema>;
462
+ } = {},
463
+ ): Promise<DaemonResponse> {
464
+ return this.send({
465
+ type: "act",
466
+ id: `act-${Date.now()}`,
467
+ actions,
468
+ ...options,
469
+ });
470
+ }
471
+
472
+ async wait(
473
+ condition: WaitCondition,
474
+ options: {
475
+ timeoutMs?: number;
476
+ includeState?: boolean;
477
+ includeStateText?: boolean;
478
+ stateOptions?: z.infer<typeof getStateOptionsSchema>;
479
+ } = {},
480
+ ): Promise<DaemonResponse> {
481
+ return this.send({
482
+ type: "wait",
483
+ id: `wait-${Date.now()}`,
484
+ condition,
485
+ ...options,
486
+ });
487
+ }
488
+
489
+ async state(
490
+ options: {
491
+ format?: "json" | "text";
492
+ stateOptions?: z.infer<typeof getStateOptionsSchema>;
493
+ } = {},
494
+ ): Promise<DaemonResponse> {
495
+ return this.send({
496
+ type: "state",
497
+ id: `state-${Date.now()}`,
498
+ options: options.stateOptions,
499
+ format: options.format,
500
+ });
501
+ }
502
+
503
+ async shutdown(): Promise<DaemonResponse> {
504
+ return this.send({ type: "shutdown", id: "shutdown" });
505
+ }
506
+
507
+ async screenshot(options?: {
508
+ fullPage?: boolean;
509
+ path?: string;
510
+ }): Promise<DaemonResponse> {
511
+ return this.send({
512
+ type: "command",
513
+ id: `screenshot-${Date.now()}`,
514
+ command: { type: "screenshot", ...options },
515
+ });
516
+ }
517
+ }
518
+
519
+ // ============================================================================
520
+ // Daemon Spawner
521
+ // ============================================================================
522
+
523
+ export async function ensureDaemon(
524
+ session = "default",
525
+ browserOptions?: AgentBrowserOptions,
526
+ ): Promise<DaemonClient> {
527
+ const client = new DaemonClient(session);
528
+
529
+ // Check if already running
530
+ if (isDaemonRunning(session)) {
531
+ // Verify it's responsive
532
+ if (await client.ping()) {
533
+ return client;
534
+ }
535
+ // Not responsive, clean up
536
+ cleanupDaemonFiles(session);
537
+ }
538
+
539
+ // Spawn new daemon
540
+ await spawnDaemon(session, browserOptions);
541
+
542
+ // Wait for daemon to be ready
543
+ const maxAttempts = 50; // 5 seconds
544
+ for (let i = 0; i < maxAttempts; i++) {
545
+ await new Promise((r) => setTimeout(r, 100));
546
+ if (await client.ping()) {
547
+ return client;
548
+ }
549
+ }
550
+
551
+ throw new Error("Failed to start daemon");
552
+ }
553
+
554
+ async function spawnDaemon(
555
+ session: string,
556
+ browserOptions?: AgentBrowserOptions,
557
+ ): Promise<void> {
558
+ const configPath = getConfigPath(session);
559
+ ensureDaemonDir();
560
+
561
+ // Write config for daemon to read
562
+ fs.writeFileSync(
563
+ configPath,
564
+ JSON.stringify({ session, browserOptions: browserOptions ?? {} }),
565
+ );
566
+
567
+ // Spawn detached process
568
+ const { spawn } = await import("node:child_process");
569
+
570
+ const child = spawn(
571
+ process.execPath,
572
+ [
573
+ "--bun",
574
+ import.meta.dirname + "/daemon-entry.ts",
575
+ "--session",
576
+ session,
577
+ "--config",
578
+ configPath,
579
+ ],
580
+ {
581
+ detached: true,
582
+ stdio: "ignore",
583
+ },
584
+ );
585
+
586
+ child.unref();
587
+ }
588
+
589
+ // ============================================================================
590
+ // Entry Point (for daemon process)
591
+ // ============================================================================
592
+
593
+ if (
594
+ process.argv[1]?.endsWith("daemon.ts") ||
595
+ process.argv[1]?.endsWith("daemon-entry.ts") ||
596
+ process.env.AGENT_BROWSER_DAEMON === "1"
597
+ ) {
598
+ // Parse args
599
+ const args = process.argv.slice(2);
600
+ let session = "default";
601
+ let configPath: string | undefined;
602
+
603
+ for (let i = 0; i < args.length; i++) {
604
+ if (args[i] === "--session" && args[i + 1]) {
605
+ session = args[i + 1];
606
+ i++;
607
+ } else if (args[i] === "--config" && args[i + 1]) {
608
+ configPath = args[i + 1];
609
+ i++;
610
+ }
611
+ }
612
+
613
+ let browserOptions: AgentBrowserOptions = {};
614
+ if (configPath && fs.existsSync(configPath)) {
615
+ try {
616
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
617
+ browserOptions = config.browserOptions ?? {};
618
+ session = config.session ?? session;
619
+ } catch {}
620
+ }
621
+
622
+ startDaemon({ session, browserOptions }).catch((err) => {
623
+ console.error("Failed to start daemon:", err);
624
+ process.exit(1);
625
+ });
626
+ }