AkurAI Build
Menu

popagent

public

Latest change 6edc7e928c57dab23c0973ed3f7ee70b3ebb72d5 - Initial popagent baseline by AkurAI Build

import type { KeyboardEventParams, MouseEventParams, ScreencastOptions, ScreencastStream } from "@mastra/core/browser";
import type { BrowserSession } from "./api-types";

export type ManagedBrowserState = {
  currentUrl: string | null;
  tabs: Array<{ id: string; url: string; title: string }>;
  activeTabIndex: number;
};

export type ManagedBrowser = {
  getState(threadId?: string): Promise<ManagedBrowserState | null>;
  closeThreadSession(threadId: string): Promise<void>;
  startScreencast(options?: ScreencastOptions): Promise<ScreencastStream>;
  injectMouseEvent(event: MouseEventParams, threadId?: string): Promise<void>;
  injectKeyboardEvent(event: KeyboardEventParams, threadId?: string): Promise<void>;
};

type Entry = {
  id: string;
  threadId: string;
  access: "interactive" | "read-only";
  browser: ManagedBrowser;
  createdAt: number;
  lastActivityAt: number;
  takeover: boolean;
};

export class BrowserSessionRegistry {
  private entries = new Map<string, Entry>();
  private maxSessions = 4;
  private idleTimeoutMs = 900_000;

  constructor(private readonly now: () => number = Date.now) {}

  configure(settings: { maxSessions: number; idleTimeoutMs: number }) {
    if (!Number.isInteger(settings.maxSessions) || settings.maxSessions < 1) throw new RangeError("maxSessions must be positive");
    if (!Number.isFinite(settings.idleTimeoutMs) || settings.idleTimeoutMs < 1) throw new RangeError("idleTimeoutMs must be positive");
    this.maxSessions = settings.maxSessions;
    this.idleTimeoutMs = settings.idleTimeoutMs;
  }

  async touch(threadId: string, access: "interactive" | "read-only", browser: ManagedBrowser) {
    const id = `${access}:${threadId}`;
    const existing = this.entries.get(id);
    if (existing) {
      existing.lastActivityAt = this.now();
      existing.browser = browser;
      return;
    }
    while (this.entries.size >= this.maxSessions) {
      const oldest = [...this.entries.values()].sort((left, right) => left.lastActivityAt - right.lastActivityAt)[0];
      if (!oldest) break;
      await this.close(oldest.id);
    }
    const now = this.now();
    this.entries.set(id, { id, threadId, access, browser, createdAt: now, lastActivityAt: now, takeover: false });
  }

  async list(threadId?: string): Promise<BrowserSession[]> {
    const entries = [...this.entries.values()]
      .filter((entry) => threadId === undefined || entry.threadId === threadId)
      .sort((left, right) => right.lastActivityAt - left.lastActivityAt);
    return Promise.all(entries.map(async (entry) => {
      let state: ManagedBrowserState | null = null;
      let status: BrowserSession["status"] = "active";
      try {
        state = await entry.browser.getState(entry.threadId);
      } catch {
        status = "error";
      }
      return {
        id: entry.id,
        threadId: entry.threadId,
        access: entry.access,
        status,
        currentUrl: state?.currentUrl ?? null,
        tabs: state?.tabs ?? [],
        activeTabIndex: state?.activeTabIndex ?? 0,
        createdAt: new Date(entry.createdAt).toISOString(),
        lastActivityAt: new Date(entry.lastActivityAt).toISOString(),
      };
    }));
  }

  async close(id: string): Promise<boolean> {
    const entry = this.entries.get(id);
    if (!entry) return false;
    this.entries.delete(id);
    await entry.browser.closeThreadSession(entry.threadId);
    return true;
  }

  async closeThread(threadId: string): Promise<number> {
    const matches = [...this.entries.values()].filter((entry) => entry.threadId === threadId);
    await Promise.all(matches.map((entry) => this.close(entry.id)));
    return matches.length;
  }

  async cleanupIdle(): Promise<number> {
    const threshold = this.now() - this.idleTimeoutMs;
    const expired = [...this.entries.values()].filter((entry) => entry.lastActivityAt < threshold);
    await Promise.all(expired.map((entry) => this.close(entry.id)));
    return expired.length;
  }

  setTakeover(id: string, enabled: boolean): boolean {
    const entry = this.entries.get(id);
    if (!entry || entry.access !== "interactive") return false;
    entry.takeover = enabled;
    return true;
  }

  async startScreencast(id: string): Promise<ScreencastStream> {
    const entry = this.entries.get(id);
    if (!entry) throw new Error("Browser session not found");
    entry.lastActivityAt = this.now();
    return entry.browser.startScreencast({ threadId: entry.threadId });
  }

  async injectMouse(id: string, event: MouseEventParams): Promise<void> {
    const entry = this.takeoverEntry(id);
    await entry.browser.injectMouseEvent(event, entry.threadId);
    entry.lastActivityAt = this.now();
  }

  async injectKeyboard(id: string, event: KeyboardEventParams): Promise<void> {
    const entry = this.takeoverEntry(id);
    await entry.browser.injectKeyboardEvent(event, entry.threadId);
    entry.lastActivityAt = this.now();
  }

  private takeoverEntry(id: string): Entry {
    const entry = this.entries.get(id);
    if (!entry) throw new Error("Browser session not found");
    if (!entry.takeover || entry.access !== "interactive") throw new Error("Browser takeover is not enabled");
    return entry;
  }
}