agent-browser-loop 0.1.0 → 0.2.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 CHANGED
@@ -18,21 +18,56 @@ import {
18
18
  type WaitCondition,
19
19
  waitConditionSchema,
20
20
  } from "./commands";
21
+ import { createIdGenerator } from "./id";
21
22
  import { formatStateText } from "./state";
22
23
 
23
24
  // ============================================================================
24
25
  // Daemon Protocol
25
26
  // ============================================================================
26
27
 
28
+ const browserOptionsSchema = z
29
+ .object({
30
+ headless: z.boolean().optional(),
31
+ executablePath: z.string().optional(),
32
+ useSystemChrome: z.boolean().optional(),
33
+ viewportWidth: z.number().optional(),
34
+ viewportHeight: z.number().optional(),
35
+ userDataDir: z.string().optional(),
36
+ timeout: z.number().optional(),
37
+ captureNetwork: z.boolean().optional(),
38
+ networkLogLimit: z.number().optional(),
39
+ storageStatePath: z.string().optional(),
40
+ })
41
+ .optional();
42
+
27
43
  const requestSchema = z.discriminatedUnion("type", [
44
+ // Session management
45
+ z.object({
46
+ type: z.literal("create"),
47
+ id: z.string(),
48
+ sessionId: z.literal("default").optional(), // Only "default" is allowed as explicit ID
49
+ options: browserOptionsSchema,
50
+ }),
51
+ z.object({
52
+ type: z.literal("list"),
53
+ id: z.string(),
54
+ }),
55
+ z.object({
56
+ type: z.literal("close"),
57
+ id: z.string(),
58
+ sessionId: z.string(),
59
+ }),
60
+ // Session operations (require sessionId, default to "default")
28
61
  z.object({
29
62
  type: z.literal("command"),
30
63
  id: z.string(),
64
+ sessionId: z.string().optional(),
31
65
  command: commandSchema,
32
66
  }),
33
67
  z.object({
34
68
  type: z.literal("act"),
35
69
  id: z.string(),
70
+ sessionId: z.string().optional(),
36
71
  actions: z.array(stepActionSchema),
37
72
  haltOnError: z.boolean().optional(),
38
73
  includeState: z.boolean().optional(),
@@ -42,6 +77,7 @@ const requestSchema = z.discriminatedUnion("type", [
42
77
  z.object({
43
78
  type: z.literal("wait"),
44
79
  id: z.string(),
80
+ sessionId: z.string().optional(),
45
81
  condition: waitConditionSchema,
46
82
  timeoutMs: z.number().optional(),
47
83
  includeState: z.boolean().optional(),
@@ -51,6 +87,7 @@ const requestSchema = z.discriminatedUnion("type", [
51
87
  z.object({
52
88
  type: z.literal("state"),
53
89
  id: z.string(),
90
+ sessionId: z.string().optional(),
54
91
  options: getStateOptionsSchema.optional(),
55
92
  format: z.enum(["json", "text"]).optional(),
56
93
  }),
@@ -73,6 +110,18 @@ interface DaemonResponse {
73
110
  error?: string;
74
111
  }
75
112
 
113
+ // ============================================================================
114
+ // Session Types
115
+ // ============================================================================
116
+
117
+ type DaemonSession = {
118
+ id: string;
119
+ browser: ReturnType<typeof createBrowser>;
120
+ lastUsed: number;
121
+ busy: boolean;
122
+ options: AgentBrowserOptions;
123
+ };
124
+
76
125
  // ============================================================================
77
126
  // Path Utilities
78
127
  // ============================================================================
@@ -85,24 +134,25 @@ function ensureDaemonDir(): void {
85
134
  }
86
135
  }
87
136
 
88
- export function getSocketPath(session = "default"): string {
89
- return path.join(DAEMON_DIR, `${session}.sock`);
137
+ // Unified daemon paths (single socket for all sessions)
138
+ export function getSocketPath(): string {
139
+ return path.join(DAEMON_DIR, "daemon.sock");
90
140
  }
91
141
 
92
- export function getPidPath(session = "default"): string {
93
- return path.join(DAEMON_DIR, `${session}.pid`);
142
+ export function getPidPath(): string {
143
+ return path.join(DAEMON_DIR, "daemon.pid");
94
144
  }
95
145
 
96
- export function getConfigPath(session = "default"): string {
97
- return path.join(DAEMON_DIR, `${session}.config.json`);
146
+ export function getConfigPath(): string {
147
+ return path.join(DAEMON_DIR, "daemon.config.json");
98
148
  }
99
149
 
100
150
  // ============================================================================
101
151
  // Daemon Status
102
152
  // ============================================================================
103
153
 
104
- export function isDaemonRunning(session = "default"): boolean {
105
- const pidPath = getPidPath(session);
154
+ export function isDaemonRunning(): boolean {
155
+ const pidPath = getPidPath();
106
156
  if (!fs.existsSync(pidPath)) {
107
157
  return false;
108
158
  }
@@ -114,15 +164,15 @@ export function isDaemonRunning(session = "default"): boolean {
114
164
  return true;
115
165
  } catch {
116
166
  // Process doesn't exist, clean up stale files
117
- cleanupDaemonFiles(session);
167
+ cleanupDaemonFiles();
118
168
  return false;
119
169
  }
120
170
  }
121
171
 
122
- export function cleanupDaemonFiles(session = "default"): void {
123
- const socketPath = getSocketPath(session);
124
- const pidPath = getPidPath(session);
125
- const configPath = getConfigPath(session);
172
+ export function cleanupDaemonFiles(): void {
173
+ const socketPath = getSocketPath();
174
+ const pidPath = getPidPath();
175
+ const configPath = getConfigPath();
126
176
 
127
177
  try {
128
178
  if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
@@ -140,41 +190,118 @@ export function cleanupDaemonFiles(session = "default"): void {
140
190
  // ============================================================================
141
191
 
142
192
  export interface DaemonOptions {
143
- session?: string;
144
- browserOptions?: AgentBrowserOptions;
193
+ defaultBrowserOptions?: AgentBrowserOptions;
194
+ sessionTtlMs?: number;
145
195
  }
146
196
 
197
+ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
198
+
147
199
  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);
200
+ const socketPath = getSocketPath();
201
+ const pidPath = getPidPath();
202
+ const configPath = getConfigPath();
152
203
 
153
204
  ensureDaemonDir();
154
- cleanupDaemonFiles(session);
205
+ cleanupDaemonFiles();
155
206
 
156
- // Create and start browser
157
- const browser = createBrowser(options.browserOptions);
158
- await browser.start();
207
+ // Multi-session state
208
+ const sessions = new Map<string, DaemonSession>();
209
+ const idGenerator = createIdGenerator();
210
+ const defaultOptions = options.defaultBrowserOptions ?? {};
211
+ const sessionTtl = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
159
212
 
160
213
  let shuttingDown = false;
161
214
  // biome-ignore lint/style/useConst: assigned separately for hoisting in shutdown()
162
215
  let server: net.Server;
216
+ // biome-ignore lint/style/useConst: assigned separately for cleanup
217
+ let cleanupTimer: ReturnType<typeof setInterval>;
218
+
219
+ // Session helpers
220
+ async function createSession(
221
+ sessionId?: string,
222
+ browserOptions?: AgentBrowserOptions,
223
+ ): Promise<DaemonSession> {
224
+ const id = sessionId ?? idGenerator.next();
225
+ if (sessions.has(id)) {
226
+ throw new Error(`Session already exists: ${id}`);
227
+ }
228
+ const mergedOptions = { ...defaultOptions, ...browserOptions };
229
+ const browser = createBrowser(mergedOptions);
230
+ await browser.start();
231
+ const session: DaemonSession = {
232
+ id,
233
+ browser,
234
+ lastUsed: Date.now(),
235
+ busy: false,
236
+ options: mergedOptions,
237
+ };
238
+ sessions.set(id, session);
239
+ return session;
240
+ }
241
+
242
+ function getSession(sessionId: string): DaemonSession {
243
+ const session = sessions.get(sessionId);
244
+ if (!session) {
245
+ throw new Error(`Session not found: ${sessionId}`);
246
+ }
247
+ return session;
248
+ }
163
249
 
164
- const shutdown = () => {
250
+ function getOrDefaultSession(sessionId?: string): DaemonSession {
251
+ const id = sessionId ?? "default";
252
+ const session = sessions.get(id);
253
+ if (!session) {
254
+ throw new Error(
255
+ `Session not found: ${id}. Use 'open' command to create a session first.`,
256
+ );
257
+ }
258
+ return session;
259
+ }
260
+
261
+ async function closeSession(sessionId: string): Promise<void> {
262
+ const session = sessions.get(sessionId);
263
+ if (!session) {
264
+ throw new Error(`Session not found: ${sessionId}`);
265
+ }
266
+ await session.browser.stop();
267
+ sessions.delete(sessionId);
268
+ idGenerator.release(sessionId);
269
+ }
270
+
271
+ const shutdown = async () => {
165
272
  if (shuttingDown) return;
166
273
  shuttingDown = true;
274
+ clearInterval(cleanupTimer);
167
275
  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);
276
+ // Close all sessions
277
+ for (const session of sessions.values()) {
278
+ try {
279
+ await session.browser.stop();
280
+ } catch {}
281
+ }
282
+ sessions.clear();
283
+ cleanupDaemonFiles();
284
+ process.kill(process.pid, "SIGKILL");
176
285
  };
177
286
 
287
+ // Session TTL cleanup
288
+ cleanupTimer = setInterval(
289
+ async () => {
290
+ if (sessionTtl <= 0) return;
291
+ const now = Date.now();
292
+ for (const [id, session] of sessions) {
293
+ if (now - session.lastUsed > sessionTtl && !session.busy) {
294
+ try {
295
+ await session.browser.stop();
296
+ } catch {}
297
+ sessions.delete(id);
298
+ idGenerator.release(id);
299
+ }
300
+ }
301
+ },
302
+ Math.max(10_000, Math.floor(sessionTtl / 2)),
303
+ );
304
+
178
305
  server = net.createServer((socket) => {
179
306
  let buffer = "";
180
307
 
@@ -202,7 +329,16 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
202
329
  error: `Invalid request: ${parseResult.error.message}`,
203
330
  };
204
331
  } else {
205
- response = await handleRequest(browser, parseResult.data);
332
+ response = await handleRequest(
333
+ parseResult.data,
334
+ sessions,
335
+ createSession,
336
+ getSession,
337
+ getOrDefaultSession,
338
+ closeSession,
339
+ idGenerator,
340
+ defaultOptions,
341
+ );
206
342
 
207
343
  // Handle shutdown
208
344
  if (parseResult.data.type === "shutdown") {
@@ -210,19 +346,6 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
210
346
  shutdown();
211
347
  return;
212
348
  }
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
349
  }
227
350
  } catch (err) {
228
351
  response = {
@@ -243,7 +366,7 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
243
366
 
244
367
  // Write PID and config
245
368
  fs.writeFileSync(pidPath, process.pid.toString());
246
- fs.writeFileSync(configPath, JSON.stringify(options.browserOptions ?? {}));
369
+ fs.writeFileSync(configPath, JSON.stringify(options));
247
370
 
248
371
  // Start listening
249
372
  server.listen(socketPath, () => {
@@ -252,29 +375,29 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
252
375
 
253
376
  server.on("error", (err) => {
254
377
  console.error("Daemon server error:", err);
255
- cleanupDaemonFiles(session);
378
+ cleanupDaemonFiles();
256
379
  process.exit(1);
257
380
  });
258
381
 
259
382
  // Handle shutdown signals
260
- process.on("SIGINT", shutdown);
261
- process.on("SIGTERM", shutdown);
262
- process.on("SIGHUP", shutdown);
383
+ process.on("SIGINT", () => shutdown());
384
+ process.on("SIGTERM", () => shutdown());
385
+ process.on("SIGHUP", () => shutdown());
263
386
 
264
387
  process.on("uncaughtException", (err) => {
265
388
  console.error("Uncaught exception:", err);
266
- cleanupDaemonFiles(session);
389
+ cleanupDaemonFiles();
267
390
  process.exit(1);
268
391
  });
269
392
 
270
393
  process.on("unhandledRejection", (reason) => {
271
394
  console.error("Unhandled rejection:", reason);
272
- cleanupDaemonFiles(session);
395
+ cleanupDaemonFiles();
273
396
  process.exit(1);
274
397
  });
275
398
 
276
399
  process.on("exit", () => {
277
- cleanupDaemonFiles(session);
400
+ cleanupDaemonFiles();
278
401
  });
279
402
 
280
403
  // Keep alive
@@ -282,8 +405,17 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
282
405
  }
283
406
 
284
407
  async function handleRequest(
285
- browser: ReturnType<typeof createBrowser>,
286
408
  request: DaemonRequest,
409
+ sessions: Map<string, DaemonSession>,
410
+ createSession: (
411
+ sessionId?: string,
412
+ options?: AgentBrowserOptions,
413
+ ) => Promise<DaemonSession>,
414
+ getSession: (sessionId: string) => DaemonSession,
415
+ getOrDefaultSession: (sessionId?: string) => DaemonSession,
416
+ closeSession: (sessionId: string) => Promise<void>,
417
+ idGenerator: ReturnType<typeof createIdGenerator>,
418
+ defaultOptions: AgentBrowserOptions,
287
419
  ): Promise<DaemonResponse> {
288
420
  const { id } = request;
289
421
 
@@ -297,86 +429,160 @@ async function handleRequest(
297
429
  return { id, success: true, data: { status: "shutting_down" } };
298
430
  }
299
431
 
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
-
432
+ case "create": {
433
+ const session = await createSession(request.sessionId, request.options);
325
434
  return {
326
435
  id,
327
436
  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
- },
437
+ data: { sessionId: session.id },
335
438
  };
336
439
  }
337
440
 
338
- case "wait": {
339
- await executeWait(browser, request.condition, {
340
- timeoutMs: request.timeoutMs,
441
+ case "list": {
442
+ const sessionList = Array.from(sessions.values()).map((s) => {
443
+ const state = s.browser.getLastState();
444
+ return {
445
+ id: s.id,
446
+ url: state?.url ?? "about:blank",
447
+ title: state?.title ?? "",
448
+ busy: s.busy,
449
+ lastUsed: s.lastUsed,
450
+ };
341
451
  });
452
+ return { id, success: true, data: { sessions: sessionList } };
453
+ }
342
454
 
343
- let state: unknown;
344
- let stateText: string | undefined;
455
+ case "close": {
456
+ await closeSession(request.sessionId);
457
+ return { id, success: true, data: { closed: request.sessionId } };
458
+ }
345
459
 
346
- if (request.includeState || request.includeStateText !== false) {
347
- const currentState = await browser.getState(request.stateOptions);
348
- if (request.includeState) {
349
- state = currentState;
460
+ case "command": {
461
+ const session = getOrDefaultSession(request.sessionId);
462
+ session.busy = true;
463
+ session.lastUsed = Date.now();
464
+ try {
465
+ const result = await executeCommand(session.browser, request.command);
466
+ // Handle close command - close the session
467
+ if (request.command.type === "close") {
468
+ await closeSession(session.id);
350
469
  }
351
- if (request.includeStateText !== false) {
352
- stateText = formatStateText(currentState);
470
+ return { id, success: true, data: result };
471
+ } finally {
472
+ if (sessions.has(session.id)) {
473
+ session.busy = false;
474
+ session.lastUsed = Date.now();
353
475
  }
354
476
  }
477
+ }
355
478
 
356
- return {
357
- id,
358
- success: true,
359
- data: {
360
- state,
361
- stateText,
362
- text: formatWaitText({ condition: request.condition, stateText }),
363
- },
364
- };
479
+ case "act": {
480
+ const session = getOrDefaultSession(request.sessionId);
481
+ session.busy = true;
482
+ session.lastUsed = Date.now();
483
+ try {
484
+ const results = await executeActions(
485
+ session.browser,
486
+ request.actions,
487
+ {
488
+ haltOnError: request.haltOnError ?? true,
489
+ },
490
+ );
491
+
492
+ let state: unknown;
493
+ let stateText: string | undefined;
494
+
495
+ if (request.includeState || request.includeStateText !== false) {
496
+ const currentState = await session.browser.getState(
497
+ request.stateOptions,
498
+ );
499
+ if (request.includeState) {
500
+ state = currentState;
501
+ }
502
+ if (request.includeStateText !== false) {
503
+ stateText = formatStateText(currentState);
504
+ }
505
+ }
506
+
507
+ const hasError = results.some((r) => r.error != null);
508
+
509
+ return {
510
+ id,
511
+ success: true,
512
+ data: {
513
+ results,
514
+ state,
515
+ stateText,
516
+ text: formatStepText({ results, stateText }),
517
+ error: hasError ? "One or more actions failed" : undefined,
518
+ },
519
+ };
520
+ } finally {
521
+ session.busy = false;
522
+ session.lastUsed = Date.now();
523
+ }
365
524
  }
366
525
 
367
- case "state": {
368
- const currentState = await browser.getState(request.options);
369
- const format = request.format ?? "text";
526
+ case "wait": {
527
+ const session = getOrDefaultSession(request.sessionId);
528
+ session.busy = true;
529
+ session.lastUsed = Date.now();
530
+ try {
531
+ await executeWait(session.browser, request.condition, {
532
+ timeoutMs: request.timeoutMs,
533
+ });
534
+
535
+ let state: unknown;
536
+ let stateText: string | undefined;
537
+
538
+ if (request.includeState || request.includeStateText !== false) {
539
+ const currentState = await session.browser.getState(
540
+ request.stateOptions,
541
+ );
542
+ if (request.includeState) {
543
+ state = currentState;
544
+ }
545
+ if (request.includeStateText !== false) {
546
+ stateText = formatStateText(currentState);
547
+ }
548
+ }
370
549
 
371
- if (format === "text") {
372
550
  return {
373
551
  id,
374
552
  success: true,
375
- data: { text: formatStateText(currentState) },
553
+ data: {
554
+ state,
555
+ stateText,
556
+ text: formatWaitText({ condition: request.condition, stateText }),
557
+ },
376
558
  };
559
+ } finally {
560
+ session.busy = false;
561
+ session.lastUsed = Date.now();
377
562
  }
563
+ }
378
564
 
379
- return { id, success: true, data: { state: currentState } };
565
+ case "state": {
566
+ const session = getOrDefaultSession(request.sessionId);
567
+ session.busy = true;
568
+ session.lastUsed = Date.now();
569
+ try {
570
+ const currentState = await session.browser.getState(request.options);
571
+ const format = request.format ?? "text";
572
+
573
+ if (format === "text") {
574
+ return {
575
+ id,
576
+ success: true,
577
+ data: { text: formatStateText(currentState) },
578
+ };
579
+ }
580
+
581
+ return { id, success: true, data: { state: currentState } };
582
+ } finally {
583
+ session.busy = false;
584
+ session.lastUsed = Date.now();
585
+ }
380
586
  }
381
587
  }
382
588
  } catch (err) {
@@ -394,9 +600,11 @@ async function handleRequest(
394
600
 
395
601
  export class DaemonClient {
396
602
  private socketPath: string;
603
+ private sessionId?: string;
397
604
 
398
- constructor(session = "default") {
399
- this.socketPath = getSocketPath(session);
605
+ constructor(sessionId?: string) {
606
+ this.socketPath = getSocketPath();
607
+ this.sessionId = sessionId;
400
608
  }
401
609
 
402
610
  private async send(request: DaemonRequest): Promise<DaemonResponse> {
@@ -435,6 +643,16 @@ export class DaemonClient {
435
643
  });
436
644
  }
437
645
 
646
+ /** Set the session ID for subsequent requests */
647
+ setSession(sessionId: string): void {
648
+ this.sessionId = sessionId;
649
+ }
650
+
651
+ /** Get the current session ID */
652
+ getSessionId(): string | undefined {
653
+ return this.sessionId;
654
+ }
655
+
438
656
  async ping(): Promise<boolean> {
439
657
  try {
440
658
  const response = await this.send({ type: "ping", id: "ping" });
@@ -444,10 +662,41 @@ export class DaemonClient {
444
662
  }
445
663
  }
446
664
 
447
- async command(command: Command): Promise<DaemonResponse> {
665
+ /** Create a new session, returns the session ID */
666
+ async create(options?: {
667
+ sessionId?: "default"; // Only "default" is allowed as explicit ID
668
+ browserOptions?: AgentBrowserOptions;
669
+ }): Promise<DaemonResponse> {
670
+ return this.send({
671
+ type: "create",
672
+ id: `create-${Date.now()}`,
673
+ sessionId: options?.sessionId,
674
+ options: options?.browserOptions,
675
+ });
676
+ }
677
+
678
+ /** List all sessions */
679
+ async list(): Promise<DaemonResponse> {
680
+ return this.send({
681
+ type: "list",
682
+ id: `list-${Date.now()}`,
683
+ });
684
+ }
685
+
686
+ /** Close a specific session */
687
+ async closeSession(sessionId: string): Promise<DaemonResponse> {
688
+ return this.send({
689
+ type: "close",
690
+ id: `close-${Date.now()}`,
691
+ sessionId,
692
+ });
693
+ }
694
+
695
+ async command(command: Command, sessionId?: string): Promise<DaemonResponse> {
448
696
  return this.send({
449
697
  type: "command",
450
698
  id: `cmd-${Date.now()}`,
699
+ sessionId: sessionId ?? this.sessionId,
451
700
  command,
452
701
  });
453
702
  }
@@ -455,39 +704,46 @@ export class DaemonClient {
455
704
  async act(
456
705
  actions: StepAction[],
457
706
  options: {
707
+ sessionId?: string;
458
708
  haltOnError?: boolean;
459
709
  includeState?: boolean;
460
710
  includeStateText?: boolean;
461
711
  stateOptions?: z.infer<typeof getStateOptionsSchema>;
462
712
  } = {},
463
713
  ): Promise<DaemonResponse> {
714
+ const { sessionId, ...rest } = options;
464
715
  return this.send({
465
716
  type: "act",
466
717
  id: `act-${Date.now()}`,
718
+ sessionId: sessionId ?? this.sessionId,
467
719
  actions,
468
- ...options,
720
+ ...rest,
469
721
  });
470
722
  }
471
723
 
472
724
  async wait(
473
725
  condition: WaitCondition,
474
726
  options: {
727
+ sessionId?: string;
475
728
  timeoutMs?: number;
476
729
  includeState?: boolean;
477
730
  includeStateText?: boolean;
478
731
  stateOptions?: z.infer<typeof getStateOptionsSchema>;
479
732
  } = {},
480
733
  ): Promise<DaemonResponse> {
734
+ const { sessionId, ...rest } = options;
481
735
  return this.send({
482
736
  type: "wait",
483
737
  id: `wait-${Date.now()}`,
738
+ sessionId: sessionId ?? this.sessionId,
484
739
  condition,
485
- ...options,
740
+ ...rest,
486
741
  });
487
742
  }
488
743
 
489
744
  async state(
490
745
  options: {
746
+ sessionId?: string;
491
747
  format?: "json" | "text";
492
748
  stateOptions?: z.infer<typeof getStateOptionsSchema>;
493
749
  } = {},
@@ -495,6 +751,7 @@ export class DaemonClient {
495
751
  return this.send({
496
752
  type: "state",
497
753
  id: `state-${Date.now()}`,
754
+ sessionId: options.sessionId ?? this.sessionId,
498
755
  options: options.stateOptions,
499
756
  format: options.format,
500
757
  });
@@ -505,13 +762,19 @@ export class DaemonClient {
505
762
  }
506
763
 
507
764
  async screenshot(options?: {
765
+ sessionId?: string;
508
766
  fullPage?: boolean;
509
767
  path?: string;
510
768
  }): Promise<DaemonResponse> {
511
769
  return this.send({
512
770
  type: "command",
513
771
  id: `screenshot-${Date.now()}`,
514
- command: { type: "screenshot", ...options },
772
+ sessionId: options?.sessionId ?? this.sessionId,
773
+ command: {
774
+ type: "screenshot",
775
+ fullPage: options?.fullPage,
776
+ path: options?.path,
777
+ },
515
778
  });
516
779
  }
517
780
  }
@@ -520,30 +783,67 @@ export class DaemonClient {
520
783
  // Daemon Spawner
521
784
  // ============================================================================
522
785
 
786
+ /**
787
+ * Ensure daemon is running and return a client.
788
+ * If sessionId is provided, set the client to use that session.
789
+ * If createIfMissing is true (default), create the "default" session if it doesn't exist.
790
+ */
523
791
  export async function ensureDaemon(
524
- session = "default",
792
+ sessionId = "default",
525
793
  browserOptions?: AgentBrowserOptions,
794
+ options?: { createIfMissing?: boolean },
526
795
  ): Promise<DaemonClient> {
527
- const client = new DaemonClient(session);
796
+ const client = new DaemonClient(sessionId);
797
+ const createIfMissing = options?.createIfMissing ?? true;
528
798
 
529
- // Check if already running
530
- if (isDaemonRunning(session)) {
799
+ // Check if daemon is already running
800
+ if (isDaemonRunning()) {
531
801
  // Verify it's responsive
532
802
  if (await client.ping()) {
803
+ // Daemon is running, check if session exists or create default
804
+ if (createIfMissing && sessionId === "default") {
805
+ const listResp = await client.list();
806
+ if (listResp.success) {
807
+ const sessions = (
808
+ listResp.data as { sessions: Array<{ id: string }> }
809
+ ).sessions;
810
+ const exists = sessions.some((s) => s.id === sessionId);
811
+ if (!exists) {
812
+ // Create the default session
813
+ const createResp = await client.create({
814
+ sessionId: "default",
815
+ browserOptions,
816
+ });
817
+ if (!createResp.success) {
818
+ throw new Error(`Failed to create session: ${createResp.error}`);
819
+ }
820
+ }
821
+ }
822
+ }
533
823
  return client;
534
824
  }
535
825
  // Not responsive, clean up
536
- cleanupDaemonFiles(session);
826
+ cleanupDaemonFiles();
537
827
  }
538
828
 
539
829
  // Spawn new daemon
540
- await spawnDaemon(session, browserOptions);
830
+ await spawnDaemon(browserOptions);
541
831
 
542
832
  // Wait for daemon to be ready
543
833
  const maxAttempts = 50; // 5 seconds
544
834
  for (let i = 0; i < maxAttempts; i++) {
545
835
  await new Promise((r) => setTimeout(r, 100));
546
836
  if (await client.ping()) {
837
+ // Create the initial default session
838
+ if (createIfMissing && sessionId === "default") {
839
+ const createResp = await client.create({
840
+ sessionId: "default",
841
+ browserOptions,
842
+ });
843
+ if (!createResp.success) {
844
+ throw new Error(`Failed to create session: ${createResp.error}`);
845
+ }
846
+ }
547
847
  return client;
548
848
  }
549
849
  }
@@ -551,17 +851,62 @@ export async function ensureDaemon(
551
851
  throw new Error("Failed to start daemon");
552
852
  }
553
853
 
554
- async function spawnDaemon(
555
- session: string,
854
+ /**
855
+ * Ensure daemon is running and create a NEW session with auto-generated ID.
856
+ * Returns the client with the new session ID set.
857
+ */
858
+ export async function ensureDaemonNewSession(
556
859
  browserOptions?: AgentBrowserOptions,
860
+ ): Promise<DaemonClient> {
861
+ const client = new DaemonClient();
862
+
863
+ // Check if daemon is already running
864
+ if (isDaemonRunning()) {
865
+ if (await client.ping()) {
866
+ // Create new session with auto-generated ID
867
+ const createResp = await client.create({ browserOptions });
868
+ if (!createResp.success) {
869
+ throw new Error(`Failed to create session: ${createResp.error}`);
870
+ }
871
+ const newSessionId = (createResp.data as { sessionId: string }).sessionId;
872
+ client.setSession(newSessionId);
873
+ return client;
874
+ }
875
+ cleanupDaemonFiles();
876
+ }
877
+
878
+ // Spawn new daemon
879
+ await spawnDaemon(browserOptions);
880
+
881
+ // Wait for daemon to be ready
882
+ const maxAttempts = 50;
883
+ for (let i = 0; i < maxAttempts; i++) {
884
+ await new Promise((r) => setTimeout(r, 100));
885
+ if (await client.ping()) {
886
+ // Create new session with auto-generated ID
887
+ const createResp = await client.create({ browserOptions });
888
+ if (!createResp.success) {
889
+ throw new Error(`Failed to create session: ${createResp.error}`);
890
+ }
891
+ const newSessionId = (createResp.data as { sessionId: string }).sessionId;
892
+ client.setSession(newSessionId);
893
+ return client;
894
+ }
895
+ }
896
+
897
+ throw new Error("Failed to start daemon");
898
+ }
899
+
900
+ async function spawnDaemon(
901
+ defaultBrowserOptions?: AgentBrowserOptions,
557
902
  ): Promise<void> {
558
- const configPath = getConfigPath(session);
903
+ const configPath = getConfigPath();
559
904
  ensureDaemonDir();
560
905
 
561
906
  // Write config for daemon to read
562
907
  fs.writeFileSync(
563
908
  configPath,
564
- JSON.stringify({ session, browserOptions: browserOptions ?? {} }),
909
+ JSON.stringify({ defaultBrowserOptions: defaultBrowserOptions ?? {} }),
565
910
  );
566
911
 
567
912
  // Spawn detached process
@@ -569,14 +914,7 @@ async function spawnDaemon(
569
914
 
570
915
  const child = spawn(
571
916
  process.execPath,
572
- [
573
- "--bun",
574
- import.meta.dirname + "/daemon-entry.ts",
575
- "--session",
576
- session,
577
- "--config",
578
- configPath,
579
- ],
917
+ ["--bun", import.meta.dirname + "/daemon-entry.ts", "--config", configPath],
580
918
  {
581
919
  detached: true,
582
920
  stdio: "ignore",
@@ -597,29 +935,27 @@ if (
597
935
  ) {
598
936
  // Parse args
599
937
  const args = process.argv.slice(2);
600
- let session = "default";
601
938
  let configPath: string | undefined;
602
939
 
603
940
  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]) {
941
+ if (args[i] === "--config" && args[i + 1]) {
608
942
  configPath = args[i + 1];
609
943
  i++;
610
944
  }
611
945
  }
612
946
 
613
- let browserOptions: AgentBrowserOptions = {};
947
+ let daemonOptions: DaemonOptions = {};
614
948
  if (configPath && fs.existsSync(configPath)) {
615
949
  try {
616
950
  const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
617
- browserOptions = config.browserOptions ?? {};
618
- session = config.session ?? session;
951
+ daemonOptions = {
952
+ defaultBrowserOptions: config.defaultBrowserOptions ?? {},
953
+ sessionTtlMs: config.sessionTtlMs,
954
+ };
619
955
  } catch {}
620
956
  }
621
957
 
622
- startDaemon({ session, browserOptions }).catch((err) => {
958
+ startDaemon(daemonOptions).catch((err) => {
623
959
  console.error("Failed to start daemon:", err);
624
960
  process.exit(1);
625
961
  });