This commit is contained in:
Chris Tate
2026-01-10 11:43:55 -06:00
parent 2d0520b770
commit 9d253da8bd
8 changed files with 527 additions and 62 deletions
+47
View File
@@ -60,6 +60,22 @@ veb select "#country" "US"
# Hover over elements
veb hover "#menu"
# Tab management
veb tab new # Open new tab
veb tab list # List all tabs
veb tab 0 # Switch to tab 0
veb tab close # Close current tab
veb tab close 1 # Close tab 1
# Window management
veb window new # Open new window
# Session management (isolate multiple agents)
veb --session agent1 open example.com
veb --session agent2 open google.com
veb session list # List active sessions
VEB_SESSION=agent1 veb eval "document.title"
# Close browser (stops daemon)
veb close
```
@@ -96,12 +112,43 @@ veb runs a background daemon that keeps the browser open between commands. The f
| `scroll <dir> [amount]` | Scroll page |
| `hover <selector>` | Hover over element |
| `select <selector> <val>` | Select dropdown option |
| `tab new` | Open new tab |
| `tab list` | List all tabs |
| `tab <index>` | Switch to tab |
| `tab close [index]` | Close tab |
| `window new` | Open new window |
| `session` | Show current session |
| `session list` | List active sessions |
| `close` | Close browser |
## Sessions
Sessions allow multiple agents to use veb simultaneously without interfering with each other. Each session runs its own isolated browser instance.
```bash
# Using --session flag
veb --session agent1 open https://site-a.com
veb --session agent2 open https://site-b.com
# Using environment variable
export VEB_SESSION=agent1
veb open https://example.com
veb click "#button"
# List all running sessions
veb session list
# Close a specific session
veb --session agent1 close
```
Sessions are identified by name. If no session is specified, the "default" session is used.
## Options
| Option | Description |
|--------|-------------|
| `--session <name>` | Use isolated browser session |
| `--json` | Output raw JSON |
| `--full, -f` | Full page screenshot |
| `--text, -t` | Wait for text |
+64
View File
@@ -14,10 +14,17 @@ import type {
SelectCommand,
HoverCommand,
ContentCommand,
TabSwitchCommand,
TabCloseCommand,
WindowNewCommand,
NavigateData,
ScreenshotData,
EvaluateData,
ContentData,
TabListData,
TabNewData,
TabSwitchData,
TabCloseData,
} from './types.js';
import { successResponse, errorResponse } from './protocol.js';
@@ -63,6 +70,16 @@ export async function executeCommand(
return await handleContent(command, browser);
case 'close':
return await handleClose(command, browser);
case 'tab_new':
return await handleTabNew(command, browser);
case 'tab_list':
return await handleTabList(command, browser);
case 'tab_switch':
return await handleTabSwitch(command, browser);
case 'tab_close':
return await handleTabClose(command, browser);
case 'window_new':
return await handleWindowNew(command, browser);
default: {
// TypeScript narrows to never here, but we handle it for safety
const unknownCommand = command as { id: string; action: string };
@@ -308,3 +325,50 @@ async function handleClose(
await browser.close();
return successResponse(command.id, { closed: true });
}
async function handleTabNew(
command: Command & { action: 'tab_new' },
browser: BrowserManager
): Promise<Response<TabNewData>> {
const result = await browser.newTab();
return successResponse(command.id, result);
}
async function handleTabList(
command: Command & { action: 'tab_list' },
browser: BrowserManager
): Promise<Response<TabListData>> {
const tabs = await browser.listTabs();
return successResponse(command.id, {
tabs,
active: browser.getActiveIndex(),
});
}
async function handleTabSwitch(
command: TabSwitchCommand,
browser: BrowserManager
): Promise<Response<TabSwitchData>> {
const result = browser.switchTo(command.index);
const page = browser.getPage();
return successResponse(command.id, {
...result,
title: await page.title(),
});
}
async function handleTabClose(
command: TabCloseCommand,
browser: BrowserManager
): Promise<Response<TabCloseData>> {
const result = await browser.closeTab(command.index);
return successResponse(command.id, result);
}
async function handleWindowNew(
command: WindowNewCommand,
browser: BrowserManager
): Promise<Response<TabNewData>> {
const result = await browser.newWindow(command.viewport);
return successResponse(command.id, result);
}
+143 -35
View File
@@ -1,45 +1,51 @@
import { chromium, firefox, webkit, type Browser, type BrowserContext, type Page } from 'playwright';
import type { BrowserState, LaunchCommand } from './types.js';
import type { LaunchCommand } from './types.js';
/**
* Manages the Playwright browser lifecycle
* Manages the Playwright browser lifecycle with multiple tabs/windows
*/
export class BrowserManager {
private state: BrowserState = {
browser: null,
context: null,
page: null,
};
private browser: Browser | null = null;
private contexts: BrowserContext[] = [];
private pages: Page[] = [];
private activePageIndex: number = 0;
/**
* Check if browser is launched
*/
isLaunched(): boolean {
return this.state.browser !== null;
return this.browser !== null;
}
/**
* Get the current page, throws if not launched
* Get the current active page, throws if not launched
*/
getPage(): Page {
if (!this.state.page) {
if (this.pages.length === 0) {
throw new Error('Browser not launched. Call launch first.');
}
return this.state.page;
return this.pages[this.activePageIndex];
}
/**
* Get all pages
*/
getPages(): Page[] {
return this.pages;
}
/**
* Get current page index
*/
getActiveIndex(): number {
return this.activePageIndex;
}
/**
* Get the current browser instance
*/
getBrowser(): Browser | null {
return this.state.browser;
}
/**
* Get the current context
*/
getContext(): BrowserContext | null {
return this.state.context;
return this.browser;
}
/**
@@ -47,7 +53,7 @@ export class BrowserManager {
*/
async launch(options: LaunchCommand): Promise<void> {
// Close existing browser if any
if (this.state.browser) {
if (this.browser) {
await this.close();
}
@@ -60,36 +66,138 @@ export class BrowserManager {
: chromium;
// Launch browser
this.state.browser = await launcher.launch({
this.browser = await launcher.launch({
headless: options.headless ?? true,
});
// Create context with viewport
this.state.context = await this.state.browser.newContext({
const context = await this.browser.newContext({
viewport: options.viewport ?? { width: 1280, height: 720 },
});
this.contexts.push(context);
// Create initial page
this.state.page = await this.state.context.newPage();
const page = await context.newPage();
this.pages.push(page);
this.activePageIndex = 0;
}
/**
* Create a new tab in the current context
*/
async newTab(): Promise<{ index: number; total: number }> {
if (!this.browser || this.contexts.length === 0) {
throw new Error('Browser not launched');
}
const context = this.contexts[0]; // Use first context for tabs
const page = await context.newPage();
this.pages.push(page);
this.activePageIndex = this.pages.length - 1;
return { index: this.activePageIndex, total: this.pages.length };
}
/**
* Create a new window (new context)
*/
async newWindow(viewport?: { width: number; height: number }): Promise<{ index: number; total: number }> {
if (!this.browser) {
throw new Error('Browser not launched');
}
const context = await this.browser.newContext({
viewport: viewport ?? { width: 1280, height: 720 },
});
this.contexts.push(context);
const page = await context.newPage();
this.pages.push(page);
this.activePageIndex = this.pages.length - 1;
return { index: this.activePageIndex, total: this.pages.length };
}
/**
* Switch to a specific tab/page by index
*/
switchTo(index: number): { index: number; url: string; title: string } {
if (index < 0 || index >= this.pages.length) {
throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
}
this.activePageIndex = index;
const page = this.pages[index];
return {
index: this.activePageIndex,
url: page.url(),
title: '', // Title requires async, will be fetched separately
};
}
/**
* Close a specific tab/page
*/
async closeTab(index?: number): Promise<{ closed: number; remaining: number }> {
const targetIndex = index ?? this.activePageIndex;
if (targetIndex < 0 || targetIndex >= this.pages.length) {
throw new Error(`Invalid tab index: ${targetIndex}`);
}
if (this.pages.length === 1) {
throw new Error('Cannot close the last tab. Use "close" to close the browser.');
}
const page = this.pages[targetIndex];
await page.close();
this.pages.splice(targetIndex, 1);
// Adjust active index if needed
if (this.activePageIndex >= this.pages.length) {
this.activePageIndex = this.pages.length - 1;
} else if (this.activePageIndex > targetIndex) {
this.activePageIndex--;
}
return { closed: targetIndex, remaining: this.pages.length };
}
/**
* List all tabs with their info
*/
async listTabs(): Promise<Array<{ index: number; url: string; title: string; active: boolean }>> {
const tabs = await Promise.all(
this.pages.map(async (page, index) => ({
index,
url: page.url(),
title: await page.title().catch(() => ''),
active: index === this.activePageIndex,
}))
);
return tabs;
}
/**
* Close the browser and clean up
*/
async close(): Promise<void> {
if (this.state.page) {
await this.state.page.close().catch(() => {});
this.state.page = null;
for (const page of this.pages) {
await page.close().catch(() => {});
}
this.pages = [];
for (const context of this.contexts) {
await context.close().catch(() => {});
}
this.contexts = [];
if (this.browser) {
await this.browser.close().catch(() => {});
this.browser = null;
}
if (this.state.context) {
await this.state.context.close().catch(() => {});
this.state.context = null;
}
if (this.state.browser) {
await this.state.browser.close().catch(() => {});
this.state.browser = null;
}
this.activePageIndex = 0;
}
}
+7 -4
View File
@@ -3,7 +3,7 @@ import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import * as path from 'path';
import * as fs from 'fs';
import { getSocketPath, isDaemonRunning } from './daemon.js';
import { getSocketPath, isDaemonRunning, setSession, getSession } from './daemon.js';
import type { Response } from './types.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -14,6 +14,8 @@ export function setDebug(enabled: boolean): void {
DEBUG = enabled;
}
export { setSession, getSession };
function debug(...args: unknown[]): void {
if (DEBUG) {
console.error('[debug]', ...args);
@@ -41,7 +43,8 @@ async function waitForSocket(maxAttempts = 30): Promise<boolean> {
* Ensure daemon is running, start if not
*/
export async function ensureDaemon(): Promise<void> {
debug('Checking if daemon is running...');
const session = getSession();
debug(`Checking if daemon is running for session "${session}"...`);
if (isDaemonRunning()) {
debug('Daemon already running');
return;
@@ -52,7 +55,7 @@ export async function ensureDaemon(): Promise<void> {
const child = spawn(process.execPath, [daemonPath], {
detached: true,
stdio: 'ignore',
env: { ...process.env, VEB_DAEMON: '1' },
env: { ...process.env, VEB_DAEMON: '1', VEB_SESSION: session },
});
child.unref();
@@ -62,7 +65,7 @@ export async function ensureDaemon(): Promise<void> {
throw new Error('Failed to start daemon');
}
debug('Daemon started');
debug(`Daemon started for session "${session}"`);
}
/**
+42 -20
View File
@@ -6,48 +6,67 @@ import { BrowserManager } from './browser.js';
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
import { executeCommand } from './actions.js';
const SOCKET_PATH = path.join(os.tmpdir(), 'veb.sock');
const PID_FILE = path.join(os.tmpdir(), 'veb.pid');
// Session support - each session gets its own socket/pid
let currentSession = process.env.VEB_SESSION || 'default';
/**
* Get the socket path
* Set the current session
*/
export function getSocketPath(): string {
return SOCKET_PATH;
export function setSession(session: string): void {
currentSession = session;
}
/**
* Get the PID file path
* Get the current session
*/
export function getPidFile(): string {
return PID_FILE;
export function getSession(): string {
return currentSession;
}
/**
* Check if daemon is running
* Get the socket path for the current session
*/
export function isDaemonRunning(): boolean {
if (!fs.existsSync(PID_FILE)) return false;
export function getSocketPath(session?: string): string {
const sess = session ?? currentSession;
return path.join(os.tmpdir(), `veb-${sess}.sock`);
}
/**
* Get the PID file path for the current session
*/
export function getPidFile(session?: string): string {
const sess = session ?? currentSession;
return path.join(os.tmpdir(), `veb-${sess}.pid`);
}
/**
* Check if daemon is running for the current session
*/
export function isDaemonRunning(session?: string): boolean {
const pidFile = getPidFile(session);
if (!fs.existsSync(pidFile)) return false;
try {
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
// Check if process exists
process.kill(pid, 0);
return true;
} catch {
// Process doesn't exist, clean up stale files
cleanupSocket();
cleanupSocket(session);
return false;
}
}
/**
* Clean up socket and PID file
* Clean up socket and PID file for the current session
*/
export function cleanupSocket(): void {
export function cleanupSocket(session?: string): void {
const socketPath = getSocketPath(session);
const pidFile = getPidFile(session);
try {
if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH);
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
} catch {
// Ignore cleanup errors
}
@@ -121,10 +140,13 @@ export async function startDaemon(): Promise<void> {
});
});
// Write PID file before listening
fs.writeFileSync(PID_FILE, process.pid.toString());
const socketPath = getSocketPath();
const pidFile = getPidFile();
server.listen(SOCKET_PATH, () => {
// Write PID file before listening
fs.writeFileSync(pidFile, process.pid.toString());
server.listen(socketPath, () => {
// Daemon is ready
});
+134 -2
View File
@@ -1,7 +1,40 @@
#!/usr/bin/env node
import { send, setDebug } from './client.js';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { send, setDebug, setSession, getSession } from './client.js';
import type { Response } from './types.js';
/**
* List all active veb sessions
*/
function listSessions(): string[] {
const tmpDir = os.tmpdir();
try {
const files = fs.readdirSync(tmpDir);
const sessions: string[] = [];
for (const file of files) {
const match = file.match(/^veb-(.+)\.pid$/);
if (match) {
const pidFile = path.join(tmpDir, file);
try {
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
// Check if process is still running
process.kill(pid, 0);
sessions.push(match[1]);
} catch {
// Process not running, ignore
}
}
}
return sessions;
} catch {
return [];
}
}
// ANSI colors
const colors = {
reset: '\x1b[0m',
@@ -39,7 +72,19 @@ ${c('yellow', 'Commands:')}
${c('cyan', 'select')} <selector> <value> Select dropdown option
${c('cyan', 'close')} Close browser and stop daemon
${c('yellow', 'Tab/Window Commands:')}
${c('cyan', 'tab new')} Open a new tab
${c('cyan', 'tab list')} List all open tabs
${c('cyan', 'tab')} <index> Switch to tab by index
${c('cyan', 'tab close')} [index] Close tab (current if no index)
${c('cyan', 'window new')} Open a new window
${c('yellow', 'Session Commands:')}
${c('cyan', 'session')} Show current session name
${c('cyan', 'session list')} List all active sessions
${c('yellow', 'Options:')}
--session <name> Use isolated browser session (or VEB_SESSION env)
--json Output raw JSON (for agents)
--selector, -s <sel> Target specific element
--text, -t Wait for text instead of selector
@@ -57,6 +102,9 @@ ${c('yellow', 'Examples:')}
veb extract "table" --json
veb eval "document.title"
veb scroll down 500
veb tab new
veb tab list
veb tab 0
`);
}
@@ -101,8 +149,24 @@ function printResponse(response: Response, jsonMode: boolean): void {
console.log(c('green', '✓'), 'Done');
} else if (data.launched) {
console.log(c('green', '✓'), 'Browser launched');
} else if (data.closed) {
} else if (data.closed === true) {
console.log(c('green', '✓'), 'Browser closed');
} else if (data.tabs) {
// Tab list
const tabs = data.tabs as Array<{ index: number; url: string; title: string; active: boolean }>;
tabs.forEach(tab => {
const marker = tab.active ? c('green', '→') : ' ';
const idx = c('cyan', `[${tab.index}]`);
const title = tab.title || c('dim', '(untitled)');
console.log(`${marker} ${idx} ${title}`);
if (tab.url) console.log(c('dim', ` ${tab.url}`));
});
} else if (data.index !== undefined && data.total !== undefined) {
// Tab new / window new
console.log(c('green', '✓'), `Tab ${data.index} created (${data.total} total)`);
} else if (data.remaining !== undefined) {
// Tab close
console.log(c('green', '✓'), `Tab closed (${data.remaining} remaining)`);
} else {
console.log(c('green', '✓'), JSON.stringify(data));
}
@@ -117,6 +181,13 @@ async function main(): Promise<void> {
setDebug(true);
}
// Handle session - check --session flag first, then env var
const sessionIdx = args.findIndex(a => a === '--session');
if (sessionIdx !== -1 && args[sessionIdx + 1]) {
setSession(args[sessionIdx + 1]);
}
// VEB_SESSION env var is already handled by daemon.ts default
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(0);
@@ -132,6 +203,7 @@ async function main(): Promise<void> {
// Check if previous arg was a flag that takes a value
const prev = args[i - 1];
if (prev === '--selector' || prev === '-s') return false;
if (prev === '--session') return false;
return true;
});
const command = cleanArgs[0];
@@ -292,6 +364,66 @@ async function main(): Promise<void> {
break;
}
case 'tab': {
const subCmd = cleanArgs[1];
if (subCmd === 'new') {
cmd = { id, action: 'tab_new' };
} else if (subCmd === 'list' || subCmd === 'ls') {
cmd = { id, action: 'tab_list' };
} else if (subCmd === 'close') {
const tabIndex = cleanArgs[2] !== undefined ? parseInt(cleanArgs[2], 10) : undefined;
cmd = { id, action: 'tab_close', index: tabIndex };
} else if (subCmd !== undefined) {
// Assume it's a tab index to switch to
const tabIndex = parseInt(subCmd, 10);
if (isNaN(tabIndex)) {
console.error(c('red', 'Error:'), `Invalid tab command or index: ${subCmd}`);
process.exit(1);
}
cmd = { id, action: 'tab_switch', index: tabIndex };
} else {
// No subcommand - list tabs
cmd = { id, action: 'tab_list' };
}
break;
}
case 'window': {
const subCmd = cleanArgs[1];
if (subCmd === 'new') {
cmd = { id, action: 'window_new' };
} else {
console.error(c('red', 'Error:'), 'Usage: veb window new');
process.exit(1);
}
break;
}
case 'session': {
const subCmd = cleanArgs[1];
if (subCmd === 'list' || subCmd === 'ls') {
const sessions = listSessions();
const currentSession = getSession();
if (sessions.length === 0) {
console.log(c('dim', 'No active sessions'));
} else {
sessions.forEach(sess => {
const marker = sess === currentSession ? c('green', '→') : ' ';
console.log(`${marker} ${c('cyan', sess)}`);
});
}
process.exit(0);
} else {
// Show current session
console.log(c('cyan', getSession()));
process.exit(0);
}
}
default:
console.error(c('red', 'Error:'), `Unknown command: ${command}`);
console.error(c('dim', 'Run veb --help for usage'));
+32
View File
@@ -101,6 +101,33 @@ const closeSchema = baseCommandSchema.extend({
action: z.literal('close'),
});
// Tab/Window schemas
const tabNewSchema = baseCommandSchema.extend({
action: z.literal('tab_new'),
});
const tabListSchema = baseCommandSchema.extend({
action: z.literal('tab_list'),
});
const tabSwitchSchema = baseCommandSchema.extend({
action: z.literal('tab_switch'),
index: z.number().nonnegative(),
});
const tabCloseSchema = baseCommandSchema.extend({
action: z.literal('tab_close'),
index: z.number().nonnegative().optional(),
});
const windowNewSchema = baseCommandSchema.extend({
action: z.literal('window_new'),
viewport: z.object({
width: z.number().positive(),
height: z.number().positive(),
}).optional(),
});
// Union schema for all commands
const commandSchema = z.discriminatedUnion('action', [
launchSchema,
@@ -117,6 +144,11 @@ const commandSchema = z.discriminatedUnion('action', [
hoverSchema,
contentSchema,
closeSchema,
tabNewSchema,
tabListSchema,
tabSwitchSchema,
tabCloseSchema,
windowNewSchema,
]);
// Parse result type
+58 -1
View File
@@ -97,6 +97,30 @@ export interface CloseCommand extends BaseCommand {
action: 'close';
}
// Tab/Window commands
export interface TabNewCommand extends BaseCommand {
action: 'tab_new';
}
export interface TabListCommand extends BaseCommand {
action: 'tab_list';
}
export interface TabSwitchCommand extends BaseCommand {
action: 'tab_switch';
index: number;
}
export interface TabCloseCommand extends BaseCommand {
action: 'tab_close';
index?: number;
}
export interface WindowNewCommand extends BaseCommand {
action: 'window_new';
viewport?: { width: number; height: number };
}
// Union of all command types
export type Command =
| LaunchCommand
@@ -112,7 +136,12 @@ export type Command =
| SelectCommand
| HoverCommand
| ContentCommand
| CloseCommand;
| CloseCommand
| TabNewCommand
| TabListCommand
| TabSwitchCommand
| TabCloseCommand
| WindowNewCommand;
// Response types
export interface SuccessResponse<T = unknown> {
@@ -152,6 +181,34 @@ export interface ContentData {
html: string;
}
export interface TabInfo {
index: number;
url: string;
title: string;
active: boolean;
}
export interface TabListData {
tabs: TabInfo[];
active: number;
}
export interface TabNewData {
index: number;
total: number;
}
export interface TabSwitchData {
index: number;
url: string;
title: string;
}
export interface TabCloseData {
closed: number;
remaining: number;
}
// Browser state
export interface BrowserState {
browser: Browser | null;