agent-browser-loop 0.2.2 → 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,6 +248,8 @@ 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;
@@ -250,14 +266,34 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
250
266
  return session;
251
267
  }
252
268
 
253
- async function closeSession(sessionId: string): Promise<void> {
269
+ async function closeSession(
270
+ sessionId: string,
271
+ ): Promise<{ profileSaved?: string }> {
254
272
  const session = sessions.get(sessionId);
255
273
  if (!session) {
256
274
  throw new Error(`Session not found: ${sessionId}`);
257
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
+
258
292
  await session.browser.stop();
259
293
  sessions.delete(sessionId);
260
294
  idGenerator.release(sessionId);
295
+
296
+ return { profileSaved };
261
297
  }
262
298
 
263
299
  const shutdown = async () => {
@@ -398,10 +434,10 @@ async function handleRequest(
398
434
  sessions: Map<string, DaemonSession>,
399
435
  createSession: (
400
436
  sessionId?: string,
401
- options?: AgentBrowserOptions,
437
+ options?: AgentBrowserOptions & { profile?: string; noSave?: boolean },
402
438
  ) => Promise<DaemonSession>,
403
439
  getOrDefaultSession: (sessionId?: string) => DaemonSession,
404
- closeSession: (sessionId: string) => Promise<void>,
440
+ closeSession: (sessionId: string) => Promise<{ profileSaved?: string }>,
405
441
  ): Promise<DaemonResponse> {
406
442
  const { id } = request;
407
443
 
@@ -439,8 +475,12 @@ async function handleRequest(
439
475
  }
440
476
 
441
477
  case "close": {
442
- await closeSession(request.sessionId);
443
- 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
+ };
444
484
  }
445
485
 
446
486
  case "command": {
@@ -451,7 +491,12 @@ async function handleRequest(
451
491
  const result = await executeCommand(session.browser, request.command);
452
492
  // Handle close command - close the session
453
493
  if (request.command.type === "close") {
454
- 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
+ };
455
500
  }
456
501
  return { id, success: true, data: result };
457
502
  } finally {
package/src/index.ts CHANGED
@@ -35,6 +35,21 @@ 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";
38
53
  export type { ElementSelectors, StoredElementRef } from "./ref-store";
39
54
  // Ref store for server-side element reference management
40
55
  export { ElementRefStore } from "./ref-store";
@@ -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
+ }