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/.claude/skills/agent-browser-loop/REFERENCE.md +37 -20
- package/.claude/skills/agent-browser-loop/SKILL.md +14 -4
- package/README.md +23 -4
- package/package.json +1 -1
- package/src/cli.ts +157 -21
- package/src/daemon.ts +482 -146
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
|
-
|
|
89
|
-
|
|
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(
|
|
93
|
-
return path.join(DAEMON_DIR,
|
|
142
|
+
export function getPidPath(): string {
|
|
143
|
+
return path.join(DAEMON_DIR, "daemon.pid");
|
|
94
144
|
}
|
|
95
145
|
|
|
96
|
-
export function getConfigPath(
|
|
97
|
-
return path.join(DAEMON_DIR,
|
|
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(
|
|
105
|
-
const pidPath = getPidPath(
|
|
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(
|
|
167
|
+
cleanupDaemonFiles();
|
|
118
168
|
return false;
|
|
119
169
|
}
|
|
120
170
|
}
|
|
121
171
|
|
|
122
|
-
export function cleanupDaemonFiles(
|
|
123
|
-
const socketPath = getSocketPath(
|
|
124
|
-
const pidPath = getPidPath(
|
|
125
|
-
const configPath = getConfigPath(
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
const configPath = getConfigPath(session);
|
|
200
|
+
const socketPath = getSocketPath();
|
|
201
|
+
const pidPath = getPidPath();
|
|
202
|
+
const configPath = getConfigPath();
|
|
152
203
|
|
|
153
204
|
ensureDaemonDir();
|
|
154
|
-
cleanupDaemonFiles(
|
|
205
|
+
cleanupDaemonFiles();
|
|
155
206
|
|
|
156
|
-
//
|
|
157
|
-
const
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
395
|
+
cleanupDaemonFiles();
|
|
273
396
|
process.exit(1);
|
|
274
397
|
});
|
|
275
398
|
|
|
276
399
|
process.on("exit", () => {
|
|
277
|
-
cleanupDaemonFiles(
|
|
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 "
|
|
301
|
-
const
|
|
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 "
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
|
|
455
|
+
case "close": {
|
|
456
|
+
await closeSession(request.sessionId);
|
|
457
|
+
return { id, success: true, data: { closed: request.sessionId } };
|
|
458
|
+
}
|
|
345
459
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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 "
|
|
368
|
-
const
|
|
369
|
-
|
|
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: {
|
|
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
|
-
|
|
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(
|
|
399
|
-
this.socketPath = getSocketPath(
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
792
|
+
sessionId = "default",
|
|
525
793
|
browserOptions?: AgentBrowserOptions,
|
|
794
|
+
options?: { createIfMissing?: boolean },
|
|
526
795
|
): Promise<DaemonClient> {
|
|
527
|
-
const client = new DaemonClient(
|
|
796
|
+
const client = new DaemonClient(sessionId);
|
|
797
|
+
const createIfMissing = options?.createIfMissing ?? true;
|
|
528
798
|
|
|
529
|
-
// Check if already running
|
|
530
|
-
if (isDaemonRunning(
|
|
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(
|
|
826
|
+
cleanupDaemonFiles();
|
|
537
827
|
}
|
|
538
828
|
|
|
539
829
|
// Spawn new daemon
|
|
540
|
-
await spawnDaemon(
|
|
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
|
-
|
|
555
|
-
|
|
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(
|
|
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({
|
|
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] === "--
|
|
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
|
|
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
|
-
|
|
618
|
-
|
|
951
|
+
daemonOptions = {
|
|
952
|
+
defaultBrowserOptions: config.defaultBrowserOptions ?? {},
|
|
953
|
+
sessionTtlMs: config.sessionTtlMs,
|
|
954
|
+
};
|
|
619
955
|
} catch {}
|
|
620
956
|
}
|
|
621
957
|
|
|
622
|
-
startDaemon(
|
|
958
|
+
startDaemon(daemonOptions).catch((err) => {
|
|
623
959
|
console.error("Failed to start daemon:", err);
|
|
624
960
|
process.exit(1);
|
|
625
961
|
});
|