Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Pulls the `page` argument out of a browser-tool dispatch so the
* cockpit's tab-activity registry can attribute the call to a tab.
* Tools without a `page` parameter (`tab_groups`, `windows`, `run`)
* always yield null. Tools that accept it optionally (`tabs` action
* variants like `list` vs `close`) yield null when the caller omits
* it. Non-integer / non-positive values are rejected to keep the
* registry from holding garbage keys.
*/

const TOOLS_WITH_PAGE: ReadonlySet<string> = new Set([
'act',
'diff',
'download',
'evaluate',
'grep',
'navigate',
'pdf',
'read',
'screenshot',
'snapshot',
'tabs',
'upload',
'wait',
])

export function extractPageId(
toolName: string,
rawArgs: unknown,
): number | null {
if (!TOOLS_WITH_PAGE.has(toolName)) return null
if (!rawArgs || typeof rawArgs !== 'object') return null
const page = (rawArgs as { page?: unknown }).page
if (typeof page !== 'number') return null
if (!Number.isInteger(page) || page < 1) return null
return page
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Process-wide singleton registry. Bound to the same
* `getBrowserSession` accessor the rest of the cockpit uses so the
* registry sees the same `PageManager` instance the tool dispatches
* write to.
*/

import { getBrowserSession } from '../browser-session'
import { createTabActivityRegistry, type TabActivityRegistry } from './registry'

export const tabActivityRegistry: TabActivityRegistry =
createTabActivityRegistry({ getSession: getBrowserSession })

export { extractPageId } from './extract-page-id'
export type { TabActivityRecord, TabActivityRegistry } from './registry'
export { ACTIVE_WINDOW_MS, createTabActivityRegistry } from './registry'
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* In-memory registry mapping a stable CDP target id to the most
* recent agent-tool dispatch that touched it. The cockpit's
* `mcp/register.ts` wrapper writes a record after every successful
* `executeTool` call; the homepage polls `GET /tabs/activity` to
* render the live view.
*
* `status` is derived at read time: a record is `active` when the
* last tool fired within `ACTIVE_WINDOW_MS`, otherwise `idle`. No
* background timers. Records whose underlying tab has closed are
* evicted lazily on the next `snapshot()` read; we detect that by
* looking up `pageId` on the live `PageManager` and confirming the
* targetId still matches (pageIds are reused after a tab closes).
*/

import type { BrowserSession } from '@browseros/server/browser/core/session'

export interface TabActivityRecord {
targetId: string
pageId: number
url: string
title: string
agentId: string
slug: string
lastToolAt: number
lastToolName: string
status: 'active' | 'idle'
}

export const ACTIVE_WINDOW_MS = 5000

export interface RegistryDeps {
getSession(): BrowserSession | null
now?: () => number
}

interface RawRecord {
targetId: string
pageId: number
agentId: string
slug: string
lastToolAt: number
lastToolName: string
}

export interface TabActivityRegistry {
recordTool(input: {
agentId: string
slug: string
pageId: number
targetId: string
toolName: string
}): void
snapshot(): TabActivityRecord[]
// Test-only escape hatch; lets unit tests assert eviction without
// mocking BrowserSession internals.
size(): number
}

export function createTabActivityRegistry(
deps: RegistryDeps,
): TabActivityRegistry {
const records = new Map<string, RawRecord>()
const now = deps.now ?? (() => Date.now())

return {
recordTool(input) {
records.set(input.targetId, {
targetId: input.targetId,
pageId: input.pageId,
agentId: input.agentId,
slug: input.slug,
lastToolAt: now(),
lastToolName: input.toolName,
})
},
snapshot() {
const session = deps.getSession()
if (!session) return []
const out: TabActivityRecord[] = []
const t = now()
for (const [targetId, raw] of records) {
const live = session.pages.getInfo(raw.pageId)
// PageManager reuses pageId after a tab closes; the targetId
// is the stable identity. If they no longer match, the
// original tab is gone (the pageId may now belong to a
// different tab).
if (!live || live.targetId !== targetId) {
records.delete(targetId)
continue
}
out.push({
targetId: raw.targetId,
pageId: raw.pageId,
url: live.url,
title: live.title,
agentId: raw.agentId,
slug: raw.slug,
lastToolAt: raw.lastToolAt,
lastToolName: raw.lastToolName,
status: t - raw.lastToolAt < ACTIVE_WINDOW_MS ? 'active' : 'idle',
})
}
return out.sort((a, b) => b.lastToolAt - a.lastToolAt)
},
size() {
return records.size
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { ZodRawShape } from 'zod'
import { getBrowserSession } from '../lib/browser-session'
import { logger } from '../lib/logger'
import { extractPageId, tabActivityRegistry } from '../lib/tab-activity'
import type { StoredAgentProfile } from '../routes/agents/schemas'
import { check } from '../services/permissions'
import { asRegister, type ToolResult } from './register-fn'
Expand Down Expand Up @@ -117,6 +118,32 @@ function domainForCall(
return agent.selectedSites[0] ?? '*'
}

/**
* Records a successful dispatch into the tab-activity registry. The
* homepage attributes the tab to the agent and surfaces the latest
* tool name. Failed dispatches and tools without a `page` arg are
* skipped at the call site by `extractPageId` returning `null`.
*/
function recordSuccessfulDispatch(args: {
toolName: string
rawArgs: unknown
agent: StoredAgentProfile
session: ReturnType<typeof getBrowserSession>
}): void {
if (!args.session) return
const pageId = extractPageId(args.toolName, args.rawArgs)
if (pageId === null) return
const live = args.session.pages.getInfo(pageId)
if (!live) return
tabActivityRegistry.recordTool({
agentId: args.agent.id,
slug: args.agent.slug,
pageId,
targetId: live.targetId,
toolName: args.toolName,
})
}

export function registerBrowserTools(
server: McpServer,
agent: StoredAgentProfile,
Expand Down Expand Up @@ -214,6 +241,14 @@ export function registerBrowserTools(
session,
signal: extra?.signal,
})
if (!result.isError) {
recordSuccessfulDispatch({
toolName: tool.name,
rawArgs,
agent,
session,
})
}
return {
content: result.content as ToolResult['content'],
isError: result.isError,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Read endpoint backing the cockpit homepage's "which tabs are
* being driven right now" view. The registry behind this route is
* fed by `apps/agent-mcp-interface/src/mcp/register.ts` every time a
* browser tool dispatch succeeds; this route just publishes the
* current snapshot. Polling is the v1 transport (the UI hook polls
* every 1500 ms); SSE on `?stream=1` is a future option if polling
* proves chatty.
*/

import { Hono } from 'hono'
import { tabActivityRegistry } from '../../lib/tab-activity'

export const tabsRoute = new Hono().get('/tabs/activity', (c) =>
c.json({ tabs: tabActivityRegistry.snapshot() }),
)
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { mcpRoute } from './routes/mcp'
import { permissionsRoute } from './routes/permissions'
import { siteRulesRoute } from './routes/site-rules'
import { systemRoute } from './routes/system'
import { tabsRoute } from './routes/tabs'

// Telemetry capture is injectable so the server module stays usable
// from the bun-test runner without pulling Sentry into the import
Expand Down Expand Up @@ -70,6 +71,7 @@ const routes = app
.route('/', siteRulesRoute)
.route('/', permissionsRoute)
.route('/', mcpRoute)
.route('/', tabsRoute)

export type AppType = typeof routes
export default routes
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'bun:test'
import { extractPageId } from '../../../src/lib/tab-activity/extract-page-id'

describe('extractPageId', () => {
it('returns the page id for every tool that takes a page arg', () => {
for (const tool of [
'act',
'diff',
'download',
'evaluate',
'grep',
'navigate',
'pdf',
'read',
'screenshot',
'snapshot',
'tabs',
'upload',
'wait',
]) {
expect(extractPageId(tool, { page: 7 })).toBe(7)
}
})

it('returns null for tools without a page arg', () => {
expect(extractPageId('tab_groups', { page: 7 })).toBeNull()
expect(extractPageId('windows', { page: 7 })).toBeNull()
expect(extractPageId('run', { page: 7 })).toBeNull()
})

it('returns null for unknown tools', () => {
expect(extractPageId('completely_unknown', { page: 1 })).toBeNull()
})

it('returns null when page is missing', () => {
expect(extractPageId('navigate', { url: 'https://example.com' })).toBeNull()
expect(extractPageId('navigate', {})).toBeNull()
})

it('returns null when page is not a number', () => {
expect(extractPageId('navigate', { page: '7' })).toBeNull()
expect(extractPageId('navigate', { page: null })).toBeNull()
expect(extractPageId('navigate', { page: undefined })).toBeNull()
})

it('returns null for non-integer page', () => {
expect(extractPageId('navigate', { page: 1.5 })).toBeNull()
})

it('returns null for non-positive page', () => {
expect(extractPageId('navigate', { page: 0 })).toBeNull()
expect(extractPageId('navigate', { page: -1 })).toBeNull()
})

it('returns null for non-object args', () => {
expect(extractPageId('navigate', null)).toBeNull()
expect(extractPageId('navigate', undefined)).toBeNull()
expect(extractPageId('navigate', 'page=1')).toBeNull()
expect(extractPageId('navigate', 42)).toBeNull()
})
})
Loading
Loading