pcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/auth.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Shared authentication helpers for Anthropic access.
|
|
2
|
+
|
|
3
|
+
API-key access uses ordinary Pydantic AI construction. Subscription access
|
|
4
|
+
(pcode's own browser sign-in) speaks to the Claude Code endpoint, which
|
|
5
|
+
requires the wire markers defined here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from functools import cache
|
|
13
|
+
|
|
14
|
+
# Compatibility markers used by Claude subscription OAuth traffic, not API keys.
|
|
15
|
+
OAUTH_BETAS = {"claude-code-20250219", "oauth-2025-04-20"}
|
|
16
|
+
OAUTH_PREAMBLE = "You are Claude Code, Anthropic's official CLI for Claude."
|
|
17
|
+
# The endpoint gates newer models on the advertised version: below a model's
|
|
18
|
+
# floor the request fails with `claude_code_version_too_old`, not a
|
|
19
|
+
# model-not-found. Each new model raises its floor, so prefer the locally
|
|
20
|
+
# installed Claude Code and keep this as the fallback for hosts without one.
|
|
21
|
+
OAUTH_VERSION = "2.1.280"
|
|
22
|
+
VERSION_TIMEOUT_SECONDS = 5.0
|
|
23
|
+
VERSION_PATTERN = re.compile(r"\d+(?:\.\d+)+")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_version(text: str) -> tuple[int, ...] | None:
|
|
27
|
+
match = VERSION_PATTERN.search(text)
|
|
28
|
+
return tuple(int(part) for part in match.group().split(".")) if match else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def oauth_version() -> str:
|
|
32
|
+
"""Fallback Claude Code version, overridable without a release."""
|
|
33
|
+
value = os.environ.get("PCODE_CLAUDE_VERSION", "").strip()
|
|
34
|
+
if not value:
|
|
35
|
+
return OAUTH_VERSION
|
|
36
|
+
if not VERSION_PATTERN.fullmatch(value):
|
|
37
|
+
raise LoginError("PCODE_CLAUDE_VERSION must be a dotted version such as 2.1.280.")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@cache
|
|
42
|
+
def oauth_user_agent() -> str:
|
|
43
|
+
"""Claude Code identity to advertise, never older than `oauth_version()`.
|
|
44
|
+
|
|
45
|
+
Spoofing a version that was never released risks rejection, so the local
|
|
46
|
+
install is the only source of anything newer than the fallback.
|
|
47
|
+
"""
|
|
48
|
+
version = oauth_version()
|
|
49
|
+
executable = shutil.which("claude")
|
|
50
|
+
if executable:
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
[executable, "--version"],
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
timeout=VERSION_TIMEOUT_SECONDS,
|
|
57
|
+
check=True,
|
|
58
|
+
)
|
|
59
|
+
except (OSError, subprocess.SubprocessError):
|
|
60
|
+
pass
|
|
61
|
+
else:
|
|
62
|
+
local = _parse_version(result.stdout)
|
|
63
|
+
if local and local > (_parse_version(version) or ()):
|
|
64
|
+
version = ".".join(str(part) for part in local)
|
|
65
|
+
return f"claude-cli/{version}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LoginError(ValueError):
|
|
69
|
+
"""Safe-to-display authentication setup failure."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SubscriptionOAuthWire:
|
|
73
|
+
"""Add Claude Code wire identity to an `AnthropicModel` subclass.
|
|
74
|
+
|
|
75
|
+
Pydantic's message mapping is a private integration seam, covered by wire
|
|
76
|
+
tests. This is compatibility support, not a claim of official third-party
|
|
77
|
+
OAuth support. Subclasses set `_subscription_oauth` when the resolved
|
|
78
|
+
credential is an OAuth token rather than an API key.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
_subscription_oauth = False
|
|
82
|
+
|
|
83
|
+
async def _map_message(self, messages, model_request_parameters, model_settings):
|
|
84
|
+
system, mapped = await super()._map_message(
|
|
85
|
+
messages, model_request_parameters, model_settings
|
|
86
|
+
)
|
|
87
|
+
if self._subscription_oauth:
|
|
88
|
+
blocks = (
|
|
89
|
+
([{"type": "text", "text": system}] if system else [])
|
|
90
|
+
if isinstance(system, str)
|
|
91
|
+
else list(system)
|
|
92
|
+
)
|
|
93
|
+
system = [{"type": "text", "text": OAUTH_PREAMBLE}, *blocks]
|
|
94
|
+
return system, mapped
|
|
95
|
+
|
|
96
|
+
def _get_betas_and_extra_headers(self, *args, **kwargs):
|
|
97
|
+
betas, headers = super()._get_betas_and_extra_headers(*args, **kwargs)
|
|
98
|
+
if self._subscription_oauth:
|
|
99
|
+
betas.update(OAUTH_BETAS)
|
|
100
|
+
headers.update({"User-Agent": oauth_user_agent(), "x-app": "cli"})
|
|
101
|
+
return betas, headers
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def anthropic_model(model: str, key: str, *, http_client=None):
|
|
105
|
+
from anthropic import AsyncAnthropic
|
|
106
|
+
from pydantic_ai.models.anthropic import AnthropicModel
|
|
107
|
+
from pydantic_ai.providers.anthropic import AnthropicProvider
|
|
108
|
+
|
|
109
|
+
# Runtime owns visible transport retries; HTTP errors must surface immediately.
|
|
110
|
+
client = AsyncAnthropic(api_key=key, http_client=http_client, max_retries=0)
|
|
111
|
+
return AnthropicModel(
|
|
112
|
+
model.removeprefix("anthropic:"), provider=AnthropicProvider(anthropic_client=client)
|
|
113
|
+
)
|
pcode/block.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""One block style for a run of output, shared by scrollback and the live panel.
|
|
2
|
+
|
|
3
|
+
A command, an edit or a live preview is drawn the same way everywhere: a top
|
|
4
|
+
line that carries the heading, a two-space indented body, and a closing line.
|
|
5
|
+
Scrollback draws it with Rich and the live panel with prompt_toolkit windows,
|
|
6
|
+
so the containers cannot be one object; the heading text, the indent and the
|
|
7
|
+
rule character live here so the two surfaces cannot drift apart.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from rich.rule import Rule
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
RULE = "─"
|
|
14
|
+
INDENT = " "
|
|
15
|
+
# Heading markers. A non-zero exit is routine, so the marker alone reports it:
|
|
16
|
+
# a word or an alarm colour would make every expected failure look like a crash.
|
|
17
|
+
RUNNING = "⟳"
|
|
18
|
+
DONE = "✓"
|
|
19
|
+
FAILED = "✗"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def block_heading(icon: str, title: str, elapsed_seconds: float | None = None) -> str:
|
|
23
|
+
"""The text that sits on the opening line: marker, what ran, how long."""
|
|
24
|
+
elapsed = f" · {elapsed_seconds:.1f}s" if elapsed_seconds is not None else ""
|
|
25
|
+
return f"{icon} {title}{elapsed}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def block_rule(heading: str | None = None) -> Rule:
|
|
29
|
+
"""The opening line when given a heading, the closing line when not."""
|
|
30
|
+
if heading is None:
|
|
31
|
+
return Rule(characters=RULE, style="pcode.muted")
|
|
32
|
+
# Left-aligned so the heading reads as a label on the line rather than as a
|
|
33
|
+
# centered banner; Rich fills the remainder of the row with the rule.
|
|
34
|
+
return Rule(
|
|
35
|
+
Text(heading, style="pcode.accent"),
|
|
36
|
+
characters=RULE,
|
|
37
|
+
style="pcode.muted",
|
|
38
|
+
align="left",
|
|
39
|
+
)
|
pcode/browser.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""The conversation's browser: one real Chrome the bundled `browser` extension drives.
|
|
2
|
+
|
|
3
|
+
Lives outside the extension because `/reload` re-imports extension modules,
|
|
4
|
+
and the browser (with whatever the user logged in to) has to outlive that.
|
|
5
|
+
Never persisted: a fresh pcode starts with the browser off, and the cookies of
|
|
6
|
+
a session that was logged in leave with the process.
|
|
7
|
+
|
|
8
|
+
Two decisions differ from Harness's own `PlaywrightBrowser` capability:
|
|
9
|
+
|
|
10
|
+
- The session is owned here, not by the capability. That capability opens a
|
|
11
|
+
browser per agent run and closes it when the run ends; pcode runs one agent
|
|
12
|
+
run per turn, so under it the user would log in and lose the session at the
|
|
13
|
+
end of the turn. One `PlaywrightBrowserSession` is handed to every run,
|
|
14
|
+
parent or sub-agent, until `/browser off` or exit.
|
|
15
|
+
- The browser is the user's installed Chrome, attached over CDP, not
|
|
16
|
+
Playwright's Chromium. Playwright launches its build with `--enable-automation`
|
|
17
|
+
(`navigator.webdriver` is true) and no Google API keys, and Google refuses to
|
|
18
|
+
sign in to that browser ("controlled through software automation"). Chrome
|
|
19
|
+
started by us with only a debugging port and its own profile carries neither
|
|
20
|
+
mark. When no Chrome is installed the session falls back to Playwright's
|
|
21
|
+
Chromium, which works everywhere except such sign-in pages.
|
|
22
|
+
- The page opens in the browser's own default context rather than a fresh one
|
|
23
|
+
(`SharedContextSession`). Harness isolates every run in a new context so a
|
|
24
|
+
run never inherits a login; here inheriting is the point. On the Chrome we
|
|
25
|
+
launch, the default context is the pcode profile on disk, so a login made
|
|
26
|
+
once holds across pcode sessions. `/browser attach` (or
|
|
27
|
+
`PCODE_BROWSER_CDP_URL`) joins the user's everyday Chrome instead, where the
|
|
28
|
+
default context holds every account they are signed in to; that is the
|
|
29
|
+
higher-risk choice and is never the default.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import os
|
|
36
|
+
import shutil
|
|
37
|
+
import socket
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
# A coding agent's usual target is its own dev server on localhost, which the
|
|
44
|
+
# Harness default (an SSRF guard for untrusted deployments) would refuse.
|
|
45
|
+
BLOCK_PRIVATE_ADDRESSES = False
|
|
46
|
+
# Every action returns page text; below the Harness default because a browsing
|
|
47
|
+
# turn is many actions and each lands in the conversation.
|
|
48
|
+
MAX_CONTENT_TOKENS = 2500
|
|
49
|
+
CHROME_START_SECONDS = 20
|
|
50
|
+
CHROME_CANDIDATES = (
|
|
51
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
52
|
+
"google-chrome",
|
|
53
|
+
"google-chrome-stable",
|
|
54
|
+
"chromium",
|
|
55
|
+
"chromium-browser",
|
|
56
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def chrome_executable() -> str | None:
|
|
61
|
+
"""The Chrome to launch: `PCODE_BROWSER_CHROME`, else the first installed candidate."""
|
|
62
|
+
configured = os.environ.get("PCODE_BROWSER_CHROME", "").strip()
|
|
63
|
+
candidates = (configured,) if configured else CHROME_CANDIDATES
|
|
64
|
+
for candidate in candidates:
|
|
65
|
+
if "/" in candidate:
|
|
66
|
+
if Path(candidate).is_file():
|
|
67
|
+
return candidate
|
|
68
|
+
elif shutil.which(candidate):
|
|
69
|
+
return candidate
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def profile_dir() -> Path:
|
|
74
|
+
"""Chrome's profile for the agent, apart from the user's own so no login leaks in."""
|
|
75
|
+
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
76
|
+
return state / "pcode" / "chrome"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _chrome_profile_dirs() -> list[Path]:
|
|
80
|
+
"""Where Chrome, Chromium, and Edge keep their debugging port files."""
|
|
81
|
+
home = Path.home()
|
|
82
|
+
return [
|
|
83
|
+
home / "Library" / "Application Support" / "Google" / "Chrome",
|
|
84
|
+
home / "Library" / "Application Support" / "Chromium",
|
|
85
|
+
home / ".config" / "google-chrome",
|
|
86
|
+
home / ".config" / "chromium",
|
|
87
|
+
home / "Library" / "Application Support" / "Microsoft Edge",
|
|
88
|
+
home / ".config" / "microsoft-edge",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def running_chrome_url() -> str | None:
|
|
93
|
+
"""The CDP endpoint of a user's Chromium-based browser with remote debugging on.
|
|
94
|
+
|
|
95
|
+
`PCODE_BROWSER_CDP_URL` wins. Otherwise Chrome writes `DevToolsActivePort`
|
|
96
|
+
(port, then the browser websocket path) into its profile when started with
|
|
97
|
+
`--remote-debugging-port` or when `chrome://inspect/#remote-debugging` is
|
|
98
|
+
toggled on; `PCODE_BROWSER_PORT_FILE` names that file elsewhere.
|
|
99
|
+
"""
|
|
100
|
+
configured = os.environ.get("PCODE_BROWSER_CDP_URL", "").strip()
|
|
101
|
+
if configured:
|
|
102
|
+
return configured
|
|
103
|
+
named = os.environ.get("PCODE_BROWSER_PORT_FILE", "").strip()
|
|
104
|
+
files = [Path(named)] if named else [d / "DevToolsActivePort" for d in _chrome_profile_dirs()]
|
|
105
|
+
for file in files:
|
|
106
|
+
try:
|
|
107
|
+
port, path = file.read_text().split()[:2]
|
|
108
|
+
except (OSError, ValueError):
|
|
109
|
+
continue
|
|
110
|
+
return f"ws://127.0.0.1:{int(port)}{path}"
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
REMOTE_DEBUGGING_PAGE = "chrome://inspect/#remote-debugging"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def show_remote_debugging_toggle() -> bool:
|
|
118
|
+
"""Open Chrome's remote-debugging switch in the user's own Chrome; False if none is installed.
|
|
119
|
+
|
|
120
|
+
Chrome forwards a URL given to a second invocation to the running instance,
|
|
121
|
+
or starts one on the default profile, so this lands in the everyday browser
|
|
122
|
+
either way. On macOS that goes through `open -a`: a cold start from the
|
|
123
|
+
binary itself drops a `chrome://` URL on the floor. Detached: the process
|
|
124
|
+
is not ours to wait on.
|
|
125
|
+
"""
|
|
126
|
+
executable = chrome_executable()
|
|
127
|
+
if executable is None:
|
|
128
|
+
return False
|
|
129
|
+
command = [executable, REMOTE_DEBUGGING_PAGE]
|
|
130
|
+
if sys.platform == "darwin" and ".app/Contents/MacOS/" in executable:
|
|
131
|
+
bundle = Path(executable)
|
|
132
|
+
while bundle.suffix != ".app":
|
|
133
|
+
bundle = bundle.parent
|
|
134
|
+
command = ["open", "-a", str(bundle), REMOTE_DEBUGGING_PAGE]
|
|
135
|
+
_detach(command)
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _detach(command: list[str]) -> None:
|
|
140
|
+
subprocess.Popen(
|
|
141
|
+
command,
|
|
142
|
+
stdin=subprocess.DEVNULL,
|
|
143
|
+
stdout=subprocess.DEVNULL,
|
|
144
|
+
stderr=subprocess.DEVNULL,
|
|
145
|
+
start_new_session=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _free_port() -> int:
|
|
150
|
+
with socket.socket() as probe:
|
|
151
|
+
probe.bind(("127.0.0.1", 0))
|
|
152
|
+
return probe.getsockname()[1]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _session_class():
|
|
156
|
+
from pydantic_ai_harness.playwright import PlaywrightBrowserSession
|
|
157
|
+
|
|
158
|
+
class SharedContextSession(PlaywrightBrowserSession):
|
|
159
|
+
"""A session whose page lives in the browser's default context.
|
|
160
|
+
|
|
161
|
+
Overrides the private `_launch` of the pinned Harness revision: the
|
|
162
|
+
only difference is `browser.contexts[0]` in place of `new_context()`
|
|
163
|
+
when the browser has one, so the page shares that context's cookies.
|
|
164
|
+
The guards Harness installs (route, websocket, page wiring) are kept.
|
|
165
|
+
A browser reached over CDP is left running by Harness's own teardown,
|
|
166
|
+
which only disconnects; the page opened here is closed first so no
|
|
167
|
+
stray tab is left in the user's window.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
async def _launch(self) -> None:
|
|
171
|
+
assert self._driver_cm is not None
|
|
172
|
+
if self._driver is None:
|
|
173
|
+
self._driver = await self._driver_cm.__aenter__()
|
|
174
|
+
self._driver_entered = True
|
|
175
|
+
if self._browser is not None:
|
|
176
|
+
stale = self._browser
|
|
177
|
+
await self._bounded(stale.close())
|
|
178
|
+
self._browser = None
|
|
179
|
+
browser = await self._connect(self._driver)
|
|
180
|
+
if browser is None:
|
|
181
|
+
return
|
|
182
|
+
self._browser = browser
|
|
183
|
+
if browser.contexts:
|
|
184
|
+
context = browser.contexts[0]
|
|
185
|
+
else: # pragma: no cover - a launched Chromium has none until asked
|
|
186
|
+
context = await self._bounded(
|
|
187
|
+
browser.new_context(service_workers="block", accept_downloads=False)
|
|
188
|
+
)
|
|
189
|
+
self._context = context
|
|
190
|
+
page = await self._bounded(context.new_page())
|
|
191
|
+
if self.policy.enforced():
|
|
192
|
+
await self._bounded(context.route("**/*", self._route_guard))
|
|
193
|
+
await self._bounded(context.route_web_socket("**/*", self._websocket_guard))
|
|
194
|
+
self._wire_page(page)
|
|
195
|
+
self.pages.append(page)
|
|
196
|
+
self.page = page
|
|
197
|
+
|
|
198
|
+
async def list_tabs(self) -> list[tuple[str, str]]:
|
|
199
|
+
"""Read browser targets over CDP, without evaluating possibly hung pages.
|
|
200
|
+
|
|
201
|
+
A browser's debugging switch can expose only a WebSocket, with no
|
|
202
|
+
HTTP `/json/list` endpoint. Reuse Playwright's connection instead.
|
|
203
|
+
"""
|
|
204
|
+
await self.ensure_page()
|
|
205
|
+
assert self._browser is not None
|
|
206
|
+
cdp = await self._bounded(self._browser.new_browser_cdp_session())
|
|
207
|
+
try:
|
|
208
|
+
result = await self._bounded(cdp.send("Target.getTargets"))
|
|
209
|
+
finally:
|
|
210
|
+
await self._bounded(cdp.detach())
|
|
211
|
+
return [
|
|
212
|
+
(target.get("title", ""), target.get("url", ""))
|
|
213
|
+
for target in result["targetInfos"]
|
|
214
|
+
if target.get("type") == "page"
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
async def __aexit__(self, exc_type, *rest) -> None:
|
|
218
|
+
for page in list(self.pages):
|
|
219
|
+
try:
|
|
220
|
+
await page.close()
|
|
221
|
+
except Exception: # noqa: BLE001 - the tab may already be gone.
|
|
222
|
+
pass
|
|
223
|
+
await super().__aexit__(exc_type, *rest)
|
|
224
|
+
|
|
225
|
+
return SharedContextSession
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class BrowserState:
|
|
229
|
+
"""Whether the browser tools are offered, and the live session behind them."""
|
|
230
|
+
|
|
231
|
+
def __init__(self) -> None:
|
|
232
|
+
self.enabled = False
|
|
233
|
+
self.attach = False
|
|
234
|
+
self.session: Any = None
|
|
235
|
+
self.toolset: Any = None
|
|
236
|
+
self.cdp_url: str | None = None
|
|
237
|
+
self.process: subprocess.Popen | None = None
|
|
238
|
+
self._armed = False
|
|
239
|
+
self._launch_lock: asyncio.Lock | None = None
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def attached(self) -> bool:
|
|
243
|
+
"""Joined a Chrome someone else started, which is theirs to close."""
|
|
244
|
+
return self.attach
|
|
245
|
+
|
|
246
|
+
def open(self):
|
|
247
|
+
"""Build (not launch) the session and toolset; idempotent.
|
|
248
|
+
|
|
249
|
+
Raises `ValueError` in attach mode when no running Chrome is found.
|
|
250
|
+
"""
|
|
251
|
+
if self.session is None:
|
|
252
|
+
from pydantic_ai_harness.playwright import EgressPolicy, PlaywrightBrowserToolset
|
|
253
|
+
|
|
254
|
+
if self.attach:
|
|
255
|
+
self.cdp_url = running_chrome_url()
|
|
256
|
+
if self.cdp_url is None:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
"No running Chrome, Chromium, or Edge with remote debugging found. "
|
|
259
|
+
"Enable remote debugging in your browser "
|
|
260
|
+
"(chrome://inspect/#remote-debugging in Chrome), "
|
|
261
|
+
"or set PCODE_BROWSER_CDP_URL."
|
|
262
|
+
)
|
|
263
|
+
elif chrome_executable():
|
|
264
|
+
# The port is chosen now so the session can be built before the
|
|
265
|
+
# browser exists; Chrome starts on the first tool call.
|
|
266
|
+
self.cdp_url = f"http://127.0.0.1:{_free_port()}"
|
|
267
|
+
else:
|
|
268
|
+
self.cdp_url = None
|
|
269
|
+
self.session = _session_class()(
|
|
270
|
+
policy=EgressPolicy(block_private_addresses=BLOCK_PRIVATE_ADDRESSES),
|
|
271
|
+
headless=False,
|
|
272
|
+
cdp_url=self.cdp_url,
|
|
273
|
+
auto_install_chromium=True,
|
|
274
|
+
)
|
|
275
|
+
self.toolset = PlaywrightBrowserToolset(
|
|
276
|
+
session=self.session, max_content_tokens=MAX_CONTENT_TOKENS
|
|
277
|
+
)
|
|
278
|
+
return self.toolset
|
|
279
|
+
|
|
280
|
+
async def arm(self) -> None:
|
|
281
|
+
"""Enter the session once, on the loop the tools run on.
|
|
282
|
+
|
|
283
|
+
Entering is cheap and launches nothing. Entering twice would discard the
|
|
284
|
+
driver handle, so it is guarded.
|
|
285
|
+
"""
|
|
286
|
+
if self.session is not None and not self._armed:
|
|
287
|
+
await self.session.__aenter__()
|
|
288
|
+
self._armed = True
|
|
289
|
+
|
|
290
|
+
async def ensure_chrome(self) -> str:
|
|
291
|
+
"""Start Chrome on the debugging port unless one is already answering there.
|
|
292
|
+
|
|
293
|
+
Returns a line describing what happened, for a notice. Nothing to do
|
|
294
|
+
when attaching to a foreign endpoint or falling back to Chromium.
|
|
295
|
+
"""
|
|
296
|
+
if self.cdp_url is None or self.attached:
|
|
297
|
+
return ""
|
|
298
|
+
if self._launch_lock is None:
|
|
299
|
+
self._launch_lock = asyncio.Lock()
|
|
300
|
+
async with self._launch_lock:
|
|
301
|
+
if self.process is not None and self.process.poll() is None:
|
|
302
|
+
return ""
|
|
303
|
+
if await self._answering():
|
|
304
|
+
return ""
|
|
305
|
+
executable = chrome_executable()
|
|
306
|
+
if executable is None: # pragma: no cover - open() would have chosen Chromium
|
|
307
|
+
return ""
|
|
308
|
+
port = self.cdp_url.rsplit(":", 1)[1]
|
|
309
|
+
profile = profile_dir()
|
|
310
|
+
profile.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
self.process = subprocess.Popen(
|
|
312
|
+
[
|
|
313
|
+
executable,
|
|
314
|
+
f"--remote-debugging-port={port}",
|
|
315
|
+
f"--user-data-dir={profile}",
|
|
316
|
+
"--no-first-run",
|
|
317
|
+
"--no-default-browser-check",
|
|
318
|
+
"about:blank",
|
|
319
|
+
],
|
|
320
|
+
stdin=subprocess.DEVNULL,
|
|
321
|
+
stdout=subprocess.DEVNULL,
|
|
322
|
+
stderr=subprocess.DEVNULL,
|
|
323
|
+
start_new_session=True,
|
|
324
|
+
)
|
|
325
|
+
deadline = asyncio.get_running_loop().time() + CHROME_START_SECONDS
|
|
326
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
327
|
+
if self.process.poll() is not None:
|
|
328
|
+
raise RuntimeError(
|
|
329
|
+
f"Chrome exited at startup (code {self.process.returncode})."
|
|
330
|
+
)
|
|
331
|
+
if await self._answering():
|
|
332
|
+
return f"Started Chrome ({executable}) with profile {profile}."
|
|
333
|
+
await asyncio.sleep(0.2)
|
|
334
|
+
raise RuntimeError(
|
|
335
|
+
f"Chrome did not open its debugging port within {CHROME_START_SECONDS}s."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
async def _answering(self) -> bool:
|
|
339
|
+
assert self.cdp_url is not None
|
|
340
|
+
import httpx
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
async with httpx.AsyncClient(timeout=1.0) as client:
|
|
344
|
+
response = await client.get(self.cdp_url.rstrip("/") + "/json/version")
|
|
345
|
+
return response.status_code == 200
|
|
346
|
+
except httpx.HTTPError:
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
async def close(self) -> None:
|
|
350
|
+
"""Disconnect, then quit the Chrome we started; safe when nothing was."""
|
|
351
|
+
session, self.session, self.toolset = self.session, None, None
|
|
352
|
+
armed, self._armed = self._armed, False
|
|
353
|
+
process, self.process = self.process, None
|
|
354
|
+
self.cdp_url = None
|
|
355
|
+
self.attach = False
|
|
356
|
+
try:
|
|
357
|
+
if session is not None and armed:
|
|
358
|
+
await session.__aexit__(None, None, None)
|
|
359
|
+
finally:
|
|
360
|
+
if process is not None and process.poll() is None:
|
|
361
|
+
process.terminate()
|
|
362
|
+
try:
|
|
363
|
+
await asyncio.to_thread(process.wait, 5)
|
|
364
|
+
except subprocess.TimeoutExpired:
|
|
365
|
+
process.kill()
|
|
366
|
+
|
|
367
|
+
async def list_tabs(self) -> list[tuple[str, str]]:
|
|
368
|
+
"""Every tab as (title, url), the user's included, without evaluating pages."""
|
|
369
|
+
if self.session is None:
|
|
370
|
+
return []
|
|
371
|
+
return await self.session.list_tabs()
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def launched(self) -> bool:
|
|
375
|
+
return self.session is not None and self.session.page is not None
|
|
376
|
+
|
|
377
|
+
def describe(self) -> str:
|
|
378
|
+
"""One line for `/browser status`."""
|
|
379
|
+
if not self.enabled:
|
|
380
|
+
return "off"
|
|
381
|
+
if self.cdp_url is None:
|
|
382
|
+
how = "Playwright's Chromium (no Chrome found; Google sign-in will refuse it)"
|
|
383
|
+
elif self.attached:
|
|
384
|
+
how = f"attached to your browser at {self.cdp_url}"
|
|
385
|
+
else:
|
|
386
|
+
how = f"own Chrome on {self.cdp_url}, profile {profile_dir()}" + (
|
|
387
|
+
"" if self.process is not None and self.process.poll() is None else " (not started)"
|
|
388
|
+
)
|
|
389
|
+
page = self.session.page if self.launched else None
|
|
390
|
+
return f"on, {how}" + (f", at {page.url}" if page is not None else "")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
STATE = BrowserState()
|