From 9d253da8bd08157b6b2346f896f4d6cd6fd358f3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 10 Jan 2026 11:43:55 -0600 Subject: [PATCH] session --- README.md | 47 +++++++++++++ src/actions.ts | 64 +++++++++++++++++ src/browser.ts | 178 ++++++++++++++++++++++++++++++++++++++---------- src/client.ts | 11 +-- src/daemon.ts | 62 +++++++++++------ src/index.ts | 136 +++++++++++++++++++++++++++++++++++- src/protocol.ts | 32 +++++++++ src/types.ts | 59 +++++++++++++++- 8 files changed, 527 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index c9db724..98f45fa 100644 --- a/README.md +++ b/README.md @@ -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 [amount]` | Scroll page | | `hover ` | Hover over element | | `select ` | Select dropdown option | +| `tab new` | Open new tab | +| `tab list` | List all tabs | +| `tab ` | 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 ` | Use isolated browser session | | `--json` | Output raw JSON | | `--full, -f` | Full page screenshot | | `--text, -t` | Wait for text | diff --git a/src/actions.ts b/src/actions.ts index 8f0d90a..1f154ec 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -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> { + const result = await browser.newTab(); + return successResponse(command.id, result); +} + +async function handleTabList( + command: Command & { action: 'tab_list' }, + browser: BrowserManager +): Promise> { + const tabs = await browser.listTabs(); + return successResponse(command.id, { + tabs, + active: browser.getActiveIndex(), + }); +} + +async function handleTabSwitch( + command: TabSwitchCommand, + browser: BrowserManager +): Promise> { + 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> { + const result = await browser.closeTab(command.index); + return successResponse(command.id, result); +} + +async function handleWindowNew( + command: WindowNewCommand, + browser: BrowserManager +): Promise> { + const result = await browser.newWindow(command.viewport); + return successResponse(command.id, result); +} diff --git a/src/browser.ts b/src/browser.ts index fec8910..8af9af5 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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 { // 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> { + 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 { - 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; } } diff --git a/src/client.ts b/src/client.ts index 2adba88..fc2ec0b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 { * Ensure daemon is running, start if not */ export async function ensureDaemon(): Promise { - 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 { 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 { throw new Error('Failed to start daemon'); } - debug('Daemon started'); + debug(`Daemon started for session "${session}"`); } /** diff --git a/src/daemon.ts b/src/daemon.ts index ba1f21e..d68fb75 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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 { }); }); - // 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 }); diff --git a/src/index.ts b/src/index.ts index 90cbac2..9f5b8b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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')} 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')} 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 Use isolated browser session (or VEB_SESSION env) --json Output raw JSON (for agents) --selector, -s 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 { 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 { // 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 { 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')); diff --git a/src/protocol.ts b/src/protocol.ts index 75349ea..92848e3 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -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 diff --git a/src/types.ts b/src/types.ts index 663a956..9a5503b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { @@ -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;