From f5791906e267394975709224b7fdb75659813802 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 12 Aug 2026 20:32:07 +0800 Subject: [PATCH] docs: add v1 implementation plan (25 tasks) --- .../plans/2026-08-12-visionl-v1.md | 1591 +++++++++++++++++ 1 file changed, 1591 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-visionl-v1.md diff --git a/docs/superpowers/plans/2026-08-12-visionl-v1.md b/docs/superpowers/plans/2026-08-12-visionl-v1.md new file mode 100644 index 0000000..2574e6c --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-visionl-v1.md @@ -0,0 +1,1591 @@ +# VisionL v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the VisionL browser automation system — daemon (Playwright + anti-detection stealth), CLI (commander), and shared core library — as a TypeScript npm workspaces monorepo. + +**Architecture:** Three-package monorepo (`core`, `daemon`, `cli`). CLI sends HTTP/WS commands to daemon which manages Playwright Chromium instances with comprehensive anti-fingerprinting. Pages persist until explicitly killed. + +**Tech Stack:** TypeScript 5.x, Node.js 18+, Playwright + playwright-extra + puppeteer-extra-plugin-stealth, npm workspaces, vitest, commander, ws, native http module. + +## Global Constraints + +- All JSON output uses `JSON.stringify` on the full response object — never manual string concatenation +- Daemon binds to `127.0.0.1` only, default port 9527 +- Page ID format: `p_` + 8 hex chars (e.g., `p_a1b2c3d4`) +- Auto-daemon: CLI checks GET /health before any command, spawns daemon if needed (3s timeout, 100ms poll) +- State files in `~/.visionl/` (daemon.pid, daemon.port) +- Stealth injection happens at BrowserContext creation time, before navigation +- All stealth modules inject via `context.addInitScript()` or `page.route()` — not via `page.evaluate()` after load +- npm registry: `--registry=https://registry.npmmirror.com` +- Git commits: conventional commits format, dev branch + +--- + +### Task 1: Monorepo Scaffold + +**Files:** +- Create: `packages/core/package.json` +- Create: `packages/daemon/package.json` +- Create: `packages/cli/package.json` +- Create: `packages/core/tsconfig.json` +- Create: `packages/daemon/tsconfig.json` +- Create: `packages/cli/tsconfig.json` +- Modify: `package.json` (root) +- Modify: `tsconfig.json` (root, create if missing) + +**Interfaces:** +- Produces: Three workspace packages ready for `npm install` +- Produces: Root `package.json` with `"workspaces": ["packages/*"]` + +- [ ] **Step 1: Create root package.json with workspaces** + +```json +{ + "name": "visionl", + "private": true, + "workspaces": ["packages/*"], + "scripts": { + "typecheck": "tsc -b", + "build": "tsc -b", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "@types/node": "^22.0.0" + } +} +``` + +- [ ] **Step 2: Create root tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "composite": true, + "outDir": "./dist", + "rootDir": "." + }, + "references": [ + { "path": "./packages/core" }, + { "path": "./packages/daemon" }, + { "path": "./packages/cli" } + ], + "files": [] +} +``` + +- [ ] **Step 3: Create packages/core/package.json** + +```json +{ + "name": "@visionl/core", + "version": "0.1.0", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "typecheck": "tsc -b", + "build": "tsc -b" + } +} +``` + +- [ ] **Step 4: Create packages/core/tsconfig.json** + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 5: Create packages/daemon/package.json** + +```json +{ + "name": "@visionl/daemon", + "version": "0.1.0", + "private": true, + "main": "./dist/server.js", + "scripts": { + "typecheck": "tsc -b", + "build": "tsc -b", + "start": "node ./dist/server.js" + }, + "dependencies": { + "@visionl/core": "*", + "playwright": "^1.52.0", + "playwright-extra": "^4.3.0", + "puppeteer-extra-plugin-stealth": "^2.11.0" + }, + "devDependencies": { + "@types/ws": "^8.0.0" + } +} +``` + +- [ ] **Step 6: Create packages/daemon/tsconfig.json** — same pattern, `rootDir: "./src"`, `references: [{ "path": "../core" }]` + +- [ ] **Step 7: Create packages/cli/package.json** + +```json +{ + "name": "@visionl/cli", + "version": "0.1.0", + "private": true, + "bin": { + "visionl": "./dist/index.js" + }, + "scripts": { + "typecheck": "tsc -b", + "build": "tsc -b" + }, + "dependencies": { + "@visionl/core": "*", + "commander": "^13.0.0" + }, + "devDependencies": { + "@types/commander": "npm:commander@^13.0.0" + } +} +``` + +- [ ] **Step 8: Create packages/cli/tsconfig.json** — same pattern, references core + +- [ ] **Step 9: Create vitest.config.ts at root** + +```typescript +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['packages/*/src/**/*.test.ts'], + }, +}); +``` + +- [ ] **Step 10: Install dependencies** + +```bash +npm install --registry=https://registry.npmmirror.com +npx playwright install chromium +``` + +- [ ] **Step 11: Verify** + +```bash +npm run typecheck # Should pass (no source files yet) +``` + +- [ ] **Step 12: Commit** + +```bash +git add -A && git commit -m "chore: initialize monorepo scaffold with npm workspaces" +``` + +--- + +### Task 2: Core Type Definitions + +**Files:** +- Create: `packages/core/src/types/page.ts` +- Create: `packages/core/src/types/api.ts` +- Create: `packages/core/src/types/ws.ts` +- Create: `packages/core/src/types/fingerprint.ts` +- Create: `packages/core/src/index.ts` + +**Interfaces:** +- Produces: `PageInfo`, `ApiResponse`, `ErrorCode`, `WsEvent`, `FingerprintProfile`, `FingerprintPermissions`, `FingerprintBehavior`, `CanvasNoiseConfig` + +- [ ] **Step 1: Write types/page.ts** + +```typescript +// Page state type definitions / 页面状态类型定义 +export interface PageInfo { + id: string; + url: string; + alias?: string; + title: string; + status: 'active' | 'crashed'; + profile: string; +} +``` + +- [ ] **Step 2: Write types/api.ts** + +```typescript +// API request/response types / API 请求响应类型 +export interface ApiResponse { + ok: boolean; + data?: T; + error?: { + code: ErrorCode; + message: string; + }; +} + +export enum ErrorCode { + PAGE_NOT_FOUND = 'PAGE_NOT_FOUND', + DAEMON_UNREACHABLE = 'DAEMON_UNREACHABLE', + INVALID_URL = 'INVALID_URL', + ALIAS_EXISTS = 'ALIAS_EXISTS', + TIMEOUT = 'TIMEOUT', + INTERNAL = 'INTERNAL', +} +``` + +- [ ] **Step 3: Write types/ws.ts** + +```typescript +// WebSocket event type definitions / WebSocket 事件类型定义 +import type { PageInfo } from './page.js'; + +export type WsEvent = + | { type: 'page:created'; data: PageInfo } + | { type: 'page:closed'; data: { id: string } } + | { type: 'page:navigated'; data: { id: string; url: string; title: string } } + | { type: 'page:crashed'; data: { id: string; error: string } } + | { type: 'page:console'; data: { id: string; level: string; text: string } } + | { type: 'page:detection:warning'; data: { id: string; level: string; detail: string } }; +``` + +- [ ] **Step 4: Write types/fingerprint.ts** + +```typescript +// Fingerprint profile type definitions / 指纹配置类型定义 +export type PermissionState = 'prompt' | 'granted' | 'denied'; + +export interface FingerprintPermissions { + notifications: PermissionState; + geolocation: PermissionState; + camera: PermissionState; + microphone: PermissionState; +} + +export interface CanvasNoiseConfig { + enabled: boolean; + strength: number; // 0-1 / 噪声强度 0-1 +} + +export interface FingerprintBehavior { + mouseMoveDelay: { min: number; max: number }; + keyPressDelay: { min: number; max: number }; + scrollStepDelay: { min: number; max: number }; +} + +export interface FingerprintProfile { + id: string; + name: string; + userAgent: string; + platform: string; + languages: string[]; + acceptLanguage: string; + screen: { width: number; height: number; colorDepth: number; pixelRatio: number }; + viewport: { width: number; height: number }; + webgl: { vendor: string; renderer: string }; + timezone: string; + geolocation?: { latitude: number; longitude: number; accuracy: number }; + permissions: FingerprintPermissions; + behavior: FingerprintBehavior; + canvasNoise: CanvasNoiseConfig; +} +``` + +- [ ] **Step 5: Write index.ts** + +```typescript +export { type PageInfo } from './types/page.js'; +export { type ApiResponse, ErrorCode } from './types/api.js'; +export { type WsEvent } from './types/ws.js'; +export { + type FingerprintProfile, + type FingerprintPermissions, + type FingerprintBehavior, + type CanvasNoiseConfig, + type PermissionState, +} from './types/fingerprint.js'; +``` + +- [ ] **Step 6: Verify** + +```bash +npm run typecheck --workspace=packages/core +``` + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/ && git commit -m "feat: add core type definitions (PageInfo, ApiResponse, WsEvent, FingerprintProfile)" +``` + +--- + +### Task 3: JSON Escape Utility + +**Files:** +- Create: `packages/core/src/escape.ts` +- Create: `packages/core/src/__tests__/escape.test.ts` + +**Interfaces:** +- Produces: `safeStringify(obj: unknown): string` — wraps JSON.stringify +- Produces: `isValidJson(str: string): boolean` — validates JSON string +- Note: for v1, `safeStringify` is just `JSON.stringify` with type safety. Tests verify edge cases. + +- [ ] **Step 1: Write test file packages/core/src/__tests__/escape.test.ts** + +```typescript +import { describe, it, expect } from 'vitest'; +import { safeStringify, isValidJson } from '../escape.js'; + +describe('safeStringify', () => { + it('returns valid JSON for simple objects', () => { + const result = safeStringify({ ok: true, data: { id: 'p_123' } }); + expect(() => JSON.parse(result)).not.toThrow(); + expect(JSON.parse(result)).toEqual({ ok: true, data: { id: 'p_123' } }); + }); + + it('escapes double quotes in string values', () => { + const result = safeStringify({ text: 'He said "hello"' }); + const parsed = JSON.parse(result); + expect(parsed.text).toBe('He said "hello"'); + }); + + it('escapes backslashes in string values', () => { + const result = safeStringify({ path: 'C:\\Users\\test' }); + const parsed = JSON.parse(result); + expect(parsed.path).toBe('C:\\Users\\test'); + }); + + it('escapes control characters (newline, tab)', () => { + const result = safeStringify({ text: 'line1\nline2\tindented' }); + const parsed = JSON.parse(result); + expect(parsed.text).toBe('line1\nline2\tindented'); + }); + + it('handles unicode characters', () => { + const result = safeStringify({ text: '你好世界 🌍' }); + const parsed = JSON.parse(result); + expect(parsed.text).toBe('你好世界 🌍'); + }); + + it('handles HTML-like content without breaking JSON', () => { + const html = '
Hello
'; + const result = safeStringify({ html }); + const parsed = JSON.parse(result); + expect(parsed.html).toBe(html); + }); + + it('handles empty string and null', () => { + expect(JSON.parse(safeStringify({ a: '' }))).toEqual({ a: '' }); + expect(JSON.parse(safeStringify({ a: null }))).toEqual({ a: null }); + }); + + it('handles arrays with special characters', () => { + const result = safeStringify({ items: ['a"b', 'c\\d', 'e\nf'] }); + const parsed = JSON.parse(result); + expect(parsed.items).toEqual(['a"b', 'c\\d', 'e\nf']); + }); +}); + +describe('isValidJson', () => { + it('returns true for valid JSON', () => { + expect(isValidJson('{"ok":true}')).toBe(true); + }); + + it('returns false for invalid JSON', () => { + expect(isValidJson('{ok:true}')).toBe(false); + expect(isValidJson('')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test — verify it fails (missing module)** + +```bash +npx vitest run packages/core/src/__tests__/escape.test.ts +``` + +- [ ] **Step 3: Write packages/core/src/escape.ts** + +```typescript +// JSON safe serialization / JSON 安全序列化 +// Always use this instead of manual string concatenation for JSON output + +export function safeStringify(obj: unknown): string { + return JSON.stringify(obj); +} + +export function isValidJson(str: string): boolean { + try { + JSON.parse(str); + return true; + } catch { + return false; + } +} +``` + +- [ ] **Step 4: Update packages/core/src/index.ts** — add `export { safeStringify, isValidJson } from './escape.js';` + +- [ ] **Step 5: Run test — verify it passes** + +```bash +npx vitest run packages/core/src/__tests__/escape.test.ts +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/escape.ts packages/core/src/__tests__/ packages/core/src/index.ts +git commit -m "feat: add JSON escape utility with comprehensive edge case tests" +``` + +--- + +### Task 4: Daemon HTTP Client + +**Files:** +- Create: `packages/core/src/client.ts` +- Create: `packages/core/src/__tests__/client.test.ts` + +**Interfaces:** +- Produces: `VisionLClient` class with methods: + - `constructor(baseUrl: string)` + - `health(): Promise` + - `openPage(url: string, alias?: string, profile?: string): Promise>` + - `listPages(): Promise>` + - `getPage(id: string): Promise>` + - `killPage(id: string): Promise>` + - `navigate(id: string, url: string): Promise>` + - `click(id: string, selector: string): Promise>` + - `type(id: string, selector: string, text: string): Promise>` + - `scroll(id: string, opts: {deltaY?: number, toBottom?: boolean}): Promise>` + - `eval(id: string, code: string): Promise>` + - `wait(id: string, opts: {selector?: string, ms?: number}): Promise>` + - `screenshot(id: string): Promise>` + - `text(id: string): Promise>` + - `html(id: string): Promise>` + - `getProfiles(): Promise>>` + +- [ ] **Step 1: Write test file packages/core/src/__tests__/client.test.ts** + +```typescript +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import { VisionLClient } from '../client.js'; +import type { ApiResponse, PageInfo } from '../types/api.js'; + +// Minimal mock daemon for testing client +function createMockServer() { + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + const url = new URL(req.url!, 'http://localhost'); + + if (req.method === 'GET' && url.pathname === '/health') { + res.end(JSON.stringify({ status: 'ok' })); + } else if (req.method === 'POST' && url.pathname === '/pages') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const { url: pageUrl, alias } = JSON.parse(body); + res.end(JSON.stringify({ + ok: true, + data: { id: 'p_a1b2c3d4', url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'desktop-chrome' } + })); + }); + } else if (req.method === 'GET' && url.pathname === '/pages') { + res.end(JSON.stringify({ ok: true, data: [{ id: 'p_test', url: 'https://test.com', title: 'Test', status: 'active' }] })); + } else if (req.method === 'GET' && url.pathname.startsWith('/pages/p_test/text')) { + res.end(JSON.stringify({ ok: true, data: { text: 'Hello World' } })); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: 'not found' } })); + } + }); + return server; +} + +describe('VisionLClient', () => { + let server: http.Server; + let client: VisionLClient; + + beforeAll(async () => { + server = createMockServer(); + await new Promise((resolve) => server.listen(19527, resolve)); + client = new VisionLClient('http://127.0.0.1:19527'); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('health returns true when daemon is up', async () => { + const result = await client.health(); + expect(result).toBe(true); + }); + + it('openPage returns page info', async () => { + const result = await client.openPage('https://example.com', 'demo'); + expect(result.ok).toBe(true); + expect(result.data!.id).toBe('p_a1b2c3d4'); + expect(result.data!.alias).toBe('demo'); + }); + + it('listPages returns array', async () => { + const result = await client.listPages(); + expect(result.ok).toBe(true); + expect(result.data!).toHaveLength(1); + }); + + it('text returns page content', async () => { + const result = await client.text('p_test'); + expect(result.ok).toBe(true); + expect(result.data!.text).toBe('Hello World'); + }); +}); +``` + +- [ ] **Step 2: Run test — verify it fails** + +```bash +npx vitest run packages/core/src/__tests__/client.test.ts +``` + +- [ ] **Step 3: Write packages/core/src/client.ts** + +```typescript +import type { ApiResponse, PageInfo } from './types/api.js'; + +export class VisionLClient { + constructor(private baseUrl: string) {} + + private async request(method: string, path: string, body?: unknown): Promise> { + const url = `${this.baseUrl}${path}`; + const options: RequestInit = { + method, + headers: { 'Content-Type': 'application/json' }, + }; + if (body !== undefined) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); + const json = await response.json() as ApiResponse; + return json; + } + + async health(): Promise { + try { + const res = await this.request<{ status: string }>('GET', '/health'); + return res.ok || (res.data as any)?.status === 'ok'; + } catch { + return false; + } + } + + async openPage(url: string, alias?: string, profile?: string): Promise> { + return this.request('POST', '/pages', { url, alias, profile }); + } + + async listPages(): Promise> { + return this.request('GET', '/pages'); + } + + async getPage(id: string): Promise> { + return this.request('GET', `/pages/${id}`); + } + + async killPage(id: string): Promise> { + return this.request('DELETE', `/pages/${id}`); + } + + async navigate(id: string, url: string): Promise> { + return this.request('POST', `/pages/${id}/navigate`, { url }); + } + + async click(id: string, selector: string): Promise> { + return this.request('POST', `/pages/${id}/click`, { selector }); + } + + async type(id: string, selector: string, text: string): Promise> { + return this.request('POST', `/pages/${id}/type`, { selector, text }); + } + + async scroll(id: string, opts: { deltaY?: number; toBottom?: boolean }): Promise> { + return this.request('POST', `/pages/${id}/scroll`, opts); + } + + async eval(id: string, code: string): Promise> { + return this.request('POST', `/pages/${id}/eval`, { code }); + } + + async wait(id: string, opts: { selector?: string; ms?: number }): Promise> { + return this.request('POST', `/pages/${id}/wait`, opts); + } + + async screenshot(id: string): Promise> { + return this.request('GET', `/pages/${id}/screenshot`); + } + + async text(id: string): Promise> { + return this.request('GET', `/pages/${id}/text`); + } + + async html(id: string): Promise> { + return this.request('GET', `/pages/${id}/html`); + } + + async getProfiles(): Promise>> { + return this.request('GET', '/profiles'); + } +} +``` + +- [ ] **Step 4: Update index.ts** — add `export { VisionLClient } from './client.js';` + +- [ ] **Step 5: Run test — verify it passes** + +```bash +npx vitest run packages/core/src/__tests__/client.test.ts +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/client.ts packages/core/src/__tests__/client.test.ts packages/core/src/index.ts +git commit -m "feat: add VisionLClient HTTP client for daemon communication" +``` + +--- + +### Task 5: Daemon Server Skeleton + Health Route + +**Files:** +- Create: `packages/daemon/src/server.ts` +- Create: `packages/daemon/src/routes/health.ts` +- Create: `packages/daemon/src/__tests__/health.test.ts` + +**Interfaces:** +- Produces: `startServer(port: number): Promise` — starts HTTP server with routes registered +- Produces: Health route responds `{ "status": "ok" }` on `GET /health` + +- [ ] **Step 1: Write test file packages/daemon/src/__tests__/health.test.ts** + +```typescript +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import { startServer } from '../server.js'; + +describe('daemon health endpoint', () => { + let server: http.Server; + const port = 19528; + + beforeAll(async () => { + server = await startServer(port); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('GET /health returns 200 with status ok', async () => { + const response = await fetch(`http://127.0.0.1:${port}/health`); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ status: 'ok' }); + }); +}); +``` + +- [ ] **Step 2: Run test — verify it fails** + +```bash +npx vitest run packages/daemon/src/__tests__/health.test.ts +``` + +- [ ] **Step 3: Write packages/daemon/src/routes/health.ts** + +```typescript +import type { IncomingMessage, ServerResponse } from 'node:http'; + +export function healthRoute(req: IncomingMessage, res: ServerResponse): boolean { + const url = new URL(req.url || '/', 'http://localhost'); + if (req.method === 'GET' && url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return true; + } + return false; +} +``` + +- [ ] **Step 4: Write packages/daemon/src/server.ts** + +```typescript +import http from 'node:http'; +import { healthRoute } from './routes/health.js'; + +const routes = [healthRoute]; + +export function startServer(port: number): Promise { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + for (const route of routes) { + if (route(req, res)) return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: { code: 'INTERNAL', message: 'not found' } })); + }); + + server.listen(port, '127.0.0.1', () => { + console.log(`[daemon] VisionL daemon started on http://127.0.0.1:${port}`); + resolve(server); + }); + }); +} + +// Direct start when running as script +const port = parseInt(process.env.VISIONL_PORT || '9527', 10); +startServer(port); +``` + +- [ ] **Step 5: Run test — verify it passes** + +```bash +npx vitest run packages/daemon/src/__tests__/health.test.ts +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/daemon/src/server.ts packages/daemon/src/routes/health.ts packages/daemon/src/__tests__/ +git commit -m "feat: add daemon server skeleton with health check endpoint" +``` + +--- + +### Task 6: Pidfile Management + +**Files:** +- Create: `packages/daemon/src/pidfile.ts` +- Create: `packages/daemon/src/__tests__/pidfile.test.ts` + +**Interfaces:** +- Produces: `writePidfile(pid: number, port: number): void` — writes `~/.visionl/daemon.pid` and `~/.visionl/daemon.port` +- Produces: `readPidfile(): { pid: number; port: number } | null` — reads pidfile, returns null if missing/invalid +- Produces: `cleanPidfile(): void` — removes pidfile and port file +- Produces: `isProcessAlive(pid: number): boolean` — checks if process with given PID is running + +- [ ] **Step 1: Write test for writePidfile and readPidfile** + +Use `os.tmpdir()` for test isolation, not real `~/.visionl/`. Tests should: +- Write pidfile, read it back, verify values match +- Read missing pidfile returns null +- cleanPidfile removes files +- isProcessAlive returns true for current process PID + +- [ ] **Step 2: Run test — fail** + +- [ ] **Step 3: Implement pidfile.ts** + +```typescript +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const VISIONL_DIR = path.join(os.homedir(), '.visionl'); + +function ensureDir(): void { + if (!fs.existsSync(VISIONL_DIR)) { + fs.mkdirSync(VISIONL_DIR, { recursive: true }); + } +} + +export function writePidfile(pid: number, port: number): void { + ensureDir(); + fs.writeFileSync(path.join(VISIONL_DIR, 'daemon.pid'), String(pid)); + fs.writeFileSync(path.join(VISIONL_DIR, 'daemon.port'), String(port)); +} + +export function readPidfile(): { pid: number; port: number } | null { + const pidPath = path.join(VISIONL_DIR, 'daemon.pid'); + const portPath = path.join(VISIONL_DIR, 'daemon.port'); + try { + const pid = parseInt(fs.readFileSync(pidPath, 'utf-8'), 10); + const port = parseInt(fs.readFileSync(portPath, 'utf-8'), 10); + if (isNaN(pid) || isNaN(port)) return null; + return { pid, port }; + } catch { + return null; + } +} + +export function cleanPidfile(): void { + try { + fs.unlinkSync(path.join(VISIONL_DIR, 'daemon.pid')); + fs.unlinkSync(path.join(VISIONL_DIR, 'daemon.port')); + } catch { + // Ignore if files don't exist + } +} + +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} +``` + +- [ ] **Step 4: Run test — pass** + +- [ ] **Step 5: Commit** + +```bash +git add packages/daemon/src/pidfile.ts packages/daemon/src/__tests__/pidfile.test.ts +git commit -m "feat: add pidfile management for daemon process tracking" +``` + +--- + +### Task 7: Browser Manager + Page Registry + +**Files:** +- Create: `packages/daemon/src/page-registry.ts` +- Create: `packages/daemon/src/browser-manager.ts` +- Create: `packages/daemon/src/__tests__/browser-manager.test.ts` + +**Interfaces:** +- Produces: `PageRegistry` class: + - `add(info: PageInfo & { page: import('playwright').Page; context: import('playwright').BrowserContext }): void` + - `remove(id: string): boolean` + - `get(id: string): RegisteredPage | undefined` + - `findByIdOrAlias(idOrAlias: string): RegisteredPage | undefined` + - `list(): PageInfo[]` + - `hasAlias(alias: string): boolean` +- Produces: `BrowserManager` class: + - `constructor(profile: FingerprintProfile)` + - `async init(): Promise` — launches browser + - `async createPage(url: string, alias?: string): Promise` — creates context, page, injects stealth, navigates + - `async closePage(id: string): Promise` + - `getPage(idOrAlias: string): RegisteredPage | undefined` + - `listPages(): PageInfo[]` + - `async cleanup(): Promise` + +- [ ] **Step 1: Write packages/daemon/src/page-registry.ts** + +```typescript +import type { Page, BrowserContext } from 'playwright'; +import type { PageInfo } from '@visionl/core'; + +export interface RegisteredPage { + info: PageInfo; + page: Page; + context: BrowserContext; +} + +export class PageRegistry { + private pages = new Map(); + private aliasMap = new Map(); // alias → id + + add(info: PageInfo, page: Page, context: BrowserContext): void { + this.pages.set(info.id, { info, page, context }); + if (info.alias) { + this.aliasMap.set(info.alias, info.id); + } + } + + remove(id: string): boolean { + const entry = this.pages.get(id); + if (!entry) return false; + if (entry.info.alias) { + this.aliasMap.delete(entry.info.alias); + } + return this.pages.delete(id); + } + + get(id: string): RegisteredPage | undefined { + return this.pages.get(id); + } + + findByIdOrAlias(idOrAlias: string): RegisteredPage | undefined { + // Try alias first + const id = this.aliasMap.get(idOrAlias); + if (id) return this.pages.get(id); + // Then try direct ID + return this.pages.get(idOrAlias); + } + + list(): PageInfo[] { + return Array.from(this.pages.values()).map((entry) => entry.info); + } + + hasAlias(alias: string): boolean { + return this.aliasMap.has(alias); + } +} +``` + +- [ ] **Step 2: Write packages/daemon/src/browser-manager.ts** + +```typescript +import { chromium } from 'playwright-extra'; +import StealthPlugin from 'puppeteer-extra-plugin-stealth'; +import type { Browser, BrowserContext, Page } from 'playwright'; +import type { FingerprintProfile, PageInfo } from '@visionl/core'; +import { PageRegistry } from './page-registry.js'; + +// Apply stealth plugin once +chromium.use(StealthPlugin()); + +function generatePageId(): string { + const hex = Math.random().toString(16).slice(2, 10); + return `p_${hex}`; +} + +export class BrowserManager { + private browser: Browser | null = null; + private registry = new PageRegistry(); + private profile: FingerprintProfile; + + constructor(profile: FingerprintProfile) { + this.profile = profile; + } + + async init(): Promise { + this.browser = await chromium.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-blink-features=AutomationControlled', + ], + }); + } + + async createPage(url: string, alias?: string): Promise { + if (!this.browser) throw new Error('Browser not initialized'); + + if (alias && this.registry.hasAlias(alias)) { + throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' }); + } + + // Validate URL format + try { + new URL(url); + } catch { + throw Object.assign(new Error(`Invalid URL: ${url}`), { code: 'INVALID_URL' }); + } + + const context = await this.browser.newContext({ + viewport: this.profile.viewport, + userAgent: this.profile.userAgent, + locale: this.profile.languages[0], + timezoneId: this.profile.timezone, + permissions: Object.entries(this.profile.permissions) + .filter(([, v]) => v === 'granted') + .map(([k]) => k as any), + geolocation: this.profile.geolocation, + colorScheme: 'light', + deviceScaleFactor: this.profile.screen.pixelRatio, + }); + + const page = await context.newPage(); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + const title = await page.title(); + + const id = generatePageId(); + const info: PageInfo = { id, url, alias, title, status: 'active', profile: this.profile.id }; + this.registry.add(info, page, context); + + return info; + } + + async closePage(idOrAlias: string): Promise { + const entry = this.registry.findByIdOrAlias(idOrAlias); + if (!entry) { + throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' }); + } + await entry.context.close(); + this.registry.remove(entry.info.id); + } + + getPage(idOrAlias: string) { + return this.registry.findByIdOrAlias(idOrAlias); + } + + listPages(): PageInfo[] { + return this.registry.list(); + } + + async cleanup(): Promise { + // Close all contexts + for (const entry of this.registry['pages'].values()) { + try { await entry.context.close(); } catch { /* ignore */ } + } + if (this.browser) { + await this.browser.close(); + this.browser = null; + } + } +} +``` + +- [ ] **Step 3: Write test for browser-manager** + +Test should launch browser, create a page, verify it's in the list, close it, verify it's gone. +Because this requires Playwright + Chromium, mark the test with `test.describe('integration')` +and only run when `VISIONL_INTEGRATION=1` is set. + +- [ ] **Step 4: Verify build** + +```bash +npm run typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/daemon/src/page-registry.ts packages/daemon/src/browser-manager.ts +git commit -m "feat: add browser manager with page registry and Playwright stealth integration" +``` + +--- + +### Task 8: Page CRUD Routes + +**Files:** +- Create: `packages/daemon/src/routes/pages.ts` +- Create: `packages/daemon/src/__tests__/pages-routes.test.ts` + +**Interfaces:** +- Produces: Function that takes `BrowserManager` and returns a route handler compatible with server.ts route array +- Handles: `POST /pages`, `GET /pages`, `GET /pages/:id`, `DELETE /pages/:id` + +- [ ] **Step 1: Write pageRoutes(browserManager: BrowserManager) → RouteHandler** + +```typescript +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { BrowserManager } from '../browser-manager.js'; +import { safeStringify } from '@visionl/core'; + +type RouteHandler = (req: IncomingMessage, res: ServerResponse) => boolean; + +export function pageRoutes(bm: BrowserManager): RouteHandler { + return (req, res) => { + const url = new URL(req.url || '/', 'http://localhost'); + const path = url.pathname; + const segments = path.split('/').filter(Boolean); + res.setHeader('Content-Type', 'application/json'); + + // POST /pages + if (req.method === 'POST' && path === '/pages') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', async () => { + try { + const { url: pageUrl, alias } = JSON.parse(body); + const info = await bm.createPage(pageUrl, alias); + res.writeHead(200); + res.end(safeStringify({ ok: true, data: info })); + } catch (err: any) { + res.writeHead(err.code === 'ALIAS_EXISTS' ? 409 : 400); + res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } })); + } + }); + return true; + } + + // GET /pages + if (req.method === 'GET' && path === '/pages') { + const pages = bm.listPages(); + res.writeHead(200); + res.end(safeStringify({ ok: true, data: pages })); + return true; + } + + // GET /pages/:id + // DELETE /pages/:id + if (segments[0] === 'pages' && segments.length === 2) { + const id = segments[1]; + + if (req.method === 'GET') { + const entry = bm.getPage(id); + if (!entry) { + res.writeHead(404); + res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } })); + return true; + } + res.writeHead(200); + res.end(safeStringify({ ok: true, data: entry.info })); + return true; + } + + if (req.method === 'DELETE') { + bm.closePage(id).then(() => { + res.writeHead(200); + res.end(safeStringify({ ok: true, data: null })); + }).catch((err: any) => { + res.writeHead(err.code === 'PAGE_NOT_FOUND' ? 404 : 500); + res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } })); + }); + return true; + } + } + + return false; + }; +} +``` + +- [ ] **Step 2: Update server.ts** — import and register `pageRoutes` + +- [ ] **Step 3: Write integration test** — starts server with browser manager, POSTs a page, GETs list, DELETEs page + +- [ ] **Step 4: Commit** + +--- + +### Task 9: Content Routes (screenshot/text/html) + +**Files:** +- Create: `packages/daemon/src/routes/content.ts` + +**Interfaces:** +- Produces: `contentRoutes(bm: BrowserManager): RouteHandler` +- Handles: `GET /pages/:id/screenshot`, `GET /pages/:id/text`, `GET /pages/:id/html` + +- [ ] **Step 1: Implement contentRoutes** + +Key logic: +- `GET /pages/:id/screenshot`: calls `page.screenshot({ type: 'png', fullPage: false })`, returns base64 +- `GET /pages/:id/text`: calls `page.evaluate(() => document.body.innerText)`, returns text +- `GET /pages/:id/html`: calls `page.evaluate(() => document.documentElement.outerHTML)`, returns html +- All use `safeStringify` for JSON output +- Handle PAGE_NOT_FOUND with 404 + +- [ ] **Step 2: Register in server.ts** + +- [ ] **Step 3: Write test** + +- [ ] **Step 4: Commit** + +--- + +### Task 10: Action Routes (click/type/scroll/eval/wait/navigate) + +**Files:** +- Create: `packages/daemon/src/routes/actions.ts` + +**Interfaces:** +- Produces: `actionRoutes(bm: BrowserManager): RouteHandler` +- Handles: All `POST /pages/:id/*` routes except `/pages` creation + +- [ ] **Step 1: Implement actionRoutes** + +Each action: +- `POST /pages/:id/click` — `page.click(selector)` +- `POST /pages/:id/type` — `page.fill(selector, text)` (v1: use Playwright's built-in type for now, stealth-enhanced type comes in Task 15) +- `POST /pages/:id/scroll` — `page.evaluate(({deltaY, toBottom}) => { window.scrollBy(0, deltaY); })` or `window.scrollTo(0, document.body.scrollHeight)` +- `POST /pages/:id/eval` — `page.evaluate(code)` — return result wrapped in `{ result }` +- `POST /pages/:id/wait` — `page.waitForSelector(selector, { timeout })` or `page.waitForTimeout(ms)` +- `POST /pages/:id/navigate` — `page.goto(url, { waitUntil: 'domcontentloaded' })` — return `{ url, title }` + +All error handling: catch exceptions, return structured error responses. + +- [ ] **Step 2: Register in server.ts** + +- [ ] **Step 3: Write test** + +- [ ] **Step 4: Commit** + +--- + +### Task 11: Stealth — Plugin Integration + Navigator + +**Files:** +- Create: `packages/daemon/src/stealth/navigator.ts` +- Create: `packages/daemon/src/__tests__/stealth-navigator.test.ts` + +**Interfaces:** +- Produces: `injectNavigatorStealth(context: BrowserContext, profile: FingerprintProfile): Promise` + — Uses `context.addInitScript()` to override navigator properties before any page script runs + +- [ ] **Step 1: Implement packages/daemon/src/stealth/navigator.ts** + +```typescript +import type { BrowserContext } from 'playwright'; +import type { FingerprintProfile } from '@visionl/core'; + +export async function injectNavigatorStealth( + context: BrowserContext, + profile: FingerprintProfile +): Promise { + await context.addInitScript((opts) => { + // Override navigator.webdriver (stealth plugin handles this, but belt-and-suspenders) + Object.defineProperty(navigator, 'webdriver', { + get: () => false, + }); + + // Override navigator.plugins if stealth didn't fully cover + if (navigator.plugins.length === 0) { + Object.defineProperty(navigator, 'plugins', { + get: () => { + // Return a PluginArray-like object with standard Chrome plugins + const arr = Object.create(PluginArray.prototype); + arr.length = 0; + arr.item = () => null; + arr.namedItem = () => null; + arr.refresh = () => {}; + return arr; + }, + }); + } + + // Override navigator.languages + Object.defineProperty(navigator, 'languages', { + get: () => opts.languages, + }); + + // Override navigator.platform + Object.defineProperty(navigator, 'platform', { + get: () => opts.platform, + }); + + // Fix navigator.hardwareConcurrency if needed (usually fine) + // Fix navigator.deviceMemory if needed + + // Inject navigator.connection if missing (headless Chrome) + if (!('connection' in navigator)) { + Object.defineProperty(navigator, 'connection', { + get: () => ({ + downlink: 10, + effectiveType: '4g', + rtt: 50, + saveData: false, + }), + }); + } + + // Standard navigator values + Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' }); + Object.defineProperty(navigator, 'productSub', { get: () => '20030107' }); + }, { + languages: profile.languages, + platform: profile.platform, + }); +} +``` + +- [ ] **Step 2: Write test** — creates a real BrowserContext, injects stealth, navigates to a test page, evaluates `navigator.webdriver` (expect `false`), `navigator.languages` (expect profile values), etc. + +- [ ] **Step 3: Verify** — run test + +- [ ] **Step 4: Commit** + +--- + +### Task 12: Stealth — Chrome Runtime + Screen + Permissions + +**Files:** +- Create: `packages/daemon/src/stealth/chrome-runtime.ts` +- Create: `packages/daemon/src/stealth/screen.ts` +- Create: `packages/daemon/src/stealth/permissions.ts` + +**Interfaces:** +- Produces: `injectChromeRuntime(context: BrowserContext): Promise` — injects `window.chrome` object +- Produces: `injectScreenStealth(context: BrowserContext, profile: FingerprintProfile): Promise` — sets screen/viewport dimensions +- Produces: `injectPermissionsStealth(context: BrowserContext, profile: FingerprintProfile): Promise` — sets permission states via Playwright Context + +- [ ] **Step 1: Implement each module** similar to Task 11 pattern + +- [ ] **Step 2: Write tests** for each + +- [ ] **Step 3: Commit** + +--- + +### Task 13: Stealth — Canvas/WebGL/Audio Noise + +**Files:** +- Create: `packages/daemon/src/stealth/canvas-noise.ts` + +**Interfaces:** +- Produces: `injectCanvasNoise(context: BrowserContext, opts: CanvasNoiseConfig): Promise` + — Uses `context.addInitScript()` to hook `HTMLCanvasElement.prototype.toDataURL`, `toBlob`, `getImageData` + — Uses `context.addInitScript()` to hook WebGL `readPixels` + — Uses `context.addInitScript()` to hook `AudioContext` createOscillator/getFloatFrequencyData + +- [ ] **Step 1: Implement canvas 2D noise** — patches `toDataURL`, `toBlob`, `getImageData` to add ±1 noise to random pixels. Uses session-based seed for consistency within same context. + +- [ ] **Step 2: Implement WebGL noise** — patches `readPixels` + +- [ ] **Step 3: Implement AudioContext noise** — patches `getFloatFrequencyData`, `getByteFrequencyData`, `getFloatTimeDomainData` + +- [ ] **Step 4: Write tests** — opens a page, runs canvas fingerprinting code, verifies the hash differs from non-noised version + +- [ ] **Step 5: Commit** + +--- + +### Task 14: Stealth — HTTP Headers + +**Files:** +- Create: `packages/daemon/src/stealth/headers.ts` + +**Interfaces:** +- Produces: `injectHeaderStealth(page: Page, profile: FingerprintProfile): Promise` + — Uses `page.route('**/*', handler)` to intercept and modify request headers + — Adds: `Sec-CH-UA`, `Sec-CH-UA-Platform`, `Sec-CH-UA-Mobile`, `sec-ch-ua-arch`, `sec-ch-ua-bitness` + +- [ ] **Step 1: Implement header injection** + +```typescript +import type { Page } from 'playwright'; +import type { FingerprintProfile } from '@visionl/core'; + +export async function injectHeaderStealth(page: Page, profile: FingerprintProfile): Promise { + await page.route('**/*', (route) => { + const headers = route.request().headers(); + headers['sec-ch-ua'] = `"Chromium";v="132", "Google Chrome";v="132", "Not?A_Brand";v="99"`; + headers['sec-ch-ua-platform'] = `"${profile.platform.includes('Windows') ? 'Windows' : profile.platform.includes('Mac') ? 'macOS' : 'Linux'}"`; + headers['sec-ch-ua-mobile'] = '?0'; + route.continue({ headers }); + }); +} +``` + +- [ ] **Step 2: Write test** — opens a page that echoes headers (or use request interception to inspect) + +- [ ] **Step 3: Commit** + +--- + +### Task 15: Stealth — Human Input Simulation + +**Files:** +- Create: `packages/daemon/src/stealth/human-input.ts` + +**Interfaces:** +- Produces: `humanClick(page: Page, selector: string, profile: FingerprintProfile): Promise` +- Produces: `humanType(page: Page, selector: string, text: string, profile: FingerprintProfile): Promise` +- Produces: `humanScroll(page: Page, opts: { deltaY?: number; toBottom?: boolean }, profile: FingerprintProfile): Promise` + +- [ ] **Step 1: Implement humanClick** — gets element bounding box, calculates mouse path from a random starting position to target center with jitter, dispatches mousemove events along path, then mousedown/mouseup/click at target. + +- [ ] **Step 2: Implement humanType** — focuses element, then for each character dispatches keydown → (delay) → keypress → (delay) → keyup with random delay between 50-150ms. Optionally dispatches input event after each character. + +- [ ] **Step 3: Implement humanScroll** — scrolls in steps of 50-200px with random delays. + +- [ ] **Step 4: Write tests** — verify mouse events are dispatched, verify key delays fall within configured range. + +- [ ] **Step 5: Commit** + +--- + +### Task 16: Fingerprint Profiles (Built-in Templates) + +**Files:** +- Create: `packages/daemon/src/stealth/profiles/desktop-chrome.ts` +- Create: `packages/daemon/src/stealth/profiles/desktop-windows.ts` +- Create: `packages/daemon/src/stealth/profiles/desktop-mac.ts` +- Create: `packages/daemon/src/stealth/profiles/index.ts` + +**Interfaces:** +- Produces: `getProfile(id: string): FingerprintProfile | undefined` +- Produces: `listProfiles(): Array<{ id: string; name: string }>` + +- [ ] **Step 1: Implement each profile** — define complete FingerprintProfile objects + +- [ ] **Step 2: Implement profiles/index.ts** — exports a Map of profiles and lookup functions + +- [ ] **Step 3: Commit** + +--- + +### Task 17: Stealth Injection Orchestrator + +**Files:** +- Create: `packages/daemon/src/stealth/index.ts` + +**Interfaces:** +- Produces: `applyStealth(context: BrowserContext, page: Page, profile: FingerprintProfile): Promise` + — Calls all stealth injection functions in correct order (initScript before navigation, page.route after) + +- [ ] **Step 1: Implement orchestrator** — calls each inject function from Tasks 11-15 + +- [ ] **Step 2: Update browser-manager.ts** — call `applyStealth` in `createPage` after context creation + +- [ ] **Step 3: Commit** + +--- + +### Task 18: CLI Entry Point + Auto-Daemon + +**Files:** +- Create: `packages/cli/src/index.ts` +- Create: `packages/cli/src/auto-daemon.ts` +- Create: `packages/cli/src/__tests__/auto-daemon.test.ts` + +**Interfaces:** +- Produces: CLI entry with commander program, all subcommands registered +- Produces: `ensureDaemonRunning(port: number): Promise` — checks health, spawns if needed + +- [ ] **Step 1: Implement auto-daemon.ts** + +Logic: +1. Try `GET /health` on port +2. If fails, check pidfile +3. If pidfile dead or missing, `spawn('node', ['packages/daemon/dist/server.js'])` with env `VISIONL_PORT=port` +4. Poll health up to 3s (100ms intervals) +5. Throw if still not up after timeout + +- [ ] **Step 2: Write test** — mock spawn and fetch, verify fallback logic + +- [ ] **Step 3: Implement CLI entry** — commander program with all commands registered as described below + +- [ ] **Step 4: Commit** + +--- + +### Task 19: CLI Page + View Commands + +**Files:** +- Create: `packages/cli/src/commands/page.ts` +- Create: `packages/cli/src/commands/view.ts` +- Create: `packages/cli/src/__tests__/cli-page.test.ts` + +**Interfaces:** +- Produces: Commander subcommands: `page open `, `page list`, `page info `, `page kill `, `page kill-all` +- Produces: Commander subcommands: `screenshot `, `text `, `html ` +- Each command: calls auto-daemon, then client method, formats output + +- [ ] **Step 1: Implement page commands** + +Draw on `VisionLClient` from core package. After auto-daemon, call appropriate client method, print `safeStringify` output. On error, print error JSON and exit with code 1. + +- [ ] **Step 2: Implement view commands** + +Same pattern. `screenshot` supports `-o` flag to write to file instead of printing base64. + +- [ ] **Step 3: Write tests** — mock client, verify correct method called + +- [ ] **Step 4: Commit** + +--- + +### Task 20: CLI Action + Daemon Commands + +**Files:** +- Create: `packages/cli/src/commands/action.ts` +- Create: `packages/cli/src/commands/daemon.ts` +- Create: `packages/cli/src/format.ts` + +**Interfaces:** +- Produces: Commander subcommands: `click`, `type`, `scroll`, `navigate`, `eval`, `wait` +- Produces: Commander subcommands: `daemon start`, `daemon stop`, `daemon status`, `profiles` +- Produces: `formatOutput(data: unknown, pretty?: boolean): string` + +- [ ] **Step 1: Implement format.ts** — if pretty, use `JSON.stringify(data, null, 2)`; else `safeStringify` with no whitespace + +- [ ] **Step 2: Implement action commands** + +- [ ] **Step 3: Implement daemon commands** — start spawns daemon, stop calls kill on pidfile pid, status checks health + +- [ ] **Step 4: Implement profiles command** — calls `client.getProfiles()` + +- [ ] **Step 5: Write tests** + +- [ ] **Step 6: Commit** + +--- + +### Task 21: WebSocket Relay + +**Files:** +- Create: `packages/daemon/src/ws-relay.ts` +- Modify: `packages/daemon/src/server.ts` (add WS upgrade) + +**Interfaces:** +- Produces: `createWsRelay(server: http.Server): WsRelay` + - `broadcast(event: WsEvent): void` — sends to all connected clients + - `on(event, handler)` — internal event hooks + +- [ ] **Step 1: Implement ws-relay.ts** using `ws` library + +- [ ] **Step 2: Integrate into server.ts** — upgrade `/ws` path + +- [ ] **Step 3: Emit events from browser-manager** — on page create, close, crash + +- [ ] **Step 4: Commit** + +--- + +### Task 22: Profiles API Endpoint + +**Files:** +- Create: `packages/daemon/src/routes/profiles.ts` + +**Interfaces:** +- `GET /profiles` → `{ ok: true, data: [...] }` + +- [ ] **Step 1: Implement** — delegates to `listProfiles()` + +- [ ] **Step 2: Register in server.ts** + +- [ ] **Step 3: Commit** + +--- + +### Task 23: Integration Test — Full E2E Flow + +**Files:** +- Create: `packages/daemon/src/__tests__/integration/e2e.test.ts` + +- [ ] **Step 1: Write E2E test** — starts daemon, opens page via HTTP, clicks, types, takes screenshot, gets text, kills page + +- [ ] **Step 2: Run** — `VISIONL_INTEGRATION=1 npx vitest run` + +- [ ] **Step 3: Commit** + +--- + +### Task 24: Anti-Detection Verification + +**Files:** +- Create: `packages/daemon/src/__tests__/stealth/verification.test.ts` + +- [ ] **Step 1: Write verification test** — opens a local test page that emulates the detection checks from bot.sannysoft.com: navigator.webdriver, plugins, languages, screen dimensions, chrome runtime, etc. Asserts all checks pass. + +- [ ] **Step 2: Run** + +- [ ] **Step 3: Commit** + +--- + +### Task 25: Documentation Polish + Final Integration + +- [ ] **Step 1: Verify all tests pass** — `npm test` + +- [ ] **Step 2: Verify typecheck** — `npm run typecheck` + +- [ ] **Step 3: Update README** if needed + +- [ ] **Step 4: Commit**