agent-browser-loop 0.2.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/src/daemon.ts CHANGED
@@ -19,7 +19,9 @@ import {
19
19
  waitConditionSchema,
20
20
  } from "./commands";
21
21
  import { createIdGenerator } from "./id";
22
+ import { saveProfile } from "./profiles";
22
23
  import { formatStateText } from "./state";
24
+ import type { StorageState } from "./types";
23
25
 
24
26
  // ============================================================================
25
27
  // Daemon Protocol
@@ -37,6 +39,10 @@ const browserOptionsSchema = z
37
39
  captureNetwork: z.boolean().optional(),
38
40
  networkLogLimit: z.number().optional(),
39
41
  storageStatePath: z.string().optional(),
42
+ // Profile to load and save back on close
43
+ profile: z.string().optional(),
44
+ // If true, don't save profile on close (read-only)
45
+ noSave: z.boolean().optional(),
40
46
  })
41
47
  .optional();
42
48
 
@@ -120,6 +126,10 @@ type DaemonSession = {
120
126
  lastUsed: number;
121
127
  busy: boolean;
122
128
  options: AgentBrowserOptions;
129
+ // Profile name to save back on close (if set)
130
+ profile?: string;
131
+ // If true, don't save profile on close
132
+ noSave?: boolean;
123
133
  };
124
134
 
125
135
  // ============================================================================
@@ -219,13 +229,17 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
219
229
  // Session helpers
220
230
  async function createSession(
221
231
  sessionId?: string,
222
- browserOptions?: AgentBrowserOptions,
232
+ browserOptions?: AgentBrowserOptions & {
233
+ profile?: string;
234
+ noSave?: boolean;
235
+ },
223
236
  ): Promise<DaemonSession> {
224
237
  const id = sessionId ?? idGenerator.next();
225
238
  if (sessions.has(id)) {
226
239
  throw new Error(`Session already exists: ${id}`);
227
240
  }
228
- const mergedOptions = { ...defaultOptions, ...browserOptions };
241
+ const { profile, noSave, ...restOptions } = browserOptions ?? {};
242
+ const mergedOptions = { ...defaultOptions, ...restOptions };
229
243
  const browser = createBrowser(mergedOptions);
230
244
  await browser.start();
231
245
  const session: DaemonSession = {
@@ -234,19 +248,13 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
234
248
  lastUsed: Date.now(),
235
249
  busy: false,
236
250
  options: mergedOptions,
251
+ profile,
252
+ noSave,
237
253
  };
238
254
  sessions.set(id, session);
239
255
  return session;
240
256
  }
241
257
 
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
- }
249
-
250
258
  function getOrDefaultSession(sessionId?: string): DaemonSession {
251
259
  const id = sessionId ?? "default";
252
260
  const session = sessions.get(id);
@@ -258,14 +266,34 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
258
266
  return session;
259
267
  }
260
268
 
261
- async function closeSession(sessionId: string): Promise<void> {
269
+ async function closeSession(
270
+ sessionId: string,
271
+ ): Promise<{ profileSaved?: string }> {
262
272
  const session = sessions.get(sessionId);
263
273
  if (!session) {
264
274
  throw new Error(`Session not found: ${sessionId}`);
265
275
  }
276
+
277
+ let profileSaved: string | undefined;
278
+
279
+ // Save profile if one was loaded and noSave is not set
280
+ if (session.profile && !session.noSave) {
281
+ try {
282
+ const storageState =
283
+ (await session.browser.saveStorageState()) as StorageState;
284
+ saveProfile(session.profile, storageState);
285
+ profileSaved = session.profile;
286
+ } catch (err) {
287
+ // Log but don't fail the close
288
+ console.error(`Failed to save profile ${session.profile}:`, err);
289
+ }
290
+ }
291
+
266
292
  await session.browser.stop();
267
293
  sessions.delete(sessionId);
268
294
  idGenerator.release(sessionId);
295
+
296
+ return { profileSaved };
269
297
  }
270
298
 
271
299
  const shutdown = async () => {
@@ -333,16 +361,13 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
333
361
  parseResult.data,
334
362
  sessions,
335
363
  createSession,
336
- getSession,
337
364
  getOrDefaultSession,
338
365
  closeSession,
339
- idGenerator,
340
- defaultOptions,
341
366
  );
342
367
 
343
368
  // Handle shutdown
344
369
  if (parseResult.data.type === "shutdown") {
345
- socket.write(JSON.stringify(response) + "\n");
370
+ socket.write(`${JSON.stringify(response)}\n`);
346
371
  shutdown();
347
372
  return;
348
373
  }
@@ -355,7 +380,7 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
355
380
  };
356
381
  }
357
382
 
358
- socket.write(JSON.stringify(response) + "\n");
383
+ socket.write(`${JSON.stringify(response)}\n`);
359
384
  }
360
385
  });
361
386
 
@@ -409,13 +434,10 @@ async function handleRequest(
409
434
  sessions: Map<string, DaemonSession>,
410
435
  createSession: (
411
436
  sessionId?: string,
412
- options?: AgentBrowserOptions,
437
+ options?: AgentBrowserOptions & { profile?: string; noSave?: boolean },
413
438
  ) => Promise<DaemonSession>,
414
- getSession: (sessionId: string) => DaemonSession,
415
439
  getOrDefaultSession: (sessionId?: string) => DaemonSession,
416
- closeSession: (sessionId: string) => Promise<void>,
417
- idGenerator: ReturnType<typeof createIdGenerator>,
418
- defaultOptions: AgentBrowserOptions,
440
+ closeSession: (sessionId: string) => Promise<{ profileSaved?: string }>,
419
441
  ): Promise<DaemonResponse> {
420
442
  const { id } = request;
421
443
 
@@ -453,8 +475,12 @@ async function handleRequest(
453
475
  }
454
476
 
455
477
  case "close": {
456
- await closeSession(request.sessionId);
457
- return { id, success: true, data: { closed: request.sessionId } };
478
+ const { profileSaved } = await closeSession(request.sessionId);
479
+ return {
480
+ id,
481
+ success: true,
482
+ data: { closed: request.sessionId, profileSaved },
483
+ };
458
484
  }
459
485
 
460
486
  case "command": {
@@ -465,7 +491,12 @@ async function handleRequest(
465
491
  const result = await executeCommand(session.browser, request.command);
466
492
  // Handle close command - close the session
467
493
  if (request.command.type === "close") {
468
- await closeSession(session.id);
494
+ const { profileSaved } = await closeSession(session.id);
495
+ return {
496
+ id,
497
+ success: true,
498
+ data: { ...((result as object) ?? {}), profileSaved },
499
+ };
469
500
  }
470
501
  return { id, success: true, data: result };
471
502
  } finally {
@@ -613,7 +644,7 @@ export class DaemonClient {
613
644
  let buffer = "";
614
645
 
615
646
  socket.on("connect", () => {
616
- socket.write(JSON.stringify(request) + "\n");
647
+ socket.write(`${JSON.stringify(request)}\n`);
617
648
  });
618
649
 
619
650
  socket.on("data", (data) => {
@@ -914,7 +945,7 @@ async function spawnDaemon(
914
945
 
915
946
  const child = spawn(
916
947
  process.execPath,
917
- ["--bun", import.meta.dirname + "/daemon-entry.ts", "--config", configPath],
948
+ ["--bun", `${import.meta.dirname}/daemon-entry.ts`, "--config", configPath],
918
949
  {
919
950
  detached: true,
920
951
  stdio: "ignore",
package/src/index.ts CHANGED
@@ -35,6 +35,24 @@ export {
35
35
  isDaemonRunning,
36
36
  startDaemon,
37
37
  } from "./daemon";
38
+ // Profiles
39
+ export {
40
+ deleteProfile,
41
+ importProfile,
42
+ listProfiles,
43
+ loadProfile,
44
+ loadStorageState,
45
+ type Profile,
46
+ type ProfileInfo,
47
+ type ProfileMeta,
48
+ resolveProfilePath,
49
+ resolveStorageStateOption,
50
+ saveProfile,
51
+ touchProfile,
52
+ } from "./profiles";
53
+ export type { ElementSelectors, StoredElementRef } from "./ref-store";
54
+ // Ref store for server-side element reference management
55
+ export { ElementRefStore } from "./ref-store";
38
56
  // Server
39
57
  export type { BrowserServerConfig } from "./server";
40
58
  export { startBrowserServer } from "./server";
@@ -0,0 +1,414 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { z } from "zod";
5
+ import type { StorageState } from "./types";
6
+
7
+ // ============================================================================
8
+ // Profile Schema & Types
9
+ // ============================================================================
10
+
11
+ const profileMetaSchema = z.object({
12
+ createdAt: z.string().optional(),
13
+ lastUsedAt: z.string().optional(),
14
+ description: z.string().optional(),
15
+ origins: z.array(z.string()).optional(),
16
+ });
17
+
18
+ const storageStateSchema = z.object({
19
+ cookies: z
20
+ .array(
21
+ z.object({
22
+ name: z.string(),
23
+ value: z.string(),
24
+ domain: z.string(),
25
+ path: z.string(),
26
+ expires: z.number(),
27
+ httpOnly: z.boolean(),
28
+ secure: z.boolean(),
29
+ sameSite: z.enum(["Strict", "Lax", "None"]),
30
+ }),
31
+ )
32
+ .default([]),
33
+ origins: z
34
+ .array(
35
+ z.object({
36
+ origin: z.string(),
37
+ localStorage: z
38
+ .array(z.object({ name: z.string(), value: z.string() }))
39
+ .default([]),
40
+ }),
41
+ )
42
+ .default([]),
43
+ });
44
+
45
+ const profileSchema = z.object({
46
+ _meta: profileMetaSchema.optional(),
47
+ cookies: storageStateSchema.shape.cookies,
48
+ origins: storageStateSchema.shape.origins,
49
+ });
50
+
51
+ export type ProfileMeta = z.infer<typeof profileMetaSchema>;
52
+ export type Profile = z.infer<typeof profileSchema>;
53
+
54
+ export interface ProfileInfo {
55
+ name: string;
56
+ scope: "local" | "local-private" | "global";
57
+ path: string;
58
+ meta?: ProfileMeta;
59
+ }
60
+
61
+ // ============================================================================
62
+ // Path Resolution
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Get the global profiles directory
67
+ * Uses XDG_CONFIG_HOME or falls back to ~/.config
68
+ */
69
+ function getGlobalProfilesDir(): string {
70
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
71
+ const base = xdgConfig || path.join(os.homedir(), ".config");
72
+ return path.join(base, "agent-browser", "profiles");
73
+ }
74
+
75
+ /**
76
+ * Get the local profiles directory (project-scoped)
77
+ */
78
+ function getLocalProfilesDir(cwd?: string): string {
79
+ return path.join(cwd || process.cwd(), ".agent-browser", "profiles");
80
+ }
81
+
82
+ /**
83
+ * Get the local private profiles directory (gitignored)
84
+ */
85
+ function getLocalPrivateProfilesDir(cwd?: string): string {
86
+ return path.join(getLocalProfilesDir(cwd), ".private");
87
+ }
88
+
89
+ /**
90
+ * Resolve profile path by name, checking local first then global
91
+ * Returns null if not found
92
+ */
93
+ export function resolveProfilePath(
94
+ name: string,
95
+ cwd?: string,
96
+ ): { path: string; scope: "local" | "local-private" | "global" } | null {
97
+ // Check local private first
98
+ const localPrivatePath = path.join(
99
+ getLocalPrivateProfilesDir(cwd),
100
+ `${name}.json`,
101
+ );
102
+ if (fs.existsSync(localPrivatePath)) {
103
+ return { path: localPrivatePath, scope: "local-private" };
104
+ }
105
+
106
+ // Check local
107
+ const localPath = path.join(getLocalProfilesDir(cwd), `${name}.json`);
108
+ if (fs.existsSync(localPath)) {
109
+ return { path: localPath, scope: "local" };
110
+ }
111
+
112
+ // Check global
113
+ const globalPath = path.join(getGlobalProfilesDir(), `${name}.json`);
114
+ if (fs.existsSync(globalPath)) {
115
+ return { path: globalPath, scope: "global" };
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Get the path where a new profile should be saved
123
+ */
124
+ export function getProfileSavePath(
125
+ name: string,
126
+ options?: { global?: boolean; private?: boolean; cwd?: string },
127
+ ): string {
128
+ if (options?.global) {
129
+ return path.join(getGlobalProfilesDir(), `${name}.json`);
130
+ }
131
+ if (options?.private) {
132
+ return path.join(getLocalPrivateProfilesDir(options?.cwd), `${name}.json`);
133
+ }
134
+ return path.join(getLocalProfilesDir(options?.cwd), `${name}.json`);
135
+ }
136
+
137
+ // ============================================================================
138
+ // Profile CRUD Operations
139
+ // ============================================================================
140
+
141
+ /**
142
+ * List all available profiles (local + global)
143
+ */
144
+ export function listProfiles(cwd?: string): ProfileInfo[] {
145
+ const profiles: ProfileInfo[] = [];
146
+ const seen = new Set<string>();
147
+
148
+ // Helper to scan a directory
149
+ const scanDir = (
150
+ dir: string,
151
+ scope: "local" | "local-private" | "global",
152
+ ) => {
153
+ if (!fs.existsSync(dir)) return;
154
+ const files = fs.readdirSync(dir);
155
+ for (const file of files) {
156
+ if (!file.endsWith(".json")) continue;
157
+ // Skip .private directory when scanning local
158
+ if (file === ".private") continue;
159
+
160
+ const name = file.replace(/\.json$/, "");
161
+ if (seen.has(name)) continue; // Local takes precedence
162
+ seen.add(name);
163
+
164
+ const profilePath = path.join(dir, file);
165
+ try {
166
+ const content = JSON.parse(fs.readFileSync(profilePath, "utf-8"));
167
+ const parsed = profileSchema.safeParse(content);
168
+ profiles.push({
169
+ name,
170
+ scope,
171
+ path: profilePath,
172
+ meta: parsed.success ? parsed.data._meta : undefined,
173
+ });
174
+ } catch {
175
+ // Invalid profile, still list it
176
+ profiles.push({
177
+ name,
178
+ scope,
179
+ path: profilePath,
180
+ });
181
+ }
182
+ }
183
+ };
184
+
185
+ // Scan in order of precedence
186
+ scanDir(getLocalPrivateProfilesDir(cwd), "local-private");
187
+ scanDir(getLocalProfilesDir(cwd), "local");
188
+ scanDir(getGlobalProfilesDir(), "global");
189
+
190
+ return profiles.sort((a, b) => a.name.localeCompare(b.name));
191
+ }
192
+
193
+ /**
194
+ * Load a profile by name
195
+ */
196
+ export function loadProfile(name: string, cwd?: string): Profile | null {
197
+ const resolved = resolveProfilePath(name, cwd);
198
+ if (!resolved) return null;
199
+
200
+ try {
201
+ const content = JSON.parse(fs.readFileSync(resolved.path, "utf-8"));
202
+ const parsed = profileSchema.parse(content);
203
+ return parsed;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Get storage state from a profile (strips _meta for Playwright)
211
+ */
212
+ export function getStorageStateFromProfile(profile: Profile): StorageState {
213
+ return {
214
+ cookies: profile.cookies,
215
+ origins: profile.origins,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Load storage state by profile name
221
+ */
222
+ export function loadStorageState(
223
+ name: string,
224
+ cwd?: string,
225
+ ): StorageState | null {
226
+ const profile = loadProfile(name, cwd);
227
+ if (!profile) return null;
228
+ return getStorageStateFromProfile(profile);
229
+ }
230
+
231
+ /**
232
+ * Save a profile
233
+ */
234
+ export function saveProfile(
235
+ name: string,
236
+ storageState: StorageState,
237
+ options?: {
238
+ global?: boolean;
239
+ private?: boolean;
240
+ cwd?: string;
241
+ description?: string;
242
+ origins?: string[];
243
+ },
244
+ ): string {
245
+ const savePath = getProfileSavePath(name, options);
246
+ const dir = path.dirname(savePath);
247
+
248
+ // Ensure directory exists
249
+ if (!fs.existsSync(dir)) {
250
+ fs.mkdirSync(dir, { recursive: true });
251
+ }
252
+
253
+ // If saving to .private, ensure it's gitignored
254
+ if (options?.private) {
255
+ ensurePrivateDirGitignored(options?.cwd);
256
+ }
257
+
258
+ // Check if profile already exists to preserve metadata
259
+ let existingMeta: ProfileMeta | undefined;
260
+ if (fs.existsSync(savePath)) {
261
+ try {
262
+ const existing = JSON.parse(fs.readFileSync(savePath, "utf-8"));
263
+ existingMeta = existing._meta;
264
+ } catch {
265
+ // Ignore
266
+ }
267
+ }
268
+
269
+ const profile: Profile = {
270
+ _meta: {
271
+ createdAt: existingMeta?.createdAt || new Date().toISOString(),
272
+ lastUsedAt: new Date().toISOString(),
273
+ description: options?.description || existingMeta?.description,
274
+ origins: options?.origins || existingMeta?.origins,
275
+ },
276
+ cookies: storageState.cookies,
277
+ origins: storageState.origins,
278
+ };
279
+
280
+ fs.writeFileSync(savePath, JSON.stringify(profile, null, 2));
281
+ return savePath;
282
+ }
283
+
284
+ /**
285
+ * Delete a profile
286
+ */
287
+ export function deleteProfile(name: string, cwd?: string): boolean {
288
+ const resolved = resolveProfilePath(name, cwd);
289
+ if (!resolved) return false;
290
+
291
+ try {
292
+ fs.unlinkSync(resolved.path);
293
+ return true;
294
+ } catch {
295
+ return false;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Import a profile from an external file
301
+ */
302
+ export function importProfile(
303
+ name: string,
304
+ sourcePath: string,
305
+ options?: { global?: boolean; private?: boolean; cwd?: string },
306
+ ): string {
307
+ const content = fs.readFileSync(sourcePath, "utf-8");
308
+ const parsed = JSON.parse(content);
309
+
310
+ // Validate it's a valid storage state or profile
311
+ const asProfile = profileSchema.safeParse(parsed);
312
+ const asStorageState = storageStateSchema.safeParse(parsed);
313
+
314
+ let storageState: StorageState;
315
+ if (asProfile.success) {
316
+ storageState = getStorageStateFromProfile(asProfile.data);
317
+ } else if (asStorageState.success) {
318
+ storageState = asStorageState.data;
319
+ } else {
320
+ throw new Error(
321
+ `Invalid profile format: ${asProfile.error?.message || asStorageState.error?.message}`,
322
+ );
323
+ }
324
+
325
+ return saveProfile(name, storageState, options);
326
+ }
327
+
328
+ /**
329
+ * Update profile's lastUsedAt timestamp
330
+ */
331
+ export function touchProfile(name: string, cwd?: string): void {
332
+ const resolved = resolveProfilePath(name, cwd);
333
+ if (!resolved) return;
334
+
335
+ try {
336
+ const content = JSON.parse(fs.readFileSync(resolved.path, "utf-8"));
337
+ const parsed = profileSchema.parse(content);
338
+
339
+ parsed._meta = {
340
+ ...parsed._meta,
341
+ lastUsedAt: new Date().toISOString(),
342
+ };
343
+
344
+ fs.writeFileSync(resolved.path, JSON.stringify(parsed, null, 2));
345
+ } catch {
346
+ // Ignore errors
347
+ }
348
+ }
349
+
350
+ // ============================================================================
351
+ // Gitignore Helper
352
+ // ============================================================================
353
+
354
+ /**
355
+ * Ensure .private directory is gitignored
356
+ */
357
+ function ensurePrivateDirGitignored(cwd?: string): void {
358
+ const agentBrowserDir = path.join(cwd || process.cwd(), ".agent-browser");
359
+ const gitignorePath = path.join(agentBrowserDir, ".gitignore");
360
+
361
+ // Check if .gitignore exists and contains .private
362
+ if (fs.existsSync(gitignorePath)) {
363
+ const content = fs.readFileSync(gitignorePath, "utf-8");
364
+ if (content.includes("profiles/.private")) {
365
+ return;
366
+ }
367
+ // Append to existing
368
+ fs.appendFileSync(
369
+ gitignorePath,
370
+ "\n# Private profiles (contains auth tokens)\nprofiles/.private/\n",
371
+ );
372
+ } else {
373
+ // Create new .gitignore
374
+ if (!fs.existsSync(agentBrowserDir)) {
375
+ fs.mkdirSync(agentBrowserDir, { recursive: true });
376
+ }
377
+ fs.writeFileSync(
378
+ gitignorePath,
379
+ "# Private profiles (contains auth tokens)\nprofiles/.private/\n",
380
+ );
381
+ }
382
+ }
383
+
384
+ // ============================================================================
385
+ // Profile Resolution for Browser Options
386
+ // ============================================================================
387
+
388
+ /**
389
+ * Resolve storage state from profile name or path
390
+ * Returns the storage state to pass to browser context
391
+ */
392
+ export function resolveStorageStateOption(
393
+ profile?: string,
394
+ storageStatePath?: string,
395
+ cwd?: string,
396
+ ): StorageState | string | undefined {
397
+ // Profile takes precedence
398
+ if (profile) {
399
+ const storageState = loadStorageState(profile, cwd);
400
+ if (!storageState) {
401
+ throw new Error(`Profile not found: ${profile}`);
402
+ }
403
+ // Touch the profile to update lastUsedAt
404
+ touchProfile(profile, cwd);
405
+ return storageState;
406
+ }
407
+
408
+ // Fall back to storageStatePath
409
+ if (storageStatePath) {
410
+ return storageStatePath;
411
+ }
412
+
413
+ return undefined;
414
+ }