agent-browser-loop 0.2.2 → 0.3.1
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/.claude/skills/agent-browser-loop/REFERENCE.md +131 -0
- package/.claude/skills/agent-browser-loop/SKILL.md +36 -4
- package/README.md +39 -17
- package/package.json +1 -1
- package/src/browser.ts +22 -0
- package/src/cli.ts +461 -33
- package/src/commands.ts +11 -0
- package/src/daemon.ts +170 -8
- package/src/index.ts +19 -0
- package/src/profiles.ts +414 -0
- package/src/server.ts +148 -0
- package/src/version.ts +19 -0
package/src/daemon.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as net from "node:net";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { VERSION } from "./version";
|
|
6
7
|
import { type AgentBrowserOptions, createBrowser } from "./browser";
|
|
7
8
|
import {
|
|
8
9
|
type Command,
|
|
@@ -19,7 +20,9 @@ import {
|
|
|
19
20
|
waitConditionSchema,
|
|
20
21
|
} from "./commands";
|
|
21
22
|
import { createIdGenerator } from "./id";
|
|
23
|
+
import { saveProfile } from "./profiles";
|
|
22
24
|
import { formatStateText } from "./state";
|
|
25
|
+
import type { StorageState } from "./types";
|
|
23
26
|
|
|
24
27
|
// ============================================================================
|
|
25
28
|
// Daemon Protocol
|
|
@@ -37,6 +40,10 @@ const browserOptionsSchema = z
|
|
|
37
40
|
captureNetwork: z.boolean().optional(),
|
|
38
41
|
networkLogLimit: z.number().optional(),
|
|
39
42
|
storageStatePath: z.string().optional(),
|
|
43
|
+
// Profile to load and save back on close
|
|
44
|
+
profile: z.string().optional(),
|
|
45
|
+
// If true, don't save profile on close (read-only)
|
|
46
|
+
noSave: z.boolean().optional(),
|
|
40
47
|
})
|
|
41
48
|
.optional();
|
|
42
49
|
|
|
@@ -120,6 +127,10 @@ type DaemonSession = {
|
|
|
120
127
|
lastUsed: number;
|
|
121
128
|
busy: boolean;
|
|
122
129
|
options: AgentBrowserOptions;
|
|
130
|
+
// Profile name to save back on close (if set)
|
|
131
|
+
profile?: string;
|
|
132
|
+
// If true, don't save profile on close
|
|
133
|
+
noSave?: boolean;
|
|
123
134
|
};
|
|
124
135
|
|
|
125
136
|
// ============================================================================
|
|
@@ -147,6 +158,25 @@ export function getConfigPath(): string {
|
|
|
147
158
|
return path.join(DAEMON_DIR, "daemon.config.json");
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
export function getVersionPath(): string {
|
|
162
|
+
return path.join(DAEMON_DIR, "daemon.version");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get the version of the currently running daemon (if any)
|
|
167
|
+
*/
|
|
168
|
+
export function getDaemonVersion(): string | null {
|
|
169
|
+
const versionPath = getVersionPath();
|
|
170
|
+
if (!fs.existsSync(versionPath)) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
return fs.readFileSync(versionPath, "utf-8").trim();
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
150
180
|
// ============================================================================
|
|
151
181
|
// Daemon Status
|
|
152
182
|
// ============================================================================
|
|
@@ -173,6 +203,7 @@ export function cleanupDaemonFiles(): void {
|
|
|
173
203
|
const socketPath = getSocketPath();
|
|
174
204
|
const pidPath = getPidPath();
|
|
175
205
|
const configPath = getConfigPath();
|
|
206
|
+
const versionPath = getVersionPath();
|
|
176
207
|
|
|
177
208
|
try {
|
|
178
209
|
if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
|
|
@@ -183,6 +214,9 @@ export function cleanupDaemonFiles(): void {
|
|
|
183
214
|
try {
|
|
184
215
|
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
185
216
|
} catch {}
|
|
217
|
+
try {
|
|
218
|
+
if (fs.existsSync(versionPath)) fs.unlinkSync(versionPath);
|
|
219
|
+
} catch {}
|
|
186
220
|
}
|
|
187
221
|
|
|
188
222
|
// ============================================================================
|
|
@@ -200,10 +234,14 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
200
234
|
const socketPath = getSocketPath();
|
|
201
235
|
const pidPath = getPidPath();
|
|
202
236
|
const configPath = getConfigPath();
|
|
237
|
+
const versionPath = getVersionPath();
|
|
203
238
|
|
|
204
239
|
ensureDaemonDir();
|
|
205
240
|
cleanupDaemonFiles();
|
|
206
241
|
|
|
242
|
+
// Write version file so CLI can detect version mismatch
|
|
243
|
+
fs.writeFileSync(versionPath, VERSION);
|
|
244
|
+
|
|
207
245
|
// Multi-session state
|
|
208
246
|
const sessions = new Map<string, DaemonSession>();
|
|
209
247
|
const idGenerator = createIdGenerator();
|
|
@@ -219,13 +257,17 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
219
257
|
// Session helpers
|
|
220
258
|
async function createSession(
|
|
221
259
|
sessionId?: string,
|
|
222
|
-
browserOptions?: AgentBrowserOptions
|
|
260
|
+
browserOptions?: AgentBrowserOptions & {
|
|
261
|
+
profile?: string;
|
|
262
|
+
noSave?: boolean;
|
|
263
|
+
},
|
|
223
264
|
): Promise<DaemonSession> {
|
|
224
265
|
const id = sessionId ?? idGenerator.next();
|
|
225
266
|
if (sessions.has(id)) {
|
|
226
267
|
throw new Error(`Session already exists: ${id}`);
|
|
227
268
|
}
|
|
228
|
-
const
|
|
269
|
+
const { profile, noSave, ...restOptions } = browserOptions ?? {};
|
|
270
|
+
const mergedOptions = { ...defaultOptions, ...restOptions };
|
|
229
271
|
const browser = createBrowser(mergedOptions);
|
|
230
272
|
await browser.start();
|
|
231
273
|
const session: DaemonSession = {
|
|
@@ -234,6 +276,8 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
234
276
|
lastUsed: Date.now(),
|
|
235
277
|
busy: false,
|
|
236
278
|
options: mergedOptions,
|
|
279
|
+
profile,
|
|
280
|
+
noSave,
|
|
237
281
|
};
|
|
238
282
|
sessions.set(id, session);
|
|
239
283
|
return session;
|
|
@@ -250,14 +294,34 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
250
294
|
return session;
|
|
251
295
|
}
|
|
252
296
|
|
|
253
|
-
async function closeSession(
|
|
297
|
+
async function closeSession(
|
|
298
|
+
sessionId: string,
|
|
299
|
+
): Promise<{ profileSaved?: string }> {
|
|
254
300
|
const session = sessions.get(sessionId);
|
|
255
301
|
if (!session) {
|
|
256
302
|
throw new Error(`Session not found: ${sessionId}`);
|
|
257
303
|
}
|
|
304
|
+
|
|
305
|
+
let profileSaved: string | undefined;
|
|
306
|
+
|
|
307
|
+
// Save profile if one was loaded and noSave is not set
|
|
308
|
+
if (session.profile && !session.noSave) {
|
|
309
|
+
try {
|
|
310
|
+
const storageState =
|
|
311
|
+
(await session.browser.saveStorageState()) as StorageState;
|
|
312
|
+
saveProfile(session.profile, storageState);
|
|
313
|
+
profileSaved = session.profile;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
// Log but don't fail the close
|
|
316
|
+
console.error(`Failed to save profile ${session.profile}:`, err);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
258
320
|
await session.browser.stop();
|
|
259
321
|
sessions.delete(sessionId);
|
|
260
322
|
idGenerator.release(sessionId);
|
|
323
|
+
|
|
324
|
+
return { profileSaved };
|
|
261
325
|
}
|
|
262
326
|
|
|
263
327
|
const shutdown = async () => {
|
|
@@ -398,10 +462,10 @@ async function handleRequest(
|
|
|
398
462
|
sessions: Map<string, DaemonSession>,
|
|
399
463
|
createSession: (
|
|
400
464
|
sessionId?: string,
|
|
401
|
-
options?: AgentBrowserOptions,
|
|
465
|
+
options?: AgentBrowserOptions & { profile?: string; noSave?: boolean },
|
|
402
466
|
) => Promise<DaemonSession>,
|
|
403
467
|
getOrDefaultSession: (sessionId?: string) => DaemonSession,
|
|
404
|
-
closeSession: (sessionId: string) => Promise<
|
|
468
|
+
closeSession: (sessionId: string) => Promise<{ profileSaved?: string }>,
|
|
405
469
|
): Promise<DaemonResponse> {
|
|
406
470
|
const { id } = request;
|
|
407
471
|
|
|
@@ -439,8 +503,12 @@ async function handleRequest(
|
|
|
439
503
|
}
|
|
440
504
|
|
|
441
505
|
case "close": {
|
|
442
|
-
await closeSession(request.sessionId);
|
|
443
|
-
return {
|
|
506
|
+
const { profileSaved } = await closeSession(request.sessionId);
|
|
507
|
+
return {
|
|
508
|
+
id,
|
|
509
|
+
success: true,
|
|
510
|
+
data: { closed: request.sessionId, profileSaved },
|
|
511
|
+
};
|
|
444
512
|
}
|
|
445
513
|
|
|
446
514
|
case "command": {
|
|
@@ -451,7 +519,12 @@ async function handleRequest(
|
|
|
451
519
|
const result = await executeCommand(session.browser, request.command);
|
|
452
520
|
// Handle close command - close the session
|
|
453
521
|
if (request.command.type === "close") {
|
|
454
|
-
await closeSession(session.id);
|
|
522
|
+
const { profileSaved } = await closeSession(session.id);
|
|
523
|
+
return {
|
|
524
|
+
id,
|
|
525
|
+
success: true,
|
|
526
|
+
data: { ...((result as object) ?? {}), profileSaved },
|
|
527
|
+
};
|
|
455
528
|
}
|
|
456
529
|
return { id, success: true, data: result };
|
|
457
530
|
} finally {
|
|
@@ -769,6 +842,34 @@ export class DaemonClient {
|
|
|
769
842
|
// Daemon Spawner
|
|
770
843
|
// ============================================================================
|
|
771
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Force restart the daemon by shutting down any existing one
|
|
847
|
+
*/
|
|
848
|
+
async function forceRestartDaemon(
|
|
849
|
+
browserOptions?: AgentBrowserOptions,
|
|
850
|
+
): Promise<void> {
|
|
851
|
+
const client = new DaemonClient();
|
|
852
|
+
|
|
853
|
+
// Try to gracefully shutdown existing daemon
|
|
854
|
+
if (isDaemonRunning()) {
|
|
855
|
+
try {
|
|
856
|
+
if (await client.ping()) {
|
|
857
|
+
await client.shutdown();
|
|
858
|
+
// Wait a bit for shutdown
|
|
859
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
860
|
+
}
|
|
861
|
+
} catch {
|
|
862
|
+
// Ignore errors during shutdown
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Clean up any stale files
|
|
867
|
+
cleanupDaemonFiles();
|
|
868
|
+
|
|
869
|
+
// Spawn fresh daemon
|
|
870
|
+
await spawnDaemon(browserOptions);
|
|
871
|
+
}
|
|
872
|
+
|
|
772
873
|
/**
|
|
773
874
|
* Ensure daemon is running and return a client.
|
|
774
875
|
* If sessionId is provided, set the client to use that session.
|
|
@@ -784,6 +885,41 @@ export async function ensureDaemon(
|
|
|
784
885
|
|
|
785
886
|
// Check if daemon is already running
|
|
786
887
|
if (isDaemonRunning()) {
|
|
888
|
+
// Check for version mismatch - force restart if versions don't match
|
|
889
|
+
const daemonVersion = getDaemonVersion();
|
|
890
|
+
if (daemonVersion && daemonVersion !== VERSION) {
|
|
891
|
+
// If we're not allowed to create sessions, tell user to re-open
|
|
892
|
+
if (!createIfMissing) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
`Daemon was upgraded (${daemonVersion} -> ${VERSION}). Please run 'agent-browser open <url>' to start a new session.`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
console.log(
|
|
899
|
+
`Daemon version mismatch (daemon: ${daemonVersion}, cli: ${VERSION}), restarting...`,
|
|
900
|
+
);
|
|
901
|
+
await forceRestartDaemon(browserOptions);
|
|
902
|
+
|
|
903
|
+
// Wait for new daemon to be ready
|
|
904
|
+
const maxAttempts = 50;
|
|
905
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
906
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
907
|
+
if (await client.ping()) {
|
|
908
|
+
if (sessionId === "default") {
|
|
909
|
+
const createResp = await client.create({
|
|
910
|
+
sessionId: "default",
|
|
911
|
+
browserOptions,
|
|
912
|
+
});
|
|
913
|
+
if (!createResp.success) {
|
|
914
|
+
throw new Error(`Failed to create session: ${createResp.error}`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return client;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
throw new Error("Failed to restart daemon after version mismatch");
|
|
921
|
+
}
|
|
922
|
+
|
|
787
923
|
// Verify it's responsive
|
|
788
924
|
if (await client.ping()) {
|
|
789
925
|
// Daemon is running, check if session exists or create default
|
|
@@ -848,6 +984,32 @@ export async function ensureDaemonNewSession(
|
|
|
848
984
|
|
|
849
985
|
// Check if daemon is already running
|
|
850
986
|
if (isDaemonRunning()) {
|
|
987
|
+
// Check for version mismatch - force restart if versions don't match
|
|
988
|
+
const daemonVersion = getDaemonVersion();
|
|
989
|
+
if (daemonVersion && daemonVersion !== VERSION) {
|
|
990
|
+
console.log(
|
|
991
|
+
`Daemon version mismatch (daemon: ${daemonVersion}, cli: ${VERSION}), restarting...`,
|
|
992
|
+
);
|
|
993
|
+
await forceRestartDaemon(browserOptions);
|
|
994
|
+
|
|
995
|
+
// Wait for new daemon to be ready and create session
|
|
996
|
+
const maxAttempts = 50;
|
|
997
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
998
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
999
|
+
if (await client.ping()) {
|
|
1000
|
+
const createResp = await client.create({ browserOptions });
|
|
1001
|
+
if (!createResp.success) {
|
|
1002
|
+
throw new Error(`Failed to create session: ${createResp.error}`);
|
|
1003
|
+
}
|
|
1004
|
+
const newSessionId = (createResp.data as { sessionId: string })
|
|
1005
|
+
.sessionId;
|
|
1006
|
+
client.setSession(newSessionId);
|
|
1007
|
+
return client;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
throw new Error("Failed to restart daemon after version mismatch");
|
|
1011
|
+
}
|
|
1012
|
+
|
|
851
1013
|
if (await client.ping()) {
|
|
852
1014
|
// Create new session with auto-generated ID
|
|
853
1015
|
const createResp = await client.create({ browserOptions });
|
package/src/index.ts
CHANGED
|
@@ -30,11 +30,30 @@ export {
|
|
|
30
30
|
DaemonClient,
|
|
31
31
|
type DaemonOptions,
|
|
32
32
|
ensureDaemon,
|
|
33
|
+
getDaemonVersion,
|
|
33
34
|
getPidPath,
|
|
34
35
|
getSocketPath,
|
|
36
|
+
getVersionPath,
|
|
35
37
|
isDaemonRunning,
|
|
36
38
|
startDaemon,
|
|
37
39
|
} from "./daemon";
|
|
40
|
+
// Version
|
|
41
|
+
export { VERSION } from "./version";
|
|
42
|
+
// Profiles
|
|
43
|
+
export {
|
|
44
|
+
deleteProfile,
|
|
45
|
+
importProfile,
|
|
46
|
+
listProfiles,
|
|
47
|
+
loadProfile,
|
|
48
|
+
loadStorageState,
|
|
49
|
+
type Profile,
|
|
50
|
+
type ProfileInfo,
|
|
51
|
+
type ProfileMeta,
|
|
52
|
+
resolveProfilePath,
|
|
53
|
+
resolveStorageStateOption,
|
|
54
|
+
saveProfile,
|
|
55
|
+
touchProfile,
|
|
56
|
+
} from "./profiles";
|
|
38
57
|
export type { ElementSelectors, StoredElementRef } from "./ref-store";
|
|
39
58
|
// Ref store for server-side element reference management
|
|
40
59
|
export { ElementRefStore } from "./ref-store";
|