agent-portal-2 0.1.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.
Files changed (120) hide show
  1. package/.continue/agents/new-config.yaml +22 -0
  2. package/AGENT_STEERING.md +36 -0
  3. package/ARCHITECTURE.md +13 -0
  4. package/CHANGELOG.md +97 -0
  5. package/CLI.md +38 -0
  6. package/CONTRIBUTING.md +55 -0
  7. package/INSTALLATION.md +58 -0
  8. package/LICENSE +60 -0
  9. package/PLUGIN_SYSTEM.md +33 -0
  10. package/PYTHON_SDK.md +22 -0
  11. package/QUICKSTART.md +19 -0
  12. package/README.md +385 -0
  13. package/RELEASE_NOTES_v0.1.0.md +281 -0
  14. package/ROADMAP.md +3 -0
  15. package/RUNTIME.md +44 -0
  16. package/SAFETY_MODEL.md +24 -0
  17. package/TESTING.md +35 -0
  18. package/TROUBLESHOOTING.md +30 -0
  19. package/UPGRADE_GUIDE.md +288 -0
  20. package/VS_CODE_EXTENSION.md +47 -0
  21. package/agent-portal.config.json +20 -0
  22. package/apps/desktop/agent-portal-desktop.zip +0 -0
  23. package/apps/desktop/fixtures/local-workflow.html +151 -0
  24. package/apps/desktop/package.json +18 -0
  25. package/apps/desktop/src/main.ts +117 -0
  26. package/apps/desktop/tsconfig.json +8 -0
  27. package/apps/vscode-extension/LICENSE +60 -0
  28. package/apps/vscode-extension/README.md +20 -0
  29. package/apps/vscode-extension/media/agent-portal-logo.png +0 -0
  30. package/apps/vscode-extension/package.json +149 -0
  31. package/apps/vscode-extension/src/extension.ts +614 -0
  32. package/apps/vscode-extension/tsconfig.json +12 -0
  33. package/assets/branding/agent-portal-logo.png +0 -0
  34. package/connectors/chatgpt-tools/README.md +9 -0
  35. package/connectors/claude-mcp-server/README.md +9 -0
  36. package/connectors/gemini-connector/README.md +9 -0
  37. package/connectors/rest-websocket-api/README.md +9 -0
  38. package/docs/MCP_SERVER.md +68 -0
  39. package/docs/architecture.md +214 -0
  40. package/docs/roadmap.md +125 -0
  41. package/package.json +21 -0
  42. package/packages/agent-portal-mcp/README.md +12 -0
  43. package/packages/agent-portal-mcp/agent_portal_mcp/__init__.py +3 -0
  44. package/packages/agent-portal-mcp/agent_portal_mcp/bridge/__init__.py +1 -0
  45. package/packages/agent-portal-mcp/agent_portal_mcp/bridge/runtime_client.py +180 -0
  46. package/packages/agent-portal-mcp/agent_portal_mcp/cli.py +32 -0
  47. package/packages/agent-portal-mcp/agent_portal_mcp/doctor.py +71 -0
  48. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/__init__.py +1 -0
  49. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/actions.py +17 -0
  50. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/results.py +24 -0
  51. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/risk.py +20 -0
  52. package/packages/agent-portal-mcp/agent_portal_mcp/security/__init__.py +1 -0
  53. package/packages/agent-portal-mcp/agent_portal_mcp/security/policy.py +27 -0
  54. package/packages/agent-portal-mcp/agent_portal_mcp/server.py +148 -0
  55. package/packages/agent-portal-mcp/agent_portal_mcp/tool_registry.py +58 -0
  56. package/packages/agent-portal-mcp/agent_portal_mcp/tools/__init__.py +1 -0
  57. package/packages/agent-portal-mcp/agent_portal_mcp/tools/browser.py +89 -0
  58. package/packages/agent-portal-mcp/agent_portal_mcp/tools/common.py +98 -0
  59. package/packages/agent-portal-mcp/agent_portal_mcp/tools/inspection.py +93 -0
  60. package/packages/agent-portal-mcp/agent_portal_mcp/tools/navigation.py +93 -0
  61. package/packages/agent-portal-mcp/agent_portal_mcp/tools/reports.py +34 -0
  62. package/packages/agent-portal-mcp/agent_portal_mcp/tools/steering.py +93 -0
  63. package/packages/agent-portal-mcp/pyproject.toml +20 -0
  64. package/packages/agent-portal-mcp/tests/test_doctor.py +20 -0
  65. package/packages/agent-portal-mcp/tests/test_mcp_server.py +161 -0
  66. package/packages/core/package.json +15 -0
  67. package/packages/core/src/index.ts +1842 -0
  68. package/packages/core/tsconfig.json +8 -0
  69. package/packages/mcp-server/package.json +15 -0
  70. package/packages/mcp-server/src/index.ts +73 -0
  71. package/packages/mcp-server/tsconfig.json +8 -0
  72. package/packages/sdk/package.json +15 -0
  73. package/packages/sdk/src/index.ts +544 -0
  74. package/packages/sdk/tsconfig.json +8 -0
  75. package/plugins/README.md +16 -0
  76. package/plugins/agent-portal-browser/plugin.json +19 -0
  77. package/plugins/agent-portal-python/plugin.json +16 -0
  78. package/plugins/agent-portal-skills/plugin.json +19 -0
  79. package/plugins/agent-portal-vscode/plugin.json +27 -0
  80. package/plugins/example-runtime-plugin/README.md +3 -0
  81. package/plugins/example-runtime-plugin/plugin.json +20 -0
  82. package/plugins/plugin.schema.json +53 -0
  83. package/python/README.md +18 -0
  84. package/python/agent_portal/__init__.py +5 -0
  85. package/python/agent_portal/__main__.py +5 -0
  86. package/python/agent_portal/browser.py +393 -0
  87. package/python/agent_portal/cli.py +164 -0
  88. package/python/agent_portal/config.py +31 -0
  89. package/python/agent_portal/doctor.py +165 -0
  90. package/python/agent_portal/exceptions.py +39 -0
  91. package/python/agent_portal/logging_utils.py +33 -0
  92. package/python/agent_portal/metrics.py +309 -0
  93. package/python/agent_portal/models.py +160 -0
  94. package/python/agent_portal/plugin_system.py +42 -0
  95. package/python/agent_portal/rate_limit.py +253 -0
  96. package/python/agent_portal/runtime.py +739 -0
  97. package/python/agent_portal/server.py +351 -0
  98. package/python/agent_portal/validation.py +299 -0
  99. package/python/pyproject.toml +29 -0
  100. package/python/tests/test_config.py +24 -0
  101. package/python/tests/test_doctor.py +19 -0
  102. package/python/tests/test_metrics.py +180 -0
  103. package/python/tests/test_rate_limit.py +237 -0
  104. package/python/tests/test_runtime.py +122 -0
  105. package/python/tests/test_server.py +53 -0
  106. package/python/tests/test_validation.py +170 -0
  107. package/releases/desktop/agent-portal-desktop/README.md +378 -0
  108. package/releases/desktop/agent-portal-desktop/RELEASE_NOTES.md +14 -0
  109. package/releases/desktop/agent-portal-desktop/assets/branding/agent-portal-logo.png +0 -0
  110. package/releases/desktop/agent-portal-desktop/fixtures/local-workflow.html +151 -0
  111. package/releases/desktop/agent-portal-desktop/launch-agent-portal.bat +4 -0
  112. package/releases/desktop/agent-portal-desktop.zip +0 -0
  113. package/releases/python/agent_portal-0.0.2-py3-none-any.whl +0 -0
  114. package/releases/python/agent_portal-0.0.2.tar.gz +0 -0
  115. package/scripts/package_desktop.mjs +117 -0
  116. package/scripts/release_python.py +46 -0
  117. package/tests/plugin-manifest.test.mjs +26 -0
  118. package/tests/runtime.test.mjs +41 -0
  119. package/tests/vscode-extension.test.mjs +22 -0
  120. package/tsconfig.base.json +16 -0
@@ -0,0 +1,1842 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { chromium, Locator, Page } from "playwright";
5
+
6
+ export type AgentRole =
7
+ | "frontend"
8
+ | "backend"
9
+ | "qa"
10
+ | "security"
11
+ | "research"
12
+ | "custom";
13
+
14
+ export type AgentStatus = "idle" | "running" | "blocked" | "completed";
15
+ export type RuntimeStatus =
16
+ | "idle"
17
+ | "thinking"
18
+ | "acting"
19
+ | "waiting-approval"
20
+ | "paused"
21
+ | "blocked"
22
+ | "finished"
23
+ | "failed"
24
+ | "stopped";
25
+ export type BrowserSessionStatus =
26
+ | "idle"
27
+ | "launching"
28
+ | "ready"
29
+ | "disconnected"
30
+ | "closing"
31
+ | "closed"
32
+ | "error";
33
+ export type ActionRiskLevel = "safe" | "low" | "medium" | "high" | "blocked";
34
+ export type ActionQueueStatus =
35
+ | "pending"
36
+ | "approved"
37
+ | "rejected"
38
+ | "completed"
39
+ | "failed"
40
+ | "blocked";
41
+
42
+ export interface AgentDefinition {
43
+ id: string;
44
+ role: AgentRole;
45
+ status: AgentStatus;
46
+ description?: string;
47
+ }
48
+
49
+ export interface WorkspaceDirectories {
50
+ browserSessions: string;
51
+ documents: string;
52
+ reports: string;
53
+ screenshots: string;
54
+ logs: string;
55
+ agents: string;
56
+ tasks: string;
57
+ workflows: string;
58
+ memory: string;
59
+ settings: string;
60
+ }
61
+
62
+ export interface WorkspaceDefinition {
63
+ id: string;
64
+ name: string;
65
+ rootPath: string;
66
+ directories: WorkspaceDirectories;
67
+ }
68
+
69
+ export type UIElementType =
70
+ | "button"
71
+ | "form"
72
+ | "input"
73
+ | "dropdown"
74
+ | "modal"
75
+ | "menu"
76
+ | "table"
77
+ | "tab"
78
+ | "card"
79
+ | "dialog"
80
+ | "link"
81
+ | "custom";
82
+
83
+ export interface UIElement {
84
+ id: string;
85
+ type: UIElementType;
86
+ label?: string;
87
+ selector?: string;
88
+ bounds?: { x: number; y: number; width: number; height: number };
89
+ actionable: boolean;
90
+ }
91
+
92
+ export interface ConsoleLogEntry {
93
+ type: "log" | "warning" | "error" | "info";
94
+ text: string;
95
+ location?: string;
96
+ at: string;
97
+ }
98
+
99
+ export interface NetworkEvent {
100
+ url: string;
101
+ status?: number;
102
+ method: string;
103
+ resourceType: string;
104
+ outcome: "pending" | "ok" | "failed";
105
+ failureText?: string;
106
+ at: string;
107
+ }
108
+
109
+ export interface VisualSnapshot {
110
+ id: string;
111
+ source: "browser" | "desktop" | "application";
112
+ capturedAt: string;
113
+ screenshotPath?: string;
114
+ dom?: string;
115
+ accessibilityTree?: string;
116
+ ocrText?: string;
117
+ consoleLogs?: ConsoleLogEntry[];
118
+ networkEvents?: NetworkEvent[];
119
+ label?: string;
120
+ url?: string;
121
+ detectedElements: UIElement[];
122
+ }
123
+
124
+ export interface BrowserAction {
125
+ kind:
126
+ | "open"
127
+ | "click"
128
+ | "type"
129
+ | "scroll"
130
+ | "hover"
131
+ | "drag"
132
+ | "drop"
133
+ | "upload"
134
+ | "download"
135
+ | "capture"
136
+ | "inspect"
137
+ | "wait"
138
+ | "execute";
139
+ target?: string;
140
+ payload?: string;
141
+ timeoutMs?: number;
142
+ }
143
+
144
+ export interface ScrollOptions {
145
+ selector?: string;
146
+ deltaX?: number;
147
+ deltaY?: number;
148
+ }
149
+
150
+ export interface SessionEvent {
151
+ type: string;
152
+ at: string;
153
+ detail: string;
154
+ artifactPath?: string;
155
+ }
156
+
157
+ export interface RootCauseHypothesis {
158
+ label: string;
159
+ confidence: number;
160
+ evidence: string[];
161
+ }
162
+
163
+ export interface ScreenUnderstanding {
164
+ pageCategory:
165
+ | "login"
166
+ | "signup"
167
+ | "dashboard"
168
+ | "docs"
169
+ | "pricing"
170
+ | "form"
171
+ | "unknown";
172
+ summary: string;
173
+ userIntent?: string;
174
+ issues: string[];
175
+ likelyNextActions: string[];
176
+ rootCauseHypotheses: RootCauseHypothesis[];
177
+ }
178
+
179
+ export interface PortalGraphNode {
180
+ id: string;
181
+ label: string;
182
+ pageCategory: ScreenUnderstanding["pageCategory"];
183
+ url?: string;
184
+ links: string[];
185
+ }
186
+
187
+ export interface MemoryRecord {
188
+ id: string;
189
+ kind: "page-understanding" | "goal-plan" | "issue" | "project-awareness";
190
+ summary: string;
191
+ createdAt: string;
192
+ payload: Record<string, unknown>;
193
+ }
194
+
195
+ export interface PluginManifest {
196
+ name: string;
197
+ version: string;
198
+ type:
199
+ | "vscode-extension"
200
+ | "browser-extension"
201
+ | "python-plugin"
202
+ | "agent-connector"
203
+ | "skill-plugin";
204
+ permissions: string[];
205
+ entryPoint?: string;
206
+ commands: string[];
207
+ settings?: Record<string, unknown>;
208
+ panels?: string[];
209
+ lifecycleHooks?: string[];
210
+ }
211
+
212
+ export interface ActionPolicyDecision {
213
+ riskLevel: ActionRiskLevel;
214
+ requiresApproval: boolean;
215
+ blockedReason?: string;
216
+ matchedPolicies: string[];
217
+ }
218
+
219
+ export interface ActionContext {
220
+ reason: string;
221
+ currentUrl?: string;
222
+ candidateLabel?: string;
223
+ targetFieldType?: string;
224
+ }
225
+
226
+ export interface ActionQueueItem {
227
+ id: string;
228
+ actionType: BrowserAction["kind"];
229
+ target?: string;
230
+ payload?: string;
231
+ reason: string;
232
+ riskLevel: ActionRiskLevel;
233
+ timestamp: string;
234
+ status: ActionQueueStatus;
235
+ result?: string;
236
+ errorMessage?: string;
237
+ requiresApproval: boolean;
238
+ beforeScreenshotPath?: string;
239
+ afterScreenshotPath?: string;
240
+ blockedReason?: string;
241
+ }
242
+
243
+ export interface SteeringState {
244
+ paused: boolean;
245
+ stopped: boolean;
246
+ stepByStepMode: boolean;
247
+ currentGoal?: string;
248
+ lockedDomain?: string;
249
+ lockedTabUrl?: string;
250
+ requireApprovalAtOrAbove: ActionRiskLevel;
251
+ }
252
+
253
+ export interface SessionSummary {
254
+ currentGoal?: string;
255
+ actionsAttempted: number;
256
+ approvedActions: number;
257
+ rejectedActions: number;
258
+ failedActions: number;
259
+ blockedActions: number;
260
+ consoleErrors: number;
261
+ networkErrors: number;
262
+ riskEvents: number;
263
+ suggestedFixes: string[];
264
+ reproductionSteps: string[];
265
+ }
266
+
267
+ export interface SessionReport {
268
+ summary: SessionSummary;
269
+ state: RuntimeState;
270
+ }
271
+
272
+ export interface RuntimeState {
273
+ workspace: WorkspaceDefinition;
274
+ agents: AgentDefinition[];
275
+ runtimeStatus: RuntimeStatus;
276
+ browserStatus: BrowserSessionStatus;
277
+ steering: SteeringState;
278
+ sessionEvents: SessionEvent[];
279
+ snapshots: VisualSnapshot[];
280
+ graph: PortalGraphNode[];
281
+ memoryRecords: MemoryRecord[];
282
+ actionQueue: ActionQueueItem[];
283
+ }
284
+
285
+ export interface RuntimeConfig {
286
+ workspace: WorkspaceDefinition;
287
+ agents?: AgentDefinition[];
288
+ workspaceBasePath?: string;
289
+ }
290
+
291
+ export interface CaptureOptions {
292
+ label?: string;
293
+ includeAccessibilityTree?: boolean;
294
+ }
295
+
296
+ export interface BrowserSessionOptions {
297
+ headless?: boolean;
298
+ viewport?: {
299
+ width: number;
300
+ height: number;
301
+ };
302
+ basePath?: string;
303
+ evidenceScreenshots?: boolean;
304
+ }
305
+
306
+ export interface BrowserInspectionResult {
307
+ url: string;
308
+ title: string;
309
+ dom: string;
310
+ accessibilityTree?: string;
311
+ screenshotPath: string;
312
+ consoleLogs: ConsoleLogEntry[];
313
+ networkEvents: NetworkEvent[];
314
+ snapshot: VisualSnapshot;
315
+ }
316
+
317
+ export interface BrowserElementText {
318
+ selector: string;
319
+ text: string | null;
320
+ }
321
+
322
+ export interface BrowserExecuteResult {
323
+ result: unknown;
324
+ }
325
+
326
+ export interface VisionContext {
327
+ snapshot: VisualSnapshot;
328
+ goal?: string;
329
+ }
330
+
331
+ export interface GoalDefinition {
332
+ id: string;
333
+ title: string;
334
+ description?: string;
335
+ }
336
+
337
+ export interface GoalPlanStep {
338
+ id: string;
339
+ title: string;
340
+ detail: string;
341
+ status: "pending" | "ready" | "blocked";
342
+ }
343
+
344
+ export interface GoalPlan {
345
+ goal: GoalDefinition;
346
+ steps: GoalPlanStep[];
347
+ }
348
+
349
+ export interface ProjectAwareness {
350
+ frameworks: string[];
351
+ services: string[];
352
+ packageManagers: string[];
353
+ summary: string;
354
+ }
355
+
356
+ interface SerializedUIElement {
357
+ id: string;
358
+ type: UIElementType;
359
+ label?: string;
360
+ selector?: string;
361
+ bounds?: { x: number; y: number; width: number; height: number };
362
+ actionable: boolean;
363
+ }
364
+
365
+ export class AgentPortalError extends Error {
366
+ constructor(
367
+ public readonly code: string,
368
+ message: string,
369
+ public readonly details?: string
370
+ ) {
371
+ super(message);
372
+ this.name = "AgentPortalError";
373
+ }
374
+ }
375
+
376
+ export class ActionApprovalRequiredError extends AgentPortalError {
377
+ constructor(public readonly actionId: string, message: string) {
378
+ super("ACTION_APPROVAL_REQUIRED", message);
379
+ this.name = "ActionApprovalRequiredError";
380
+ }
381
+ }
382
+
383
+ export class ActionBlockedError extends AgentPortalError {
384
+ constructor(public readonly actionId: string, message: string) {
385
+ super("ACTION_BLOCKED", message);
386
+ this.name = "ActionBlockedError";
387
+ }
388
+ }
389
+
390
+ export class AgentPortalRuntime {
391
+ private readonly state: RuntimeState;
392
+ private readonly workspaceBasePath: string;
393
+
394
+ constructor(config: RuntimeConfig) {
395
+ this.state = {
396
+ workspace: config.workspace,
397
+ agents: config.agents ?? [],
398
+ runtimeStatus: "idle",
399
+ browserStatus: "idle",
400
+ steering: {
401
+ paused: false,
402
+ stopped: false,
403
+ stepByStepMode: false,
404
+ requireApprovalAtOrAbove: "high"
405
+ },
406
+ sessionEvents: [],
407
+ snapshots: [],
408
+ graph: [],
409
+ memoryRecords: [],
410
+ actionQueue: []
411
+ };
412
+ this.workspaceBasePath = config.workspaceBasePath ?? process.cwd();
413
+ }
414
+
415
+ addAgent(agent: AgentDefinition): void {
416
+ this.state.agents.push(agent);
417
+ this.record({
418
+ type: "agent.added",
419
+ at: new Date().toISOString(),
420
+ detail: `Added agent ${agent.id}`
421
+ });
422
+ }
423
+
424
+ startAgent(agentId: string): AgentDefinition {
425
+ return this.updateAgentStatus(agentId, "running", "agent.started");
426
+ }
427
+
428
+ blockAgent(agentId: string): AgentDefinition {
429
+ return this.updateAgentStatus(agentId, "blocked", "agent.blocked");
430
+ }
431
+
432
+ completeAgent(agentId: string): AgentDefinition {
433
+ return this.updateAgentStatus(agentId, "completed", "agent.completed");
434
+ }
435
+
436
+ resetAgent(agentId: string): AgentDefinition {
437
+ return this.updateAgentStatus(agentId, "idle", "agent.reset");
438
+ }
439
+
440
+ pauseAgentExecution(): void {
441
+ this.state.steering.paused = true;
442
+ this.setRuntimeStatus("paused");
443
+ this.recordStatusEvent("steering.paused", "Agent execution paused");
444
+ }
445
+
446
+ resumeAgentExecution(): void {
447
+ this.state.steering.paused = false;
448
+ if (this.state.runtimeStatus === "paused") {
449
+ this.setRuntimeStatus("idle");
450
+ }
451
+ this.recordStatusEvent("steering.resumed", "Agent execution resumed");
452
+ }
453
+
454
+ stopAgentExecution(): void {
455
+ this.state.steering.stopped = true;
456
+ this.setRuntimeStatus("stopped");
457
+ this.recordStatusEvent("steering.stopped", "Agent execution stopped");
458
+ }
459
+
460
+ clearStoppedState(): void {
461
+ this.state.steering.stopped = false;
462
+ if (this.state.runtimeStatus === "stopped") {
463
+ this.setRuntimeStatus("idle");
464
+ }
465
+ this.recordStatusEvent("steering.stop-cleared", "Stopped state cleared");
466
+ }
467
+
468
+ enableStepByStepMode(): void {
469
+ this.state.steering.stepByStepMode = true;
470
+ this.recordStatusEvent("steering.step-by-step.enabled", "Step-by-step mode enabled");
471
+ }
472
+
473
+ disableStepByStepMode(): void {
474
+ this.state.steering.stepByStepMode = false;
475
+ this.recordStatusEvent("steering.step-by-step.disabled", "Step-by-step mode disabled");
476
+ }
477
+
478
+ redirectGoal(goal: string): void {
479
+ this.state.steering.currentGoal = goal;
480
+ this.recordStatusEvent("steering.goal.redirected", `Redirected goal to ${goal}`);
481
+ }
482
+
483
+ lockToDomain(domain: string): void {
484
+ this.state.steering.lockedDomain = domain;
485
+ this.recordStatusEvent("steering.domain.locked", `Locked agent to domain ${domain}`);
486
+ }
487
+
488
+ unlockDomain(): void {
489
+ this.state.steering.lockedDomain = undefined;
490
+ this.recordStatusEvent("steering.domain.unlocked", "Removed domain lock");
491
+ }
492
+
493
+ lockToTab(url: string): void {
494
+ this.state.steering.lockedTabUrl = url;
495
+ this.recordStatusEvent("steering.tab.locked", `Locked agent to tab ${url}`);
496
+ }
497
+
498
+ unlockTab(): void {
499
+ this.state.steering.lockedTabUrl = undefined;
500
+ this.recordStatusEvent("steering.tab.unlocked", "Removed tab lock");
501
+ }
502
+
503
+ setApprovalThreshold(level: ActionRiskLevel): void {
504
+ this.state.steering.requireApprovalAtOrAbove = level;
505
+ this.recordStatusEvent(
506
+ "steering.threshold.updated",
507
+ `Approval threshold set to ${level}`
508
+ );
509
+ }
510
+
511
+ setBrowserStatus(status: BrowserSessionStatus): void {
512
+ this.state.browserStatus = status;
513
+ this.record({
514
+ type: "browser.status.updated",
515
+ at: new Date().toISOString(),
516
+ detail: `Browser status is now ${status}`
517
+ });
518
+ }
519
+
520
+ setRuntimeStatus(status: RuntimeStatus): void {
521
+ this.state.runtimeStatus = status;
522
+ }
523
+
524
+ record(event: SessionEvent): void {
525
+ this.state.sessionEvents.push(event);
526
+ }
527
+
528
+ addSnapshot(snapshot: VisualSnapshot): void {
529
+ this.state.snapshots.push(snapshot);
530
+ }
531
+
532
+ addGraphNode(node: PortalGraphNode): void {
533
+ const existing = this.state.graph.findIndex((entry) => entry.id === node.id);
534
+
535
+ if (existing >= 0) {
536
+ this.state.graph[existing] = node;
537
+ } else {
538
+ this.state.graph.push(node);
539
+ }
540
+
541
+ this.recordStatusEvent("graph.updated", `Updated graph node ${node.id}`);
542
+ }
543
+
544
+ addMemoryRecord(record: MemoryRecord): void {
545
+ this.state.memoryRecords.push(record);
546
+ this.recordStatusEvent("memory.recorded", `Stored memory record ${record.id}`);
547
+ }
548
+
549
+ planAction(action: BrowserAction): string {
550
+ const target = action.target ? ` on ${action.target}` : "";
551
+ return `${action.kind}${target}`;
552
+ }
553
+
554
+ getOverview(): RuntimeState {
555
+ return {
556
+ workspace: this.state.workspace,
557
+ agents: [...this.state.agents],
558
+ runtimeStatus: this.state.runtimeStatus,
559
+ browserStatus: this.state.browserStatus,
560
+ steering: { ...this.state.steering },
561
+ sessionEvents: [...this.state.sessionEvents],
562
+ snapshots: [...this.state.snapshots],
563
+ graph: [...this.state.graph],
564
+ memoryRecords: [...this.state.memoryRecords],
565
+ actionQueue: [...this.state.actionQueue]
566
+ };
567
+ }
568
+
569
+ getWorkspaceBasePath(): string {
570
+ return this.workspaceBasePath;
571
+ }
572
+
573
+ getPendingActions(): ActionQueueItem[] {
574
+ return this.state.actionQueue.filter((item) => item.status === "pending");
575
+ }
576
+
577
+ approveAction(actionId: string): ActionQueueItem {
578
+ const action = this.requireAction(actionId);
579
+ action.status = "approved";
580
+ this.recordStatusEvent("action.approved", `Approved action ${actionId}`);
581
+ return { ...action };
582
+ }
583
+
584
+ rejectAction(actionId: string, reason = "Rejected by user"): ActionQueueItem {
585
+ const action = this.requireAction(actionId);
586
+ action.status = "rejected";
587
+ action.result = reason;
588
+ this.recordStatusEvent("action.rejected", `Rejected action ${actionId}`);
589
+ return { ...action };
590
+ }
591
+
592
+ editAction(
593
+ actionId: string,
594
+ patch: Partial<Pick<ActionQueueItem, "target" | "payload" | "reason">>
595
+ ): ActionQueueItem {
596
+ const action = this.requireAction(actionId);
597
+ if (patch.target !== undefined) action.target = patch.target;
598
+ if (patch.payload !== undefined) action.payload = patch.payload;
599
+ if (patch.reason !== undefined) action.reason = patch.reason;
600
+ this.recordStatusEvent("action.edited", `Edited action ${actionId}`);
601
+ return { ...action };
602
+ }
603
+
604
+ createActionRecord(action: BrowserAction, context: ActionContext): ActionQueueItem {
605
+ const decision = evaluateActionPolicy(action, context, this.state.steering);
606
+ const actionRecord: ActionQueueItem = {
607
+ id: createActionId(action.kind),
608
+ actionType: action.kind,
609
+ target: action.target,
610
+ payload: action.payload,
611
+ reason: context.reason,
612
+ riskLevel: decision.riskLevel,
613
+ timestamp: new Date().toISOString(),
614
+ status: decision.blockedReason
615
+ ? "blocked"
616
+ : decision.requiresApproval
617
+ ? "pending"
618
+ : "approved",
619
+ requiresApproval: decision.requiresApproval,
620
+ blockedReason: decision.blockedReason
621
+ };
622
+
623
+ this.state.actionQueue.push(actionRecord);
624
+
625
+ if (actionRecord.status === "pending") {
626
+ this.setRuntimeStatus("waiting-approval");
627
+ this.recordStatusEvent(
628
+ "action.pending-approval",
629
+ `Action ${actionRecord.id} requires approval`
630
+ );
631
+ } else if (actionRecord.status === "blocked") {
632
+ this.setRuntimeStatus("blocked");
633
+ this.recordStatusEvent(
634
+ "action.blocked",
635
+ `Action ${actionRecord.id} was blocked: ${decision.blockedReason}`
636
+ );
637
+ } else {
638
+ this.recordStatusEvent("action.approved-automatic", `Auto-approved ${actionRecord.id}`);
639
+ }
640
+
641
+ return { ...actionRecord };
642
+ }
643
+
644
+ attachActionEvidence(
645
+ actionId: string,
646
+ evidence: Pick<ActionQueueItem, "beforeScreenshotPath" | "afterScreenshotPath">
647
+ ): void {
648
+ const action = this.requireAction(actionId);
649
+ if (evidence.beforeScreenshotPath) {
650
+ action.beforeScreenshotPath = evidence.beforeScreenshotPath;
651
+ }
652
+ if (evidence.afterScreenshotPath) {
653
+ action.afterScreenshotPath = evidence.afterScreenshotPath;
654
+ }
655
+ }
656
+
657
+ completeAction(actionId: string, result: string): ActionQueueItem {
658
+ const action = this.requireAction(actionId);
659
+ action.status = "completed";
660
+ action.result = result;
661
+ this.setRuntimeStatus("idle");
662
+ this.recordStatusEvent("action.completed", `Completed action ${actionId}`);
663
+ return { ...action };
664
+ }
665
+
666
+ failAction(actionId: string, message: string): ActionQueueItem {
667
+ const action = this.requireAction(actionId);
668
+ action.status = "failed";
669
+ action.errorMessage = message;
670
+ this.setRuntimeStatus("failed");
671
+ this.recordStatusEvent("action.failed", `Action ${actionId} failed`);
672
+ return { ...action };
673
+ }
674
+
675
+ async ensureWorkspaceDirectories(basePath = this.workspaceBasePath): Promise<void> {
676
+ const directories = [
677
+ this.state.workspace.rootPath,
678
+ ...Object.values(this.state.workspace.directories)
679
+ ];
680
+
681
+ await Promise.all(
682
+ directories.map((directory) =>
683
+ mkdir(path.resolve(basePath, directory), { recursive: true })
684
+ )
685
+ );
686
+ }
687
+
688
+ async writeSessionReport(basePath = this.workspaceBasePath): Promise<string> {
689
+ await this.ensureWorkspaceDirectories(basePath);
690
+
691
+ const reportPath = path.resolve(
692
+ basePath,
693
+ this.state.workspace.directories.reports,
694
+ `session-${Date.now()}.json`
695
+ );
696
+
697
+ const report: SessionReport = {
698
+ summary: this.buildSessionSummary(),
699
+ state: this.getOverview()
700
+ };
701
+
702
+ await writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
703
+
704
+ this.record({
705
+ type: "session.report.created",
706
+ at: new Date().toISOString(),
707
+ detail: "Session report written",
708
+ artifactPath: reportPath
709
+ });
710
+
711
+ return reportPath;
712
+ }
713
+
714
+ async writeMemoryRecord(record: MemoryRecord): Promise<string> {
715
+ this.addMemoryRecord(record);
716
+ await this.ensureWorkspaceDirectories();
717
+
718
+ const memoryPath = path.resolve(
719
+ this.workspaceBasePath,
720
+ this.state.workspace.directories.memory,
721
+ `${record.id}.json`
722
+ );
723
+
724
+ await writeFile(memoryPath, JSON.stringify(record, null, 2), "utf8");
725
+ return memoryPath;
726
+ }
727
+
728
+ async detectProjectAwareness(scanBasePath = this.workspaceBasePath): Promise<ProjectAwareness> {
729
+ const packageJsonPath = path.resolve(scanBasePath, "package.json");
730
+ const frameworks = new Set<string>();
731
+ const services = new Set<string>();
732
+ const packageManagers = new Set<string>();
733
+
734
+ try {
735
+ const packageJsonRaw = await readFile(packageJsonPath, "utf8");
736
+ const packageJson = JSON.parse(packageJsonRaw) as {
737
+ dependencies?: Record<string, string>;
738
+ devDependencies?: Record<string, string>;
739
+ packageManager?: string;
740
+ };
741
+
742
+ const dependencyMap = {
743
+ ...(packageJson.dependencies ?? {}),
744
+ ...(packageJson.devDependencies ?? {})
745
+ };
746
+ const dependencyNames = Object.keys(dependencyMap);
747
+
748
+ if (dependencyNames.includes("next")) frameworks.add("Next.js");
749
+ if (dependencyNames.includes("react")) frameworks.add("React");
750
+ if (dependencyNames.includes("vue")) frameworks.add("Vue");
751
+ if (dependencyNames.includes("angular")) frameworks.add("Angular");
752
+ if (dependencyNames.includes("fastify")) frameworks.add("Fastify");
753
+ if (dependencyNames.includes("express")) frameworks.add("Express");
754
+ if (dependencyNames.some((name) => name.startsWith("@supabase/"))) services.add("Supabase");
755
+ if (dependencyNames.some((name) => name.startsWith("stripe"))) services.add("Stripe");
756
+ if (dependencyNames.includes("mongodb")) services.add("MongoDB");
757
+ if (dependencyNames.includes("pg")) services.add("PostgreSQL");
758
+ if (dependencyNames.includes("playwright")) services.add("Playwright");
759
+
760
+ if (packageJson.packageManager) {
761
+ packageManagers.add(packageJson.packageManager.split("@")[0]);
762
+ } else {
763
+ packageManagers.add("npm");
764
+ }
765
+ } catch {
766
+ packageManagers.add("unknown");
767
+ }
768
+
769
+ return {
770
+ frameworks: [...frameworks],
771
+ services: [...services],
772
+ packageManagers: [...packageManagers],
773
+ summary:
774
+ frameworks.size || services.size
775
+ ? `Detected ${[...frameworks, ...services].join(", ")}`
776
+ : "No strong framework or service signals detected yet"
777
+ };
778
+ }
779
+
780
+ private buildSessionSummary(): SessionSummary {
781
+ const consoleErrors = this.state.snapshots.flatMap((snapshot) => snapshot.consoleLogs ?? []);
782
+ const networkErrors = this.state.snapshots.flatMap((snapshot) =>
783
+ (snapshot.networkEvents ?? []).filter(
784
+ (entry) => entry.outcome === "failed" || (entry.status !== undefined && entry.status >= 400)
785
+ )
786
+ );
787
+ const approvedActions = this.state.actionQueue.filter(
788
+ (action) => action.status === "approved" || action.status === "completed"
789
+ );
790
+ const rejectedActions = this.state.actionQueue.filter((action) => action.status === "rejected");
791
+ const failedActions = this.state.actionQueue.filter((action) => action.status === "failed");
792
+ const blockedActions = this.state.actionQueue.filter((action) => action.status === "blocked");
793
+
794
+ return {
795
+ currentGoal: this.state.steering.currentGoal,
796
+ actionsAttempted: this.state.actionQueue.length,
797
+ approvedActions: approvedActions.length,
798
+ rejectedActions: rejectedActions.length,
799
+ failedActions: failedActions.length,
800
+ blockedActions: blockedActions.length,
801
+ consoleErrors: consoleErrors.filter((entry) => entry.type === "error").length,
802
+ networkErrors: networkErrors.length,
803
+ riskEvents: this.state.actionQueue.filter((action) => action.riskLevel !== "safe").length,
804
+ suggestedFixes: collectSuggestedFixes(this.state.actionQueue, networkErrors),
805
+ reproductionSteps: this.state.actionQueue.map(
806
+ (action) => `${action.actionType}${action.target ? ` ${action.target}` : ""}`
807
+ )
808
+ };
809
+ }
810
+
811
+ private requireAction(actionId: string): ActionQueueItem {
812
+ const action = this.state.actionQueue.find((entry) => entry.id === actionId);
813
+
814
+ if (!action) {
815
+ throw new AgentPortalError("ACTION_NOT_FOUND", `Action ${actionId} does not exist`);
816
+ }
817
+
818
+ return action;
819
+ }
820
+
821
+ private updateAgentStatus(
822
+ agentId: string,
823
+ status: AgentStatus,
824
+ eventType: string
825
+ ): AgentDefinition {
826
+ const agent = this.state.agents.find((entry) => entry.id === agentId);
827
+
828
+ if (!agent) {
829
+ throw new AgentPortalError("AGENT_NOT_FOUND", `Agent ${agentId} does not exist`);
830
+ }
831
+
832
+ agent.status = status;
833
+ this.recordStatusEvent(eventType, `Agent ${agentId} is now ${status}`);
834
+ return { ...agent };
835
+ }
836
+
837
+ private recordStatusEvent(type: string, detail: string): void {
838
+ this.record({
839
+ type,
840
+ at: new Date().toISOString(),
841
+ detail
842
+ });
843
+ }
844
+ }
845
+
846
+ export class BrowserSession {
847
+ private readonly runtime: AgentPortalRuntime;
848
+ private readonly basePath: string;
849
+ private readonly options: BrowserSessionOptions;
850
+ private page?: Page;
851
+ private closeBrowser?: () => Promise<void>;
852
+ private readonly consoleLogs: ConsoleLogEntry[] = [];
853
+ private readonly networkEvents: NetworkEvent[] = [];
854
+ private status: BrowserSessionStatus = "idle";
855
+
856
+ constructor(
857
+ runtime: AgentPortalRuntime,
858
+ options: BrowserSessionOptions = {},
859
+ basePath = options.basePath ?? runtime.getWorkspaceBasePath()
860
+ ) {
861
+ this.runtime = runtime;
862
+ this.options = options;
863
+ this.basePath = basePath;
864
+ }
865
+
866
+ getStatus(): BrowserSessionStatus {
867
+ return this.status;
868
+ }
869
+
870
+ async launch(): Promise<void> {
871
+ if (this.status === "ready" && this.page) {
872
+ return;
873
+ }
874
+
875
+ if (this.status === "launching") {
876
+ throw new AgentPortalError(
877
+ "BROWSER_ALREADY_LAUNCHING",
878
+ "A browser session is already launching."
879
+ );
880
+ }
881
+
882
+ this.runtime.setRuntimeStatus("thinking");
883
+ this.status = "launching";
884
+ this.runtime.setBrowserStatus("launching");
885
+ await this.runtime.ensureWorkspaceDirectories(this.basePath);
886
+
887
+ try {
888
+ const browser = await chromium.launch({
889
+ headless: this.options.headless ?? true
890
+ });
891
+ const context = await browser.newContext({
892
+ viewport: this.options.viewport ?? { width: 1440, height: 900 }
893
+ });
894
+
895
+ this.page = await context.newPage();
896
+ this.attachPageListeners(this.page);
897
+ this.closeBrowser = async () => {
898
+ await context.close().catch(() => undefined);
899
+ await browser.close().catch(() => undefined);
900
+ };
901
+ this.status = "ready";
902
+ this.runtime.setBrowserStatus("ready");
903
+ this.runtime.setRuntimeStatus("idle");
904
+ this.runtime.record({
905
+ type: "browser.launched",
906
+ at: new Date().toISOString(),
907
+ detail: "Browser session launched"
908
+ });
909
+ } catch (error) {
910
+ this.status = "error";
911
+ this.runtime.setBrowserStatus("error");
912
+ this.runtime.setRuntimeStatus("failed");
913
+ throw normalizeError(error, "Failed to launch Playwright browser session.");
914
+ }
915
+ }
916
+
917
+ async open(url: string): Promise<void> {
918
+ await this.runAction(
919
+ { kind: "open", target: url },
920
+ {
921
+ reason: "Open the requested page",
922
+ currentUrl: this.page?.url() || undefined
923
+ },
924
+ async () => {
925
+ const page = this.getPage();
926
+ await page.goto(url, { waitUntil: "domcontentloaded" });
927
+ this.runtime.record({
928
+ type: "browser.opened",
929
+ at: new Date().toISOString(),
930
+ detail: `Opened ${url}`
931
+ });
932
+ }
933
+ );
934
+ }
935
+
936
+ async click(selector: string): Promise<void> {
937
+ await this.runAction(
938
+ { kind: "click", target: selector },
939
+ {
940
+ reason: "Click the requested element",
941
+ currentUrl: this.page?.url(),
942
+ candidateLabel: await this.tryReadLabel(selector)
943
+ },
944
+ async () => {
945
+ const locator = await this.resolveLocator(selector);
946
+ await locator.click();
947
+ this.runtime.record({
948
+ type: "browser.clicked",
949
+ at: new Date().toISOString(),
950
+ detail: `Clicked ${selector}`
951
+ });
952
+ }
953
+ );
954
+ }
955
+
956
+ async type(selector: string, value: string): Promise<void> {
957
+ await this.runAction(
958
+ { kind: "type", target: selector, payload: value },
959
+ {
960
+ reason: "Type into the requested input",
961
+ currentUrl: this.page?.url(),
962
+ candidateLabel: await this.tryReadLabel(selector),
963
+ targetFieldType: await this.tryReadInputType(selector)
964
+ },
965
+ async () => {
966
+ const locator = await this.resolveLocator(selector);
967
+ await locator.fill(value);
968
+ this.runtime.record({
969
+ type: "browser.typed",
970
+ at: new Date().toISOString(),
971
+ detail: `Typed into ${selector}`
972
+ });
973
+ }
974
+ );
975
+ }
976
+
977
+ async hover(selector: string): Promise<void> {
978
+ await this.runAction(
979
+ { kind: "hover", target: selector },
980
+ {
981
+ reason: "Hover over the requested element",
982
+ currentUrl: this.page?.url(),
983
+ candidateLabel: await this.tryReadLabel(selector)
984
+ },
985
+ async () => {
986
+ const locator = await this.resolveLocator(selector);
987
+ await locator.hover();
988
+ this.runtime.record({
989
+ type: "browser.hovered",
990
+ at: new Date().toISOString(),
991
+ detail: `Hovered ${selector}`
992
+ });
993
+ }
994
+ );
995
+ }
996
+
997
+ async scroll(options: ScrollOptions = {}): Promise<void> {
998
+ await this.runAction(
999
+ {
1000
+ kind: "scroll",
1001
+ target: options.selector,
1002
+ payload: `${options.deltaX ?? 0},${options.deltaY ?? 800}`
1003
+ },
1004
+ {
1005
+ reason: "Scroll to reveal more interface context",
1006
+ currentUrl: this.page?.url()
1007
+ },
1008
+ async () => {
1009
+ const page = this.getPage();
1010
+ if (options.selector) {
1011
+ const locator = await this.resolveLocator(options.selector);
1012
+ await locator.scrollIntoViewIfNeeded();
1013
+ this.runtime.record({
1014
+ type: "browser.scrolled",
1015
+ at: new Date().toISOString(),
1016
+ detail: `Scrolled to ${options.selector}`
1017
+ });
1018
+ return;
1019
+ }
1020
+
1021
+ await page.mouse.wheel(options.deltaX ?? 0, options.deltaY ?? 800);
1022
+ this.runtime.record({
1023
+ type: "browser.scrolled",
1024
+ at: new Date().toISOString(),
1025
+ detail: `Scrolled by ${options.deltaX ?? 0}, ${options.deltaY ?? 800}`
1026
+ });
1027
+ }
1028
+ );
1029
+ }
1030
+
1031
+ async waitForSelector(selector: string, timeoutMs = 5000): Promise<void> {
1032
+ await this.runAction(
1033
+ { kind: "wait", target: selector, timeoutMs },
1034
+ {
1035
+ reason: "Wait for an element to become available",
1036
+ currentUrl: this.page?.url(),
1037
+ candidateLabel: await this.tryReadLabel(selector)
1038
+ },
1039
+ async () => {
1040
+ const locator = await this.resolveLocator(selector);
1041
+ await locator.waitFor({ timeout: timeoutMs, state: "visible" });
1042
+ this.runtime.record({
1043
+ type: "browser.waited",
1044
+ at: new Date().toISOString(),
1045
+ detail: `Waited for ${selector}`
1046
+ });
1047
+ }
1048
+ );
1049
+ }
1050
+
1051
+ async execute(script: string): Promise<BrowserExecuteResult> {
1052
+ let result: unknown;
1053
+ await this.runAction(
1054
+ { kind: "execute", payload: script },
1055
+ {
1056
+ reason: "Execute a browser script",
1057
+ currentUrl: this.page?.url()
1058
+ },
1059
+ async () => {
1060
+ const page = this.getPage();
1061
+ result = await page.evaluate((source) => {
1062
+ const callback = new Function(source);
1063
+ return callback();
1064
+ }, script);
1065
+ this.runtime.record({
1066
+ type: "browser.executed",
1067
+ at: new Date().toISOString(),
1068
+ detail: "Executed browser script"
1069
+ });
1070
+ }
1071
+ );
1072
+
1073
+ return { result };
1074
+ }
1075
+
1076
+ async readText(selector: string): Promise<BrowserElementText> {
1077
+ const page = this.getPage();
1078
+ const locator = await this.resolveLocator(selector);
1079
+ const text = await locator.textContent();
1080
+
1081
+ this.runtime.record({
1082
+ type: "browser.read",
1083
+ at: new Date().toISOString(),
1084
+ detail: `Read text from ${selector}`
1085
+ });
1086
+
1087
+ return {
1088
+ selector,
1089
+ text
1090
+ };
1091
+ }
1092
+
1093
+ async inspect(label?: string): Promise<BrowserInspectionResult> {
1094
+ return this.capture({
1095
+ includeAccessibilityTree: true,
1096
+ label
1097
+ });
1098
+ }
1099
+
1100
+ async capture(options: CaptureOptions = {}): Promise<BrowserInspectionResult> {
1101
+ const page = this.getPage();
1102
+ await this.runtime.ensureWorkspaceDirectories(this.basePath);
1103
+
1104
+ const now = new Date().toISOString();
1105
+ const stamp = createArtifactStamp(options.label);
1106
+ const screenshotsDir = path.resolve(
1107
+ this.basePath,
1108
+ this.runtime.getOverview().workspace.directories.screenshots
1109
+ );
1110
+ const screenshotPath = path.join(screenshotsDir, `${stamp}.png`);
1111
+
1112
+ await page.screenshot({ path: screenshotPath, fullPage: true });
1113
+
1114
+ const dom = await page.content();
1115
+ const title = await page.title();
1116
+ const currentUrl = page.url();
1117
+ const accessibilityTree = options.includeAccessibilityTree
1118
+ ? await page.locator("body").ariaSnapshot()
1119
+ : undefined;
1120
+ const detectedElements = (await page.evaluate(() => {
1121
+ const selectors = [
1122
+ "button",
1123
+ "a",
1124
+ "input",
1125
+ "select",
1126
+ "textarea",
1127
+ "form",
1128
+ "[role='dialog']",
1129
+ "[role='tab']",
1130
+ "[role='menu']",
1131
+ "table"
1132
+ ];
1133
+
1134
+ return Array.from(document.querySelectorAll(selectors.join(",")))
1135
+ .slice(0, 50)
1136
+ .map((element, index) => {
1137
+ const htmlElement = element as HTMLElement;
1138
+ const rect = htmlElement.getBoundingClientRect();
1139
+ const tag = htmlElement.tagName.toLowerCase();
1140
+ const role = htmlElement.getAttribute("role");
1141
+ const type: UIElementType =
1142
+ tag === "button"
1143
+ ? "button"
1144
+ : tag === "form"
1145
+ ? "form"
1146
+ : tag === "input" || tag === "textarea"
1147
+ ? "input"
1148
+ : tag === "select"
1149
+ ? "dropdown"
1150
+ : tag === "table"
1151
+ ? "table"
1152
+ : role === "dialog"
1153
+ ? "dialog"
1154
+ : role === "tab"
1155
+ ? "tab"
1156
+ : role === "menu"
1157
+ ? "menu"
1158
+ : tag === "a"
1159
+ ? "link"
1160
+ : "custom";
1161
+
1162
+ const label =
1163
+ htmlElement.innerText?.trim() ||
1164
+ htmlElement.getAttribute("aria-label") ||
1165
+ htmlElement.getAttribute("name") ||
1166
+ undefined;
1167
+
1168
+ return {
1169
+ id: `${type}-${index + 1}`,
1170
+ type,
1171
+ label,
1172
+ selector: htmlElement.id ? `#${htmlElement.id}` : tag,
1173
+ bounds: {
1174
+ x: rect.x,
1175
+ y: rect.y,
1176
+ width: rect.width,
1177
+ height: rect.height
1178
+ },
1179
+ actionable:
1180
+ tag === "button" ||
1181
+ tag === "a" ||
1182
+ tag === "input" ||
1183
+ tag === "select" ||
1184
+ tag === "textarea"
1185
+ };
1186
+ });
1187
+ })) as SerializedUIElement[];
1188
+
1189
+ const consoleLogs = this.consoleLogs.slice(-50);
1190
+ const networkEvents = this.networkEvents.slice(-100);
1191
+
1192
+ const snapshot: VisualSnapshot = {
1193
+ id: stamp,
1194
+ source: "browser",
1195
+ capturedAt: now,
1196
+ screenshotPath,
1197
+ dom,
1198
+ accessibilityTree,
1199
+ consoleLogs,
1200
+ networkEvents,
1201
+ label: options.label,
1202
+ url: currentUrl,
1203
+ detectedElements
1204
+ };
1205
+
1206
+ this.runtime.addSnapshot(snapshot);
1207
+ this.runtime.record({
1208
+ type: "browser.captured",
1209
+ at: now,
1210
+ detail: `Captured ${currentUrl}`,
1211
+ artifactPath: screenshotPath
1212
+ });
1213
+
1214
+ return {
1215
+ url: currentUrl,
1216
+ title,
1217
+ dom,
1218
+ accessibilityTree,
1219
+ screenshotPath,
1220
+ consoleLogs,
1221
+ networkEvents,
1222
+ snapshot
1223
+ };
1224
+ }
1225
+
1226
+ async close(): Promise<void> {
1227
+ if (!this.closeBrowser) {
1228
+ this.status = "closed";
1229
+ this.runtime.setBrowserStatus("closed");
1230
+ return;
1231
+ }
1232
+
1233
+ this.status = "closing";
1234
+ this.runtime.setBrowserStatus("closing");
1235
+
1236
+ await this.closeBrowser().catch(() => undefined);
1237
+ this.closeBrowser = undefined;
1238
+ this.page = undefined;
1239
+ this.status = "closed";
1240
+ this.runtime.setBrowserStatus("closed");
1241
+
1242
+ this.runtime.record({
1243
+ type: "browser.closed",
1244
+ at: new Date().toISOString(),
1245
+ detail: "Browser session closed"
1246
+ });
1247
+ }
1248
+
1249
+ async shutdownGracefully(): Promise<void> {
1250
+ await this.close();
1251
+ }
1252
+
1253
+ private async runAction(
1254
+ action: BrowserAction,
1255
+ context: ActionContext,
1256
+ executor: () => Promise<void>
1257
+ ): Promise<void> {
1258
+ this.ensureBrowserReady();
1259
+
1260
+ const actionRecord = this.runtime.createActionRecord(action, context);
1261
+
1262
+ if (actionRecord.status === "blocked") {
1263
+ throw new ActionBlockedError(
1264
+ actionRecord.id,
1265
+ actionRecord.blockedReason ?? "Action was blocked by steering policy."
1266
+ );
1267
+ }
1268
+
1269
+ if (actionRecord.status === "pending") {
1270
+ throw new ActionApprovalRequiredError(
1271
+ actionRecord.id,
1272
+ "Action requires user approval before execution."
1273
+ );
1274
+ }
1275
+
1276
+ this.runtime.setRuntimeStatus("acting");
1277
+
1278
+ try {
1279
+ const beforeScreenshotPath = await this.captureActionEvidence(
1280
+ `${actionRecord.id}-before`
1281
+ );
1282
+ this.runtime.attachActionEvidence(actionRecord.id, { beforeScreenshotPath });
1283
+ await executor();
1284
+ const afterScreenshotPath = await this.captureActionEvidence(`${actionRecord.id}-after`);
1285
+ this.runtime.attachActionEvidence(actionRecord.id, { afterScreenshotPath });
1286
+ this.runtime.completeAction(actionRecord.id, "Executed successfully");
1287
+ } catch (error) {
1288
+ this.status = this.status === "disconnected" ? "disconnected" : "error";
1289
+ this.runtime.setBrowserStatus(this.status);
1290
+ const normalized = normalizeError(error, "Browser action failed.");
1291
+ this.runtime.failAction(actionRecord.id, normalized.message);
1292
+ throw normalized;
1293
+ }
1294
+ }
1295
+
1296
+ private async captureActionEvidence(label: string): Promise<string | undefined> {
1297
+ if (this.options.evidenceScreenshots === false || !this.page) {
1298
+ return undefined;
1299
+ }
1300
+
1301
+ const screenshotsDir = path.resolve(
1302
+ this.basePath,
1303
+ this.runtime.getOverview().workspace.directories.screenshots
1304
+ );
1305
+ const filePath = path.join(screenshotsDir, `${createArtifactStamp(label)}.png`);
1306
+ await this.page.screenshot({ path: filePath, fullPage: true }).catch(() => undefined);
1307
+ return filePath;
1308
+ }
1309
+
1310
+ private attachPageListeners(page: Page): void {
1311
+ page.on("console", (message) => {
1312
+ this.consoleLogs.push({
1313
+ type: normalizeConsoleType(message.type()),
1314
+ text: message.text(),
1315
+ location: message.location().url || undefined,
1316
+ at: new Date().toISOString()
1317
+ });
1318
+ });
1319
+ page.on("request", (request) => {
1320
+ this.networkEvents.push({
1321
+ url: request.url(),
1322
+ method: request.method(),
1323
+ resourceType: request.resourceType(),
1324
+ outcome: "pending",
1325
+ at: new Date().toISOString()
1326
+ });
1327
+ });
1328
+ page.on("response", (response) => {
1329
+ this.networkEvents.push({
1330
+ url: response.url(),
1331
+ method: response.request().method(),
1332
+ resourceType: response.request().resourceType(),
1333
+ status: response.status(),
1334
+ outcome: response.ok() ? "ok" : "failed",
1335
+ at: new Date().toISOString()
1336
+ });
1337
+ });
1338
+ page.on("requestfailed", (request) => {
1339
+ this.networkEvents.push({
1340
+ url: request.url(),
1341
+ method: request.method(),
1342
+ resourceType: request.resourceType(),
1343
+ outcome: "failed",
1344
+ failureText: request.failure()?.errorText,
1345
+ at: new Date().toISOString()
1346
+ });
1347
+ });
1348
+ page.on("close", () => {
1349
+ this.status = "disconnected";
1350
+ this.runtime.setBrowserStatus("disconnected");
1351
+ this.runtime.record({
1352
+ type: "browser.disconnected",
1353
+ at: new Date().toISOString(),
1354
+ detail: "Browser page disconnected"
1355
+ });
1356
+ });
1357
+ page.on("crash", () => {
1358
+ this.status = "error";
1359
+ this.runtime.setBrowserStatus("error");
1360
+ this.runtime.record({
1361
+ type: "browser.crashed",
1362
+ at: new Date().toISOString(),
1363
+ detail: "Browser page crashed"
1364
+ });
1365
+ });
1366
+ }
1367
+
1368
+ private async resolveLocator(selector: string): Promise<Locator> {
1369
+ const page = this.getPage();
1370
+ const candidates = buildLocatorCandidates(selector);
1371
+
1372
+ for (const candidate of candidates) {
1373
+ const locator = page.locator(candidate).first();
1374
+ const count = await locator.count().catch(() => 0);
1375
+
1376
+ if (count > 0) {
1377
+ return locator;
1378
+ }
1379
+ }
1380
+
1381
+ throw new AgentPortalError(
1382
+ "ELEMENT_NOT_FOUND",
1383
+ `Unable to find an element for target "${selector}".`,
1384
+ "Try approving an edited action with a more specific selector."
1385
+ );
1386
+ }
1387
+
1388
+ private async tryReadLabel(selector: string): Promise<string | undefined> {
1389
+ try {
1390
+ const locator = await this.resolveLocator(selector);
1391
+ const text = await locator.textContent();
1392
+ return text?.trim() || undefined;
1393
+ } catch {
1394
+ return undefined;
1395
+ }
1396
+ }
1397
+
1398
+ private async tryReadInputType(selector: string): Promise<string | undefined> {
1399
+ try {
1400
+ const locator = await this.resolveLocator(selector);
1401
+ return (await locator.getAttribute("type")) ?? undefined;
1402
+ } catch {
1403
+ return undefined;
1404
+ }
1405
+ }
1406
+
1407
+ private ensureBrowserReady(): void {
1408
+ if (this.status === "disconnected") {
1409
+ throw new AgentPortalError(
1410
+ "BROWSER_DISCONNECTED",
1411
+ "The browser session was disconnected. Start a new session and retry."
1412
+ );
1413
+ }
1414
+
1415
+ if (this.status === "error") {
1416
+ throw new AgentPortalError(
1417
+ "BROWSER_ERROR",
1418
+ "The browser session is in an error state. Restart the runtime and retry."
1419
+ );
1420
+ }
1421
+ }
1422
+
1423
+ private getPage(): Page {
1424
+ if (!this.page) {
1425
+ throw new AgentPortalError(
1426
+ "BROWSER_NOT_READY",
1427
+ "Browser session has not been launched yet."
1428
+ );
1429
+ }
1430
+
1431
+ return this.page;
1432
+ }
1433
+ }
1434
+
1435
+ export class VisionCore {
1436
+ analyze(context: VisionContext): ScreenUnderstanding {
1437
+ const { snapshot, goal } = context;
1438
+ const domText = `${snapshot.dom ?? ""} ${snapshot.accessibilityTree ?? ""}`.toLowerCase();
1439
+ const issues: string[] = [];
1440
+ const nextActions: string[] = [];
1441
+ const rootCauseHypotheses: RootCauseHypothesis[] = [];
1442
+
1443
+ const pageCategory: ScreenUnderstanding["pageCategory"] = domText.includes("password")
1444
+ ? "login"
1445
+ : domText.includes("sign up") || domText.includes("create account")
1446
+ ? "signup"
1447
+ : domText.includes("dashboard")
1448
+ ? "dashboard"
1449
+ : domText.includes("pricing")
1450
+ ? "pricing"
1451
+ : domText.includes("docs") || domText.includes("documentation")
1452
+ ? "docs"
1453
+ : snapshot.detectedElements.some((element) => element.type === "form")
1454
+ ? "form"
1455
+ : "unknown";
1456
+
1457
+ const consoleErrors = (snapshot.consoleLogs ?? []).filter((entry) => entry.type === "error");
1458
+ const failedNetwork = (snapshot.networkEvents ?? []).filter(
1459
+ (entry) => entry.outcome === "failed" || (entry.status !== undefined && entry.status >= 400)
1460
+ );
1461
+
1462
+ if (consoleErrors.length > 0) {
1463
+ issues.push(`Console errors detected: ${consoleErrors.length}`);
1464
+ rootCauseHypotheses.push({
1465
+ label: "Frontend runtime issue",
1466
+ confidence: 0.72,
1467
+ evidence: consoleErrors.slice(0, 3).map((entry) => entry.text)
1468
+ });
1469
+ }
1470
+
1471
+ if (failedNetwork.length > 0) {
1472
+ issues.push(`Network failures detected: ${failedNetwork.length}`);
1473
+ rootCauseHypotheses.push({
1474
+ label: "Backend or API route failure",
1475
+ confidence: 0.82,
1476
+ evidence: failedNetwork
1477
+ .slice(0, 3)
1478
+ .map((entry) => `${entry.status ?? "failed"} ${entry.url}`)
1479
+ });
1480
+ }
1481
+
1482
+ if (pageCategory === "login") {
1483
+ nextActions.push("Fill email and password");
1484
+ nextActions.push("Submit authentication form");
1485
+ nextActions.push("Verify redirect or error response");
1486
+ } else if (pageCategory === "signup") {
1487
+ nextActions.push("Open account creation form");
1488
+ nextActions.push("Validate required fields");
1489
+ nextActions.push("Submit and verify onboarding state");
1490
+ } else if (pageCategory === "form") {
1491
+ nextActions.push("Fill detected inputs");
1492
+ nextActions.push("Submit the form");
1493
+ nextActions.push("Inspect confirmation or validation result");
1494
+ } else {
1495
+ nextActions.push("Inspect primary call-to-action");
1496
+ nextActions.push("Navigate through key visible links");
1497
+ }
1498
+
1499
+ const summary = goal
1500
+ ? `Goal "${goal}" is being evaluated on a ${pageCategory} interface with ${snapshot.detectedElements.length} detected elements.`
1501
+ : `Detected a ${pageCategory} interface with ${snapshot.detectedElements.length} actionable or structural elements.`;
1502
+
1503
+ return {
1504
+ pageCategory,
1505
+ summary,
1506
+ userIntent:
1507
+ pageCategory === "login"
1508
+ ? "Authenticate into an account"
1509
+ : pageCategory === "signup"
1510
+ ? "Create a new account"
1511
+ : pageCategory === "form"
1512
+ ? "Complete a structured workflow"
1513
+ : undefined,
1514
+ issues,
1515
+ likelyNextActions: nextActions,
1516
+ rootCauseHypotheses
1517
+ };
1518
+ }
1519
+ }
1520
+
1521
+ export class GoalPlanner {
1522
+ createPlan(goal: GoalDefinition, understanding?: ScreenUnderstanding): GoalPlan {
1523
+ const title = goal.title.toLowerCase();
1524
+ const steps: GoalPlanStep[] = [];
1525
+
1526
+ if (title.includes("sign") || title.includes("login") || title.includes("authenticate")) {
1527
+ steps.push(
1528
+ createPlanStep("open", "Open the authentication surface"),
1529
+ createPlanStep("find-form", "Locate the login form and primary submit action"),
1530
+ createPlanStep("fill-form", "Fill the required credentials or test data"),
1531
+ createPlanStep("submit", "Submit the form and observe the response"),
1532
+ createPlanStep("verify", "Verify redirect, session state, or failure evidence")
1533
+ );
1534
+ } else if (title.includes("test") || title.includes("qa")) {
1535
+ steps.push(
1536
+ createPlanStep("open", "Open the target page or workflow"),
1537
+ createPlanStep("explore", "Traverse the key interactive controls"),
1538
+ createPlanStep("edge-cases", "Try invalid, empty, and unexpected inputs"),
1539
+ createPlanStep("capture", "Capture visual and technical evidence"),
1540
+ createPlanStep("report", "Summarize issues, root causes, and next actions")
1541
+ );
1542
+ } else {
1543
+ steps.push(
1544
+ createPlanStep("understand", "Inspect the current interface and classify the flow"),
1545
+ createPlanStep("act", "Perform the most likely actions toward the goal"),
1546
+ createPlanStep("verify", "Check whether the intended outcome occurred"),
1547
+ createPlanStep("report", "Write the result into memory and reporting artifacts")
1548
+ );
1549
+ }
1550
+
1551
+ if (understanding?.issues.length) {
1552
+ steps.unshift({
1553
+ id: "issue-triage",
1554
+ title: "issue triage",
1555
+ detail: `Review detected issues before execution: ${understanding.issues.join("; ")}`,
1556
+ status: "ready"
1557
+ });
1558
+ }
1559
+
1560
+ return {
1561
+ goal,
1562
+ steps
1563
+ };
1564
+ }
1565
+ }
1566
+
1567
+ export class PortalGraph {
1568
+ fromSnapshot(snapshot: VisualSnapshot, understanding: ScreenUnderstanding): PortalGraphNode {
1569
+ const links = snapshot.detectedElements
1570
+ .filter((element) => element.type === "link" && element.label)
1571
+ .map((element) => element.label as string);
1572
+
1573
+ return {
1574
+ id: snapshot.id,
1575
+ label: snapshot.label ?? snapshot.id,
1576
+ pageCategory: understanding.pageCategory,
1577
+ url: snapshot.url,
1578
+ links
1579
+ };
1580
+ }
1581
+ }
1582
+
1583
+ export function createDefaultWorkspace(name: string): WorkspaceDefinition {
1584
+ const root = `workspaces/${name}`;
1585
+
1586
+ return {
1587
+ id: name.toLowerCase().replace(/\s+/g, "-"),
1588
+ name,
1589
+ rootPath: root,
1590
+ directories: {
1591
+ browserSessions: `${root}/browser-sessions`,
1592
+ documents: `${root}/documents`,
1593
+ reports: `${root}/reports`,
1594
+ screenshots: `${root}/screenshots`,
1595
+ logs: `${root}/logs`,
1596
+ agents: `${root}/agents`,
1597
+ tasks: `${root}/tasks`,
1598
+ workflows: `${root}/workflows`,
1599
+ memory: `${root}/memory`,
1600
+ settings: `${root}/settings`
1601
+ }
1602
+ };
1603
+ }
1604
+
1605
+ export function resolveWorkspaceFileUrl(
1606
+ workspaceBasePath: string,
1607
+ relativeFilePath: string
1608
+ ): string {
1609
+ return pathToFileURL(path.resolve(workspaceBasePath, relativeFilePath)).toString();
1610
+ }
1611
+
1612
+ export function validatePluginManifest(manifest: PluginManifest): string[] {
1613
+ const errors: string[] = [];
1614
+
1615
+ if (!manifest.name) errors.push("Plugin manifest is missing a name.");
1616
+ if (!manifest.version) errors.push("Plugin manifest is missing a version.");
1617
+ if (!manifest.type) errors.push("Plugin manifest is missing a type.");
1618
+ if (!Array.isArray(manifest.permissions)) errors.push("Plugin permissions must be an array.");
1619
+ if (!Array.isArray(manifest.commands)) errors.push("Plugin commands must be an array.");
1620
+
1621
+ return errors;
1622
+ }
1623
+
1624
+ function evaluateActionPolicy(
1625
+ action: BrowserAction,
1626
+ context: ActionContext,
1627
+ steering: SteeringState
1628
+ ): ActionPolicyDecision {
1629
+ const matchedPolicies: string[] = [];
1630
+
1631
+ if (steering.stopped) {
1632
+ return {
1633
+ riskLevel: "blocked",
1634
+ requiresApproval: false,
1635
+ blockedReason: "Agent execution is stopped.",
1636
+ matchedPolicies: ["steering:stopped"]
1637
+ };
1638
+ }
1639
+
1640
+ if (steering.paused) {
1641
+ return {
1642
+ riskLevel: "medium",
1643
+ requiresApproval: true,
1644
+ blockedReason: undefined,
1645
+ matchedPolicies: ["steering:paused"]
1646
+ };
1647
+ }
1648
+
1649
+ if (
1650
+ steering.lockedDomain &&
1651
+ action.kind === "open" &&
1652
+ action.target &&
1653
+ !domainMatches(action.target, steering.lockedDomain)
1654
+ ) {
1655
+ return {
1656
+ riskLevel: "blocked",
1657
+ requiresApproval: false,
1658
+ blockedReason: `Action is outside locked domain ${steering.lockedDomain}.`,
1659
+ matchedPolicies: ["steering:domain-lock"]
1660
+ };
1661
+ }
1662
+
1663
+ if (
1664
+ steering.lockedTabUrl &&
1665
+ context.currentUrl &&
1666
+ context.currentUrl !== steering.lockedTabUrl &&
1667
+ action.kind !== "open"
1668
+ ) {
1669
+ return {
1670
+ riskLevel: "blocked",
1671
+ requiresApproval: false,
1672
+ blockedReason: "Agent is locked to the current tab.",
1673
+ matchedPolicies: ["steering:tab-lock"]
1674
+ };
1675
+ }
1676
+
1677
+ let riskLevel: ActionRiskLevel = "low";
1678
+
1679
+ if (action.kind === "inspect" || action.kind === "capture" || action.kind === "wait") {
1680
+ riskLevel = "safe";
1681
+ matchedPolicies.push("read-only");
1682
+ } else if (action.kind === "scroll" || action.kind === "hover" || action.kind === "open") {
1683
+ riskLevel = "low";
1684
+ matchedPolicies.push("navigation");
1685
+ } else if (action.kind === "type") {
1686
+ riskLevel = "low";
1687
+ matchedPolicies.push("typing");
1688
+ } else if (action.kind === "click") {
1689
+ riskLevel = "low";
1690
+ matchedPolicies.push("click");
1691
+ } else if (action.kind === "execute") {
1692
+ riskLevel = "medium";
1693
+ matchedPolicies.push("script-execution");
1694
+ }
1695
+
1696
+ const candidateText = `${action.target ?? ""} ${context.candidateLabel ?? ""} ${context.reason}`.toLowerCase();
1697
+
1698
+ if (context.targetFieldType === "password") {
1699
+ riskLevel = "blocked";
1700
+ matchedPolicies.push("password-protection");
1701
+ } else if (
1702
+ includesAny(candidateText, ["billing", "payment", "checkout", "subscribe", "card"])
1703
+ ) {
1704
+ riskLevel = "blocked";
1705
+ matchedPolicies.push("billing-protection");
1706
+ } else if (includesAny(candidateText, ["delete", "remove", "drop database", "destroy"])) {
1707
+ riskLevel = "high";
1708
+ matchedPolicies.push("destructive-action");
1709
+ } else if (includesAny(candidateText, ["send", "post publicly", "publish", "submit"])) {
1710
+ riskLevel = maxRiskLevel(riskLevel, "medium");
1711
+ matchedPolicies.push("submission");
1712
+ } else if (includesAny(candidateText, ["login", "sign in", "authenticate"])) {
1713
+ riskLevel = maxRiskLevel(riskLevel, "medium");
1714
+ matchedPolicies.push("authentication");
1715
+ } else if (includesAny(candidateText, ["settings", "admin"])) {
1716
+ riskLevel = maxRiskLevel(riskLevel, "high");
1717
+ matchedPolicies.push("settings-change");
1718
+ }
1719
+
1720
+ if (riskLevel === "blocked") {
1721
+ return {
1722
+ riskLevel,
1723
+ requiresApproval: false,
1724
+ blockedReason: "This action is blocked until the user explicitly changes the steering policy.",
1725
+ matchedPolicies
1726
+ };
1727
+ }
1728
+
1729
+ const requiresApproval =
1730
+ steering.stepByStepMode ||
1731
+ compareRiskLevel(riskLevel, steering.requireApprovalAtOrAbove) >= 0;
1732
+
1733
+ return {
1734
+ riskLevel,
1735
+ requiresApproval,
1736
+ matchedPolicies
1737
+ };
1738
+ }
1739
+
1740
+ function collectSuggestedFixes(
1741
+ actions: ActionQueueItem[],
1742
+ networkErrors: NetworkEvent[]
1743
+ ): string[] {
1744
+ const fixes = new Set<string>();
1745
+
1746
+ if (actions.some((action) => action.status === "failed")) {
1747
+ fixes.add("Inspect the failed action targets and verify selectors still match the UI.");
1748
+ }
1749
+
1750
+ if (actions.some((action) => action.status === "blocked")) {
1751
+ fixes.add("Review steering policy locks or approval thresholds before retrying blocked actions.");
1752
+ }
1753
+
1754
+ if (networkErrors.length > 0) {
1755
+ fixes.add("Check backend routes or local development server health for failed network requests.");
1756
+ }
1757
+
1758
+ if (fixes.size === 0) {
1759
+ fixes.add("No urgent fixes suggested from this session.");
1760
+ }
1761
+
1762
+ return [...fixes];
1763
+ }
1764
+
1765
+ function createArtifactStamp(label?: string): string {
1766
+ const suffix = label ? `-${label.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}` : "";
1767
+ return `${Date.now()}${suffix}`;
1768
+ }
1769
+
1770
+ function createActionId(kind: string): string {
1771
+ return `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1772
+ }
1773
+
1774
+ function normalizeConsoleType(type: string): ConsoleLogEntry["type"] {
1775
+ if (type === "warning" || type === "error" || type === "info") {
1776
+ return type;
1777
+ }
1778
+
1779
+ return "log";
1780
+ }
1781
+
1782
+ function createPlanStep(id: string, detail: string): GoalPlanStep {
1783
+ return {
1784
+ id,
1785
+ title: id.replace(/-/g, " "),
1786
+ detail,
1787
+ status: "ready"
1788
+ };
1789
+ }
1790
+
1791
+ function compareRiskLevel(left: ActionRiskLevel, right: ActionRiskLevel): number {
1792
+ const order: ActionRiskLevel[] = ["safe", "low", "medium", "high", "blocked"];
1793
+ return order.indexOf(left) - order.indexOf(right);
1794
+ }
1795
+
1796
+ function maxRiskLevel(left: ActionRiskLevel, right: ActionRiskLevel): ActionRiskLevel {
1797
+ return compareRiskLevel(left, right) >= 0 ? left : right;
1798
+ }
1799
+
1800
+ function includesAny(haystack: string, needles: string[]): boolean {
1801
+ return needles.some((needle) => {
1802
+ const escaped = needle.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
1803
+ const regex = new RegExp(`\\b${escaped}\\b`, "i");
1804
+ return regex.test(haystack);
1805
+ });
1806
+ }
1807
+
1808
+ function domainMatches(target: string, domain: string): boolean {
1809
+ try {
1810
+ const url = new URL(target);
1811
+ return url.hostname === domain;
1812
+ } catch {
1813
+ return false;
1814
+ }
1815
+ }
1816
+
1817
+ function buildLocatorCandidates(selector: string): string[] {
1818
+ const normalized = selector.replace(/^#/, "").trim();
1819
+ const escaped = normalized.replace(/"/g, '\\"');
1820
+
1821
+ return [
1822
+ selector,
1823
+ `#${escaped}`,
1824
+ `[aria-label="${escaped}"]`,
1825
+ `[name="${escaped}"]`,
1826
+ `button:has-text("${escaped}")`,
1827
+ `a:has-text("${escaped}")`,
1828
+ `text="${escaped}"`
1829
+ ];
1830
+ }
1831
+
1832
+ function normalizeError(error: unknown, fallbackMessage: string): AgentPortalError {
1833
+ if (error instanceof AgentPortalError) {
1834
+ return error;
1835
+ }
1836
+
1837
+ if (error instanceof Error) {
1838
+ return new AgentPortalError("RUNTIME_ERROR", error.message || fallbackMessage);
1839
+ }
1840
+
1841
+ return new AgentPortalError("RUNTIME_ERROR", fallbackMessage);
1842
+ }