browser-debugging-daemon 1.0.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.
@@ -0,0 +1,689 @@
1
+ import path from "path";
2
+ import { OpenAIPlanner } from "./OpenAIPlanner.js";
3
+
4
+ function compactHistory(history, limit = 8) {
5
+ return history.slice(-limit);
6
+ }
7
+
8
+ function compactElements(elements, limit = 50) {
9
+ return elements.slice(0, limit).map((element) => ({
10
+ id: element.id,
11
+ tag: element.tag,
12
+ text: element.text,
13
+ placeholder: element.placeholder,
14
+ ariaLabel: element.ariaLabel,
15
+ role: element.role,
16
+ type: element.type,
17
+ href: element.href,
18
+ }));
19
+ }
20
+
21
+ function compactDebugState(debugState, limit = 8) {
22
+ return {
23
+ recentConsole: (debugState?.recentConsole || []).slice(-limit),
24
+ recentNetwork: (debugState?.recentNetwork || []).slice(-limit),
25
+ recentErrors: (debugState?.recentErrors || []).slice(-limit),
26
+ capabilities: debugState?.capabilities || null,
27
+ counts: debugState?.counts || {
28
+ console: 0,
29
+ network: 0,
30
+ errors: 0,
31
+ observedElements: 0,
32
+ },
33
+ };
34
+ }
35
+
36
+ function compactOperatorMessages(messages, limit = 8) {
37
+ return (messages || []).slice(-limit).map((message) => ({
38
+ role: message.role,
39
+ content: message.content,
40
+ timestamp: message.timestamp || null,
41
+ }));
42
+ }
43
+
44
+ function defaultVerification() {
45
+ return {
46
+ goal_status: "incomplete",
47
+ confidence: "low",
48
+ summary: "Verification was skipped because no verifier was configured.",
49
+ evidence: [],
50
+ next_hint: "",
51
+ };
52
+ }
53
+
54
+ function formatAction(action) {
55
+ if (!action) return "no-op";
56
+ const detail = action.url || action.text || action.key || action.direction || action.id || "";
57
+ return detail ? `${action.type} (${detail})` : action.type;
58
+ }
59
+
60
+ function toIsoString(timestampMs) {
61
+ if (!Number.isFinite(timestampMs)) return null;
62
+ return new Date(timestampMs).toISOString();
63
+ }
64
+
65
+ function formatDurationMs(durationMs) {
66
+ if (!Number.isFinite(durationMs) || durationMs < 0) return "unknown";
67
+ if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
68
+ return `${(durationMs / 1000).toFixed(2)}s`;
69
+ }
70
+
71
+ function formatVideoTimestamp(seconds) {
72
+ if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
73
+ const total = Math.floor(seconds);
74
+ const mins = Math.floor(total / 60);
75
+ const secs = total % 60;
76
+ return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
77
+ }
78
+
79
+ function toArtifactRelativePath(absolutePath, sessionDir) {
80
+ if (!absolutePath || !sessionDir) {
81
+ return absolutePath || null;
82
+ }
83
+
84
+ try {
85
+ const relative = path.relative(sessionDir, absolutePath);
86
+ if (!relative || relative.startsWith("..")) {
87
+ return absolutePath;
88
+ }
89
+ return relative.split(path.sep).join("/");
90
+ } catch (error) {
91
+ return absolutePath;
92
+ }
93
+ }
94
+
95
+ function buildStructuredTimeline(result) {
96
+ const sessionDir = result.artifacts?.sessionDir || null;
97
+ const primaryVideoPath = result.artifacts?.videoFiles?.[0] || null;
98
+ const primaryVideoRelative = toArtifactRelativePath(primaryVideoPath, sessionDir);
99
+ const history = Array.isArray(result.history) ? result.history : [];
100
+
101
+ const steps = history.map((entry, index) => {
102
+ const screenshotRelative = toArtifactRelativePath(entry.actionResult?.screenshotPath, sessionDir);
103
+ const videoOffsetSeconds = Number.isFinite(entry.videoOffsetSeconds)
104
+ ? Math.max(0, entry.videoOffsetSeconds)
105
+ : null;
106
+ const videoAnchor = videoOffsetSeconds !== null
107
+ ? Math.max(0, Math.floor(videoOffsetSeconds))
108
+ : null;
109
+ return {
110
+ index,
111
+ step: entry.step,
112
+ actionType: entry.action?.type || "unknown",
113
+ action: entry.action || null,
114
+ plannerSummary: entry.summary || "",
115
+ plannerThinking: entry.thinking || "",
116
+ verification: entry.verification || null,
117
+ timestamps: {
118
+ startedAt: entry.stepStartedAt || null,
119
+ finishedAt: entry.stepFinishedAt || null,
120
+ actionDurationMs: Number.isFinite(entry.actionDurationMs) ? entry.actionDurationMs : null,
121
+ elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
122
+ videoOffsetSeconds,
123
+ videoTimestamp: videoOffsetSeconds !== null ? formatVideoTimestamp(videoOffsetSeconds) : null,
124
+ },
125
+ page: entry.page || null,
126
+ artifacts: {
127
+ screenshotPath: screenshotRelative,
128
+ videoPath: primaryVideoRelative,
129
+ videoJump: primaryVideoRelative && videoAnchor !== null ? `${primaryVideoRelative}#t=${videoAnchor}` : null,
130
+ eventsPath: toArtifactRelativePath(result.artifacts?.eventsPath, sessionDir),
131
+ tracePath: toArtifactRelativePath(result.artifacts?.tracePath, sessionDir),
132
+ },
133
+ };
134
+ });
135
+
136
+ return {
137
+ schemaVersion: 1,
138
+ generatedAt: new Date().toISOString(),
139
+ status: result.status,
140
+ summary: result.summary,
141
+ finalPage: result.page || null,
142
+ session: {
143
+ sessionDir,
144
+ primaryVideoPath: primaryVideoRelative,
145
+ eventsPath: toArtifactRelativePath(result.artifacts?.eventsPath, sessionDir),
146
+ tracePath: toArtifactRelativePath(result.artifacts?.tracePath, sessionDir),
147
+ },
148
+ steps,
149
+ };
150
+ }
151
+
152
+ function includesAny(text, patterns) {
153
+ const normalized = (text || "").toLowerCase();
154
+ return patterns.some((pattern) => normalized.includes(pattern));
155
+ }
156
+
157
+ function collectElementText(elements) {
158
+ return (elements || []).map((element) => [
159
+ element.text,
160
+ element.placeholder,
161
+ element.ariaLabel,
162
+ element.href,
163
+ element.role,
164
+ element.type,
165
+ ].filter(Boolean).join(" ")).join(" ").toLowerCase();
166
+ }
167
+
168
+ function detectGuidanceRequest({ taskInstruction, page, elements, debugState, raisedSignals }) {
169
+ const pageText = `${page?.title || ""} ${page?.url || ""} ${page?.textPreview || ""}`.toLowerCase();
170
+ const elementText = collectElementText(elements);
171
+ const combinedText = `${pageText} ${elementText}`;
172
+ const taskText = (taskInstruction || "").toLowerCase();
173
+
174
+ if (!raisedSignals.has("page_errors") && (debugState?.recentErrors || []).length > 0) {
175
+ const errorSummary = debugState.recentErrors
176
+ .slice(-2)
177
+ .map((entry) => entry.message || "Unknown page error")
178
+ .join(" | ");
179
+ return {
180
+ signal: "page_errors",
181
+ question: "The page is throwing runtime errors. Should I keep going, refresh, or stop for inspection?",
182
+ details: errorSummary,
183
+ suggestedReply: "Stop and inspect the page errors before continuing.",
184
+ };
185
+ }
186
+
187
+ const loginPatterns = ["sign in", "log in", "login", "sign up", "password", "continue with google", "continue with github", "continue with apple", "enter your email"];
188
+ const taskExpectsAuth = includesAny(taskText, ["login", "log in", "sign in", "authenticate", "auth", "credentials"]);
189
+ if (!raisedSignals.has("login_wall") && !taskExpectsAuth && includesAny(combinedText, loginPatterns)) {
190
+ return {
191
+ signal: "login_wall",
192
+ question: "I hit a login or authentication wall. Do you want to provide guidance or take over manually?",
193
+ details: "The page content looks like a sign-in flow rather than the intended task surface.",
194
+ suggestedReply: "Pause here and let me handle login manually.",
195
+ };
196
+ }
197
+
198
+ const permissionPatterns = ["allow notifications", "show notifications", "allow location", "use your location", "allow camera", "allow microphone", "permission", "notifications", "microphone", "camera", "location access"];
199
+ const taskExpectsPermission = includesAny(taskText, ["notification", "permission", "camera", "microphone", "location"]);
200
+ if (!raisedSignals.has("permission_prompt") && !taskExpectsPermission && includesAny(combinedText, permissionPatterns)) {
201
+ return {
202
+ signal: "permission_prompt",
203
+ question: "A browser permission prompt may need a human decision. Should I continue, deny it, or stop here?",
204
+ details: "The page appears to be asking for browser permissions such as notifications, camera, microphone, or location.",
205
+ suggestedReply: "Deny the permission and continue with the task.",
206
+ };
207
+ }
208
+
209
+ return null;
210
+ }
211
+
212
+ export class BrowserSubagent {
213
+ constructor(runtime, options = {}) {
214
+ this.runtime = runtime;
215
+ this.planner = options.planner || new OpenAIPlanner(options);
216
+ this.defaultMaxSteps = options.defaultMaxSteps || 12;
217
+ }
218
+
219
+ async delegateTask(taskInstruction, options = {}) {
220
+ const maxSteps = options.maxSteps || this.defaultMaxSteps;
221
+ const onProgress = typeof options.onProgress === "function" ? options.onProgress : null;
222
+ const onNeedsInput = typeof options.onNeedsInput === "function" ? options.onNeedsInput : null;
223
+ const shouldAbort = typeof options.shouldAbort === "function" ? options.shouldAbort : null;
224
+ const getAbortReason = typeof options.getAbortReason === "function" ? options.getAbortReason : null;
225
+ const pullHandoffRequest = typeof options.pullHandoffRequest === "function" ? options.pullHandoffRequest : null;
226
+ const startOptions = options.startOptions || {};
227
+ const history = [];
228
+ const operatorMessages = [];
229
+ const raisedSignals = new Set();
230
+ const taskStartedAtMs = Date.now();
231
+
232
+ await this.runtime.ensureStarted(startOptions);
233
+ this.runtime.recordEvent("subagent_task_started", { taskInstruction, maxSteps });
234
+
235
+ for (let step = 1; step <= maxSteps; step += 1) {
236
+ if (shouldAbort?.()) {
237
+ const result = await this.buildResult({
238
+ status: "aborted",
239
+ step: Math.max(0, step - 1),
240
+ summary: getAbortReason?.() || "Browser task aborted.",
241
+ history,
242
+ page: await this.runtime.getPageMetadata().catch(() => null),
243
+ verification: null,
244
+ operatorMessages,
245
+ });
246
+ this.runtime.recordEvent("subagent_task_aborted", result);
247
+ return result;
248
+ }
249
+
250
+ const observation = await this.runtime.observe();
251
+ const page = await this.runtime.getPageMetadata();
252
+ const debugState = this.runtime.getDebugState(10);
253
+ const externalHandoffRequest = pullHandoffRequest?.() || null;
254
+ if (externalHandoffRequest) {
255
+ const externalOutcome = await this.handlePendingInput({
256
+ step,
257
+ pendingInput: {
258
+ ...externalHandoffRequest,
259
+ mode: externalHandoffRequest.mode || "manual_control",
260
+ },
261
+ page,
262
+ history,
263
+ operatorMessages,
264
+ onProgress,
265
+ onNeedsInput,
266
+ });
267
+
268
+ if (externalOutcome.result) {
269
+ return externalOutcome.result;
270
+ }
271
+
272
+ continue;
273
+ }
274
+
275
+ const guidanceRequest = detectGuidanceRequest({
276
+ taskInstruction,
277
+ page,
278
+ elements: observation.elements,
279
+ debugState,
280
+ raisedSignals,
281
+ });
282
+
283
+ if (guidanceRequest) {
284
+ raisedSignals.add(guidanceRequest.signal);
285
+ const guidanceOutcome = await this.handlePendingInput({
286
+ step,
287
+ pendingInput: {
288
+ step,
289
+ question: guidanceRequest.question,
290
+ details: guidanceRequest.details,
291
+ suggestedReply: guidanceRequest.suggestedReply,
292
+ },
293
+ page,
294
+ history,
295
+ operatorMessages,
296
+ onProgress,
297
+ onNeedsInput,
298
+ });
299
+
300
+ if (guidanceOutcome.result) {
301
+ return guidanceOutcome.result;
302
+ }
303
+ continue;
304
+ }
305
+
306
+ const decision = await this.planner.decide({
307
+ taskInstruction,
308
+ page,
309
+ elements: compactElements(observation.elements),
310
+ history: compactHistory(history),
311
+ operatorMessages: compactOperatorMessages(operatorMessages),
312
+ debugState,
313
+ });
314
+
315
+ this.runtime.recordEvent("subagent_decision", {
316
+ step,
317
+ decision,
318
+ });
319
+
320
+ const nextAction = decision.next_action || {};
321
+ const status = decision.status || "continue";
322
+
323
+ if (status === "completed" || nextAction.type === "finish") {
324
+ const result = await this.buildResult({
325
+ status: "completed",
326
+ step,
327
+ summary: decision.summary || nextAction.result || "Task completed.",
328
+ history,
329
+ page,
330
+ verification: null,
331
+ operatorMessages,
332
+ });
333
+ this.runtime.recordEvent("subagent_task_completed", result);
334
+ return result;
335
+ }
336
+
337
+ if (status === "failed" || nextAction.type === "fail") {
338
+ const result = await this.buildResult({
339
+ status: "failed",
340
+ step,
341
+ summary: decision.summary || nextAction.reason || "Task failed.",
342
+ history,
343
+ page,
344
+ verification: null,
345
+ operatorMessages,
346
+ });
347
+ this.runtime.recordEvent("subagent_task_failed", result);
348
+ return result;
349
+ }
350
+
351
+ if (status === "needs_input" || nextAction.type === "ask_main_agent") {
352
+ const pendingInput = {
353
+ step,
354
+ question: nextAction.question || decision.summary || "Need guidance before continuing.",
355
+ details: nextAction.details || decision.thinking || "",
356
+ suggestedReply: nextAction.suggested_reply || "",
357
+ };
358
+ const guidanceOutcome = await this.handlePendingInput({
359
+ step,
360
+ pendingInput,
361
+ page,
362
+ history,
363
+ operatorMessages,
364
+ onProgress,
365
+ onNeedsInput,
366
+ });
367
+
368
+ if (guidanceOutcome.result) {
369
+ return guidanceOutcome.result;
370
+ }
371
+ continue;
372
+ }
373
+
374
+ const stepStartedAtMs = Date.now();
375
+ const actionResult = await this.executeAction(nextAction);
376
+ const postActionObservation = await this.runtime.observe();
377
+ const postActionPage = await this.runtime.getPageMetadata();
378
+ const postActionDebugState = this.runtime.getDebugState(10);
379
+ const verification = await this.verifyProgress({
380
+ taskInstruction,
381
+ page: postActionPage,
382
+ elements: compactElements(postActionObservation.elements),
383
+ history,
384
+ lastAction: nextAction,
385
+ lastActionResult: actionResult,
386
+ debugState: postActionDebugState,
387
+ });
388
+
389
+ this.runtime.recordEvent("subagent_verification", {
390
+ step,
391
+ verification,
392
+ });
393
+ const stepFinishedAtMs = Date.now();
394
+
395
+ history.push({
396
+ step,
397
+ stepStartedAt: toIsoString(stepStartedAtMs),
398
+ stepFinishedAt: toIsoString(stepFinishedAtMs),
399
+ actionDurationMs: Math.max(0, stepFinishedAtMs - stepStartedAtMs),
400
+ elapsedMs: Math.max(0, stepFinishedAtMs - taskStartedAtMs),
401
+ videoOffsetSeconds: Math.max(0, (stepStartedAtMs - taskStartedAtMs) / 1000),
402
+ thinking: decision.thinking || "",
403
+ summary: decision.summary || "",
404
+ action: nextAction,
405
+ actionResult,
406
+ verification,
407
+ page: postActionPage,
408
+ debug: compactDebugState(postActionDebugState),
409
+ });
410
+
411
+ if (onProgress) {
412
+ await onProgress(this.buildProgressSnapshot({
413
+ status: "running",
414
+ step,
415
+ summary: verification.summary || decision.summary || `Completed step ${step}.`,
416
+ history,
417
+ page: postActionPage,
418
+ verification,
419
+ operatorMessages,
420
+ pendingInput: null,
421
+ }));
422
+ }
423
+
424
+ if (verification.goal_status === "completed") {
425
+ const result = await this.buildResult({
426
+ status: "completed",
427
+ step,
428
+ summary: verification.summary || decision.summary || "Task completed.",
429
+ history,
430
+ page: postActionPage,
431
+ verification,
432
+ operatorMessages,
433
+ });
434
+ this.runtime.recordEvent("subagent_task_completed", result);
435
+ return result;
436
+ }
437
+
438
+ if (verification.goal_status === "blocked") {
439
+ const result = await this.buildResult({
440
+ status: "failed",
441
+ step,
442
+ summary: verification.summary || "Task is blocked.",
443
+ history,
444
+ page: postActionPage,
445
+ verification,
446
+ operatorMessages,
447
+ });
448
+ this.runtime.recordEvent("subagent_task_failed", result);
449
+ return result;
450
+ }
451
+ }
452
+
453
+ const timeoutResult = await this.buildResult({
454
+ status: "failed",
455
+ step: maxSteps,
456
+ summary: `Stopped after ${maxSteps} steps without completing the task.`,
457
+ history,
458
+ page: await this.runtime.getPageMetadata().catch(() => null),
459
+ verification: null,
460
+ operatorMessages,
461
+ });
462
+ this.runtime.recordEvent("subagent_task_failed", timeoutResult);
463
+ return timeoutResult;
464
+ }
465
+
466
+ async verifyProgress(input) {
467
+ if (typeof this.planner.verify !== "function") {
468
+ return defaultVerification();
469
+ }
470
+
471
+ try {
472
+ return await this.planner.verify(input);
473
+ } catch (error) {
474
+ return {
475
+ goal_status: "incomplete",
476
+ confidence: "low",
477
+ summary: `Verification failed: ${error.message}`,
478
+ evidence: [],
479
+ next_hint: "Continue with caution.",
480
+ };
481
+ }
482
+ }
483
+
484
+ async handlePendingInput({ step, pendingInput, page, history, operatorMessages, onProgress, onNeedsInput }) {
485
+ const mode = pendingInput.mode || "guidance";
486
+ const waitingStatus = mode === "manual_control" ? "manual_control" : "waiting_for_instruction";
487
+ const requestMessage = {
488
+ role: mode === "manual_control" ? "system" : "subagent",
489
+ content: pendingInput.question,
490
+ timestamp: new Date().toISOString(),
491
+ };
492
+
493
+ operatorMessages.push(requestMessage);
494
+ this.runtime.recordEvent("subagent_input_requested", pendingInput);
495
+
496
+ if (onProgress) {
497
+ await onProgress(this.buildProgressSnapshot({
498
+ status: waitingStatus,
499
+ step,
500
+ summary: pendingInput.question,
501
+ history,
502
+ page,
503
+ verification: null,
504
+ operatorMessages,
505
+ pendingInput,
506
+ }));
507
+ }
508
+
509
+ if (!onNeedsInput) {
510
+ const result = await this.buildResult({
511
+ status: "failed",
512
+ step,
513
+ summary: pendingInput.question,
514
+ history,
515
+ page,
516
+ verification: null,
517
+ operatorMessages,
518
+ pendingInput,
519
+ });
520
+ this.runtime.recordEvent("subagent_task_failed", result);
521
+ return { result };
522
+ }
523
+
524
+ const reply = await onNeedsInput(pendingInput);
525
+ if (reply?.abort) {
526
+ const result = await this.buildResult({
527
+ status: "aborted",
528
+ step,
529
+ summary: reply.reason || "Browser task aborted.",
530
+ history,
531
+ page,
532
+ verification: null,
533
+ operatorMessages,
534
+ });
535
+ this.runtime.recordEvent("subagent_task_aborted", result);
536
+ return { result };
537
+ }
538
+
539
+ const operatorMessage = {
540
+ role: "main_agent",
541
+ content: typeof reply === "string" ? reply : reply?.instruction || "",
542
+ timestamp: new Date().toISOString(),
543
+ };
544
+
545
+ operatorMessages.push(operatorMessage);
546
+ this.runtime.recordEvent("subagent_input_received", operatorMessage);
547
+
548
+ if (onProgress) {
549
+ await onProgress(this.buildProgressSnapshot({
550
+ status: "running",
551
+ step,
552
+ summary: mode === "manual_control" ? "Manual control complete. Resuming browser task." : "Guidance received. Continuing browser task.",
553
+ history,
554
+ page,
555
+ verification: null,
556
+ operatorMessages,
557
+ pendingInput: null,
558
+ }));
559
+ }
560
+
561
+ return { result: null };
562
+ }
563
+
564
+ buildProgressSnapshot({ status, step, summary, history, page, verification, operatorMessages, pendingInput }) {
565
+ const debugState = this.runtime.getDebugState(20);
566
+ return {
567
+ status: status || "running",
568
+ step,
569
+ summary,
570
+ history: [...history],
571
+ artifacts: debugState.artifacts,
572
+ page,
573
+ verification,
574
+ operatorMessages: compactOperatorMessages(operatorMessages, 12),
575
+ pendingInput,
576
+ debug: compactDebugState(debugState, 12),
577
+ };
578
+ }
579
+
580
+ async buildResult({ status, step, summary, history, page, verification, operatorMessages, pendingInput = null }) {
581
+ const artifacts = this.runtime.getDebugState(10).artifacts;
582
+ const result = {
583
+ status,
584
+ step,
585
+ summary,
586
+ history,
587
+ artifacts,
588
+ page,
589
+ verification,
590
+ operatorMessages: compactOperatorMessages(operatorMessages, 12),
591
+ pendingInput,
592
+ debug: compactDebugState(this.runtime.getDebugState(20), 12),
593
+ };
594
+ result.capabilities = result.debug?.capabilities || null;
595
+
596
+ const reportPaths = this.writeReports(result);
597
+ return {
598
+ ...result,
599
+ reports: reportPaths,
600
+ };
601
+ }
602
+
603
+ writeReports(result) {
604
+ const reportJsonPath = this.runtime.writeArtifactJson("task-report.json", result);
605
+ const walkthroughPath = this.runtime.writeArtifactText("walkthrough.md", this.renderWalkthrough(result));
606
+ const timelineJsonPath = this.runtime.writeArtifactJson("task-timeline.json", buildStructuredTimeline(result));
607
+ return {
608
+ reportJsonPath,
609
+ walkthroughPath,
610
+ timelineJsonPath,
611
+ };
612
+ }
613
+
614
+ renderWalkthrough(result) {
615
+ const sessionDir = result.artifacts?.sessionDir || null;
616
+ const primaryVideoPath = result.artifacts?.videoFiles?.[0] || null;
617
+ const primaryVideoRelative = toArtifactRelativePath(primaryVideoPath, sessionDir);
618
+ const lines = [
619
+ "# Browser Task Report",
620
+ "",
621
+ `Status: ${result.status}`,
622
+ `Summary: ${result.summary}`,
623
+ `Final URL: ${result.page?.url || "unknown"}`,
624
+ `Final Title: ${result.page?.title || "unknown"}`,
625
+ "",
626
+ "## Steps",
627
+ ];
628
+
629
+ if (!result.history.length) {
630
+ lines.push("", "No browser actions were executed.");
631
+ }
632
+
633
+ for (const entry of result.history) {
634
+ lines.push("");
635
+ lines.push(`### Step ${entry.step}`);
636
+ lines.push(`Action: ${formatAction(entry.action)}`);
637
+ if (entry.summary) lines.push(`Plan Summary: ${entry.summary}`);
638
+ if (entry.verification?.summary) lines.push(`Verification: ${entry.verification.summary}`);
639
+ if (entry.stepStartedAt || entry.stepFinishedAt) {
640
+ lines.push(`Timing: ${entry.stepStartedAt || "unknown"} -> ${entry.stepFinishedAt || "unknown"} (${formatDurationMs(entry.actionDurationMs)})`);
641
+ }
642
+ if (entry.page?.url) lines.push(`Page URL: ${entry.page.url}`);
643
+ if (entry.page?.title) lines.push(`Page Title: ${entry.page.title}`);
644
+
645
+ const screenshotPath = toArtifactRelativePath(entry.actionResult?.screenshotPath, sessionDir);
646
+ if (screenshotPath) {
647
+ lines.push(`Screenshot: ${screenshotPath}`);
648
+ }
649
+
650
+ if (primaryVideoRelative && Number.isFinite(entry.videoOffsetSeconds)) {
651
+ const secondAnchor = Math.max(0, Math.floor(entry.videoOffsetSeconds));
652
+ lines.push(`Video Timestamp: ${formatVideoTimestamp(entry.videoOffsetSeconds)} (+${secondAnchor}s)`);
653
+ lines.push(`Video Jump: ${primaryVideoRelative}#t=${secondAnchor}`);
654
+ }
655
+ }
656
+
657
+ lines.push("");
658
+ lines.push("## Artifacts");
659
+ lines.push(`Session Dir: ${result.artifacts?.sessionDir || "unknown"}`);
660
+ lines.push(`Trace: ${result.artifacts?.tracePath || "pending"}`);
661
+ lines.push(`Events: ${result.artifacts?.eventsPath || "unknown"}`);
662
+ if (primaryVideoRelative) {
663
+ lines.push(`Primary Video: ${primaryVideoRelative}`);
664
+ } else {
665
+ lines.push("Primary Video: pending");
666
+ }
667
+
668
+ return `${lines.join("\n")}\n`;
669
+ }
670
+
671
+ async executeAction(action) {
672
+ switch (action.type) {
673
+ case "goto":
674
+ return this.runtime.goto(action.url);
675
+ case "click":
676
+ return this.runtime.click(Number(action.id));
677
+ case "type":
678
+ return this.runtime.type(Number(action.id), action.text || "");
679
+ case "hover":
680
+ return this.runtime.hover(Number(action.id));
681
+ case "keypress":
682
+ return this.runtime.keypress(action.key);
683
+ case "scroll":
684
+ return this.runtime.scroll(action.direction || "down");
685
+ default:
686
+ throw new Error(`Unsupported subagent action: ${action.type}`);
687
+ }
688
+ }
689
+ }