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.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. 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()