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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All notable changes to this project are documented here. The format follows [Kee
- Headless sessions keep Chrome's scrollbars: Playwright's `--hide-scrollbars` made them 0 px wide where Chrome on Windows draws 15.

### Added
- A bare `patchright-cli` prints the home view instead of the manual: the live sessions of this workspace (mode, profile, tab count, current page, lifetime) and the commands to run next, or how to open a browser when none is; `--json` gives the same as data and `--help` is still the manual. It starts nothing, gives each daemon 1.5 s to answer and does not count as activity for the idle timeout.
- Named profiles: `open --profile=<name>` keeps an identity under the state root (`named-profiles/<name>`) that any session in any workspace opens by name; a value with a path separator is still a directory. `open` refuses a profile another live session holds and names it, instead of Chrome failing on the locked profile. `profile-list` shows each profile's size, identity and holder; `profile-delete <name>` removes one and is refused while it is held.
- `url`, `title`, `text [target]`, `wait <ms>` / `wait --url=<glob>`, `wait-for <target> --state=...` and `scroll-to <target>`: the reads and waits agents otherwise write as `eval`/`run-code` every time. Each is a fixed Playwright snippet sent through `run-code`, so element reads stay in the isolated world, and prints only the value.
- `cookie-import <file>` takes what people actually have: a cookie-editor browser extension export (Cookie-Editor, EditThisCookie), a Netscape `cookies.txt`, a Playwright storage state or cookie array; `cookie-export [file]` writes JSON or `--format=netscape`, owner-only. Both take `--domain`. Cookie values never appear in the output.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ Warnings go to stderr as `[patchright-cli] LeakWarning(<key>): ...`, or into `wa

A session nobody comes back to closes itself. The daemon is upstream code and keeps its browser until something sends `close`; an agent that opens a session, finishes and exits never does, and every leaked session keeps a Chrome running at full rate (microsoft/playwright-cli#460). So `open` starts a small watchdog next to the daemon: inside an agent harness (Codex, Claude Code, Copilot, Cursor, Gemini, Aider are recognised from their environment markers) the browser closes after 30 minutes without a command, and as soon as the agent process exits when the harness exposes its pid (`CLAUDE_PID`, or `PATCHRIGHT_CLI_OWNER_PID` for any harness). `--idle-timeout=2h` (or `idleTimeout` in the config) changes the limit, `--idle-timeout=0` and `--no-owner-pid` keep the browser open, `--owner-pid=<pid>` ties it to another process. Outside a harness nothing changes unless asked. The persistent profile survives, so the next `open` is still logged in; `list` shows each session's lifetime and `<session>.watchdog.log` in the state directory says why a browser went away. `kill-all` also stops watchdogs.

A bare `patchright-cli` prints the home view instead of the manual: the live sessions of this workspace (mode, profile, tab count, current page, lifetime) and the next commands, or how to open a browser when none is. It starts nothing, gives each daemon 1.5 s to answer and does not count as activity for the idle timeout; `--json` returns the same as data, `--help` is the manual.

`-s=<name>` (or `PATCHRIGHT_CLI_SESSION`) selects a session: its own daemon, Chrome window, persistent profile, identity and proxy. `identity` shows what a profile presents; `delete-data` discards it; `--isolated` opens a throwaway profile. Details: [skills/patchright-cli/references/identity.md](skills/patchright-cli/references/identity.md).

`open` states the launch facts once, on its first line: browser and build, mode, profile, and the user agent when it is not the browser's own (`### Browser \`x\` opened with pid N: chrome 152, headless, profile ud-x-chrome, user agent Chrome/152 (headed name of this build)`). The executable path appears only for a non-standard browser (the bundled fallback, a configured `executablePath`); `--json` carries all of it as `launch`. Notes on `open` are one line of facts each, and no later command repeats them; paths are on demand in `list`, `identity`, `doctor` and `env`.
Expand Down Expand Up @@ -144,7 +146,7 @@ When the default state root is read-only, point it at the workspace: `PATCHRIGHT

Core: `open attach close detach goto type click dblclick fill drag drop hover select upload check uncheck snapshot find eval dialog-accept dialog-dismiss resize run-code delete-data` · Navigation: `go-back go-forward reload` · Keyboard: `press keydown keyup` · Mouse: `mousemove mousedown mouseup mousewheel` · Save as: `screenshot pdf` · Tabs: `tab-list tab-new tab-close tab-select` · Storage: `state-load state-save cookie-* localstorage-* sessionstorage-*` · Network: `requests request request-headers request-body response-headers response-body route route-list unroute network-state-set` · Diagnostics: `console tracing-start tracing-stop video-* show generate-locator highlight config-print` · Stealth and environment: `env batch wait-for-user identity selftest doctor` · Sessions: `list close-all kill-all install install-browser`.

`patchright-cli --help` and `patchright-cli <command> --help` are always current; the skill at [skills/patchright-cli/SKILL.md](skills/patchright-cli/SKILL.md) documents everything for agents.
`patchright-cli` alone shows what is open; `patchright-cli --help` and `patchright-cli <command> --help` are always current; the skill at [skills/patchright-cli/SKILL.md](skills/patchright-cli/SKILL.md) documents everything for agents.

## Configuration

Expand Down
6 changes: 6 additions & 0 deletions skills/patchright-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ Once per session, before the first browser command:

```bash
patchright-cli env
# what is already open in this workspace: sessions, their current page, what to run next
patchright-cli
```

A bare `patchright-cli` is the home view, not the manual (that is `--help`). It lists the live sessions of this workspace with their mode, profile, tab count, current page and lifetime, then the next commands; `--json` gives the same as data. It starts nothing and does not keep an idle session alive. When the browser you need is already open there, continue it with its `-s=<name>` instead of opening another one.

It prints the shell it detected, the console encoding, whether a display exists, the host locale and timezone, the agent host and sandbox markers, whether the state root is writable, and the quoting rules that apply here (`--json` for a structured version, `--probe` also checks network access). Follow those rules for every later command; the CLI never changes the console settings itself. If a `find` or `eval` unexpectedly reports nothing, or a `[msys-path-conversion]` warning appears, the shell mangled the argument: fix the invocation, do not conclude the page lacks the content.

Sandboxes, permission modes that confirm every command (`batch` runs a whole flow as one approval), a read-only home, and the security rules for profiles, credentials and `run-code` are in [references/environments.md](references/environments.md); read it when `env` reports a sandbox or a limit. A denied permission or a sandbox limit is the user's decision: report it and ask, do not work around it.
Expand Down Expand Up @@ -330,6 +334,8 @@ patchright-cli doctor
### Sessions and setup

```bash
# home view: live sessions of this workspace and the next commands
patchright-cli
patchright-cli list
patchright-cli close-all
patchright-cli kill-all
Expand Down
219 changes: 219 additions & 0 deletions src/commands/home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* Copyright (c) 2026 b1zya (https://github.com/b1zya/patchright-cli).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// The home view: what a bare `patchright-cli` prints instead of the manual (that is `--help`).
// An agent arriving in a workspace learns in a few lines which browsers are already open here,
// what each one shows and what to run next, so it continues a session instead of opening a
// second one. It is read-only and quick: it starts nothing, deletes no stale session file,
// does not count as activity for a session's idle timer, and gives each daemon a short budget.

import path from 'path';

import { readState } from '../humanize/state';
import { describeLifetime } from '../lifetime';
import { namedProfilesDir } from '../paths';
import { clientKey } from '../registry';
import { Session } from '../session';

import type { ClientInfo, Registry, SessionFile } from '../registry';

export type HomeTab = { index: number, title: string, url: string };

export type HomeSession = {
name: string;
// open: answered `tab-list`; busy: did not answer in time; incompatible: started by a newer
// client than this one, which refuses to drive it (`open` restarts it).
status: 'open' | 'busy' | 'incompatible';
headed?: boolean;
attached: boolean;
browser?: string;
// The named profile (`open --profile=<name>`) the session runs on, if any.
profile?: string;
tabs?: number;
current?: HomeTab;
lifetime?: string;
};

export type HomeHint = { command: string, why: string };

export type HomeData = {
version: string;
core: string;
workspace: string | null;
skill?: string;
sessions: HomeSession[];
next: HomeHint[];
};

// One daemon gets this long to answer `tab-list`; all of them are asked at once.
export const homeProbeTimeoutMs = 1500;

// `- 1: (current) [Title](https://example.com/)`, as the daemon's raw tab-list prints it.
const tabLine = /^- (\d+): (\(current\) )?\[(.*)\]\((.*)\)$/;

export function parseTabList(text: string): { tabs: number, current?: HomeTab } {
let tabs = 0;
let current: HomeTab | undefined;
for (const line of text.split(/\r?\n/)) {
const match = line.match(tabLine);
if (!match)
continue;
tabs++;
if (match[2])
current = { index: Number(match[1]), title: match[3], url: match[4] };
}
return { tabs, current };
}

function profileOf(userDataDir: string | undefined): string | undefined {
if (!userDataDir)
return undefined;
const relative = path.relative(namedProfilesDir(), userDataDir);
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) && !relative.includes(path.sep) ? relative : undefined;
}

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), ms); });
return Promise.race([promise.catch(() => undefined), timeout]).finally(() => clearTimeout(timer));
}

export type HomeProbe = (entry: SessionFile) => Promise<{ alive: boolean, tabList?: string }>;

// Connects to the daemon and asks for its tabs. A daemon that does not accept the connection
// is gone (its session file is stale); one that accepts but does not answer in time is busy.
export function daemonProbe(clientInfo: ClientInfo, timeoutMs = homeProbeTimeoutMs): HomeProbe {
return async entry => {
const session = new Session(entry);
if (!await withTimeout(session.canConnect(), timeoutMs))
return { alive: false };
if (!session.isCompatible(clientInfo))
return { alive: true };
const result = await withTimeout(session.run(clientInfo, { _: ['tab-list'] }, { raw: true }), timeoutMs);
return { alive: true, tabList: result && !result.isError ? result.text : undefined };
};
}

export async function collectHomeSessions(registry: Registry, clientInfo: ClientInfo, probe: HomeProbe): Promise<HomeSession[]> {
const entries = registry.entryMap().get(clientKey(clientInfo)) ?? [];
const sessions = await Promise.all(entries.map(async (entry): Promise<HomeSession | undefined> => {
const { alive, tabList } = await probe(entry);
if (!alive)
return undefined;
const config = entry.config;
const compatible = new Session(entry).isCompatible(clientInfo);
const lifetime = readState(entry.daemonDir, config.name).lifetime;
const session: HomeSession = {
name: config.name,
status: !compatible ? 'incompatible' : tabList === undefined ? 'busy' : 'open',
headed: config.browser ? !config.browser.launchOptions.headless : undefined,
attached: !!config.attached,
browser: config.browser?.launchOptions.channel ?? config.browser?.browserName,
profile: profileOf(config.browser?.userDataDir),
lifetime: lifetime ? describeLifetime(lifetime) : undefined,
};
if (tabList !== undefined)
Object.assign(session, parseTabList(tabList));
return session;
}));
return sessions.filter((s): s is HomeSession => !!s).sort((a, b) => a.name === 'default' ? -1 : b.name === 'default' ? 1 : a.name.localeCompare(b.name));
}

function sessionFlag(name: string): string {
return name === 'default' ? '' : ` -s=${name}`;
}

export function homeHints(sessions: HomeSession[]): HomeHint[] {
const hints: HomeHint[] = [];
const first = sessions.find(s => s.status === 'open');
if (first) {
hints.push({ command: `patchright-cli${sessionFlag(first.name)} snapshot`, why: 'element refs of the current page' });
if (sessions.length > 1)
hints.push({ command: 'patchright-cli -s=<name> <command>', why: 'act in another session' });
} else if (!sessions.length) {
hints.push({ command: 'patchright-cli open <url>', why: 'headless Chrome with this workspace\'s identity; --headed for a window' });
hints.push({ command: 'patchright-cli env', why: 'shell quoting, display and rules for this machine' });
}
const incompatible = sessions.find(s => s.status === 'incompatible');
if (incompatible)
hints.push({ command: `patchright-cli${sessionFlag(incompatible.name)} open`, why: `restart '${incompatible.name}', started by a newer patchright-cli` });
hints.push({ command: 'patchright-cli list --all', why: 'sessions in every workspace' });
hints.push({ command: 'patchright-cli --help', why: 'all commands; <command> --help for one' });
return hints;
}

const maxUrl = 80;

function shorten(text: string, max: number): string {
// ASCII on purpose: a legacy Windows console code page has no ellipsis.
return text.length > max ? text.slice(0, max - 3) + '...' : text;
}

function describeSession(session: HomeSession): string[] {
const parts: string[] = [];
if (session.attached)
parts.push(`attached ${session.browser ?? 'browser'}`);
else
parts.push(`${session.headed ? 'headed' : 'headless'} ${session.browser ?? 'chrome'}`);
if (session.profile)
parts.push(`profile ${session.profile}`);
if (session.status === 'incompatible') {
parts.push('started by a newer patchright-cli; open restarts it');
} else if (session.status === 'busy') {
parts.push(`busy (no answer within ${homeProbeTimeoutMs / 1000} s)`);
} else {
parts.push(`${session.tabs} tab${session.tabs === 1 ? '' : 's'}`);
if (session.current) {
const title = session.current.title ? ` "${shorten(session.current.title, 60)}"` : '';
parts.push(`current${title} ${shorten(session.current.url, maxUrl)}`);
}
}
const lines = [`- ${session.name}: ${parts.join(', ')}`];
if (session.lifetime)
lines.push(` ${session.lifetime}`);
return lines;
}

export function renderHome(data: HomeData): string {
const lines = [`patchright-cli ${data.version} (patchright-core ${data.core}) - anti-detection browser automation for AI agents`];
if (data.skill)
lines.push(`Agent skill: ${data.skill}`);
lines.push('');
if (data.sessions.length) {
lines.push(`Browsers open in this workspace (${data.sessions.length}):`);
for (const session of data.sessions)
lines.push(...describeSession(session));
} else {
lines.push('No browser open in this workspace.');
}
lines.push('', 'Next:');
const width = Math.max(...data.next.map(h => h.command.length));
for (const hint of data.next)
lines.push(` ${hint.command.padEnd(width)} ${hint.why}`);
return lines.join('\n');
}

export async function collectHome(options: { registry: Registry, clientInfo: ClientInfo, version: string, core: string, skill?: string, probe?: HomeProbe }): Promise<HomeData> {
const sessions = await collectHomeSessions(options.registry, options.clientInfo, options.probe ?? daemonProbe(options.clientInfo));
return {
version: options.version,
core: options.core,
workspace: options.clientInfo.workspaceDir ?? null,
skill: options.skill,
sessions,
next: homeHints(sessions),
};
}
13 changes: 11 additions & 2 deletions src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { Session } from './session';
import { coreBundle, corePackageJSON, dashboardAppPath, serverRegistry, utilsBundle } from './core';
import { loadUserConfig } from './config/load';
import { applyCoreEnvInProcess, coreProcessEnv } from './daemonEnv';
import { collectHome, renderHome } from './commands/home';
import { assertProfileFree } from './commands/profiles';
import { loadHelp } from './help';
import { killAllDaemons } from './killAll';
Expand Down Expand Up @@ -125,13 +126,21 @@ export async function program(options?: { embedderVersion?: string }) {
}

const command = commandName && help.commands[commandName];
const agentSkill = process.env.CLAUDECODE || process.env.COPILOT_CLI ? path.relative(process.cwd(), path.join(bundledSkillDir(), 'SKILL.md')) : undefined;
// A bare run is the home view (src/commands/home.ts): live sessions and what to run next.
if (!commandName && !args.help && !args.h) {
const home = await collectHome({ registry: await Registry.load(), clientInfo, version: options?.embedderVersion ?? clientInfo.version, core: corePackageJSON.version, skill: agentSkill });
output.toolResult(output.json ? JSON.stringify(home, null, 2) : renderHome(home));
// eslint-disable-next-line no-restricted-syntax
processExit(0);
}
if (args.help || args.h || !commandName) {
if (command) {
output.help(command.help);
} else {
const lines = [`patchright-cli - anti-detection browser automation for AI agents (${corePackageJSON.name} ${corePackageJSON.version})`];
if (process.env.CLAUDECODE || process.env.COPILOT_CLI)
lines.push(`Agent skill: ${path.relative(process.cwd(), path.join(bundledSkillDir(), 'SKILL.md'))}`);
if (agentSkill)
lines.push(`Agent skill: ${agentSkill}`);
lines.push(help.global);
output.help(lines.join('\n\n'));
}
Expand Down
Loading
Loading