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
@@ -0,0 +1,311 @@
1
+ # Writing a pcode extension
2
+
3
+ An extension is one Python file that defines `setup(pcode)`. pcode imports it
4
+ when the agent starts and again on `/reload`, calls `setup`, and wires whatever
5
+ it registered into the agent and the terminal.
6
+
7
+ ## Where to put it
8
+
9
+ | Location | Scope | Loads |
10
+ | --- | --- | --- |
11
+ | `~/.config/pcode/extensions/<name>.py` (or `$XDG_CONFIG_HOME/pcode/extensions/`) | user, every workspace | always |
12
+ | `<workspace>/.pcode/extensions/<name>.py` | this project | only once the user trusts the repository (launch prompt, or `project_extensions on`) |
13
+ | `/config set extension_dirs DIR:DIR` | extra directories | always |
14
+ | `src/pcode/extensions/` (shipped with pcode) | bundled defaults | always |
15
+
16
+ A directory with `__init__.py` works too, for multi-file extensions. Names
17
+ starting with `_` or `.` are skipped. When two directories hold the same name,
18
+ the project one wins, then user, then configured, then bundled. That is how a
19
+ bundled default is replaced: a user file named `web_research.py` overrides the
20
+ shipped web search and fetch tools, and one whose `setup` does nothing removes
21
+ them. The bundled files are ordinary extensions and worth reading as examples.
22
+
23
+ After writing or changing an extension, run `/reload` (or tell the user to). It
24
+ rebuilds the agent around the current conversation and prints each extension's
25
+ status; a broken one is reported with its error and line and contributes
26
+ nothing, and the session keeps working. `/extensions` lists the current state.
27
+
28
+ ## Turning one on and off
29
+
30
+ `/extensions` lists every extension found, where it came from, and what it
31
+ contributed (or why it did not). `/extensions off NAME` stops it loading at all
32
+ and `/extensions on NAME` brings it back; either reloads the session and is
33
+ remembered in the `extensions_off` / `extensions_on` preferences. This works for
34
+ bundled extensions too, so `/extensions off web_research` removes the shipped web
35
+ tools without writing a replacement file.
36
+
37
+ An extension that should ship opt-in says so at module level:
38
+
39
+ ```python
40
+ DEFAULT_ENABLED = False # Discovered and listed, but loads only after /extensions on <name>
41
+ ```
42
+
43
+ pcode still imports the module to read that flag, so keep import-time work
44
+ cheap; `setup` does not run until the user turns it on.
45
+
46
+ ## The API
47
+
48
+ ```python
49
+ from pcode.ext import ExtensionAPI
50
+
51
+
52
+ def setup(pcode: ExtensionAPI) -> None: ...
53
+ ```
54
+
55
+ `pcode.name` is the file stem, `pcode.workspace` the resolved workspace path.
56
+ `pcode.session_dir` is the resolved session-storage root, honoring `--session-dir`,
57
+ `PCODE_SESSION_DIR`, and the default state directory.
58
+
59
+ ### Tools the model can call
60
+
61
+ ```python
62
+ @pcode.tool
63
+ def ticket_status(ticket_id: str) -> str:
64
+ """Look up a ticket's title and status."""
65
+ return subprocess.run(["tk", "show", ticket_id], capture_output=True, text=True).stdout
66
+ ```
67
+
68
+ The docstring is the description the model sees; the type hints become the
69
+ schema (this is Pydantic AI's `Agent.tool_plain`). `async def` works. Raise
70
+ `pydantic_ai.ModelRetry("...")` to send the model a correction instead of a
71
+ result.
72
+
73
+ ### Extra instructions
74
+
75
+ ```python
76
+ pcode.instructions("Always run `make lint` before declaring a change finished.")
77
+ ```
78
+
79
+ Keep this text fixed for the life of the session. It sits in the cached prompt
80
+ prefix, so anything that changes between requests (time, git status) breaks
81
+ caching on every turn. Read changing state inside a tool or hook instead.
82
+
83
+ ### Hooks on the agent lifecycle
84
+
85
+ `pcode.hooks.on.<name>` are Pydantic AI's `Hooks` decorators. The ones worth
86
+ knowing:
87
+
88
+ ```python
89
+ from pydantic_ai import ModelRetry
90
+
91
+
92
+ @pcode.hooks.on.before_tool_execute
93
+ async def guard(ctx, *, call, tool_def, args):
94
+ """Runs before every tool call; return args (possibly modified) or raise to block."""
95
+ if tool_def.name == "shell" and "rm -rf" in str(args.get("command", "")):
96
+ pcode.ui.notify(f"Blocked: {args['command']}", "warning")
97
+ raise ModelRetry("Destructive command blocked by the rm-guard extension.")
98
+ return args
99
+
100
+
101
+ @pcode.hooks.on.after_tool_execute
102
+ async def log(ctx, *, call, tool_def, args, result):
103
+ """Runs after; return the result (possibly modified)."""
104
+ return result
105
+
106
+
107
+ @pcode.hooks.on.before_model_request
108
+ async def before(ctx, request_context):
109
+ return request_context
110
+
111
+
112
+ @pcode.hooks.on.after_run
113
+ async def done(ctx, *, result):
114
+ return result
115
+ ```
116
+
117
+ Other hook names: `before_run`, `before_model_request`, `after_model_request`,
118
+ `before_tool_validate`, `after_tool_validate`, `tool_execute` (wrap: receives
119
+ `handler` and must call it), `tool_execute_error`, `event` (every stream
120
+ event), `prepare_tools` (filter or rewrite the tool definitions the model
121
+ sees). Each must return what it was given unless it means to change it.
122
+
123
+ Tool names in this agent: `read_file`, `write_file`, `edit_file`, `list_files`,
124
+ `grep`, `shell`, `write_plan`, `delegate_task`, plus any MCP or extension tools.
125
+
126
+ ### Slash commands
127
+
128
+ ```python
129
+ def standup(argument: str) -> None:
130
+ log = subprocess.run(
131
+ ["git", "log", "--since=yesterday", "--oneline"], capture_output=True, text=True
132
+ )
133
+ pcode.ui.notify(log.stdout or "No commits since yesterday.")
134
+
135
+
136
+ pcode.register_command("/standup", "Summarize commits since yesterday", standup)
137
+ pcode.register_command(
138
+ "/mode",
139
+ "Set a mode",
140
+ set_mode,
141
+ arguments=("fast", "careful"),
142
+ argument_descriptions={"fast": "Skip verification", "careful": "Verify every step"},
143
+ )
144
+ ```
145
+
146
+ Pass `complete_paths=True` to Tab-complete the argument (after any leading
147
+ `--option` words) as a file or directory path.
148
+
149
+ Handlers run on the terminal's event loop, so keep them quick. Raise
150
+ `ValueError("message")` to show a usage error. A name pcode already uses is
151
+ reported and skipped. Commands cannot send a prompt to the model; to do that,
152
+ write a skill (`SKILL.md`) instead.
153
+
154
+ ### Sub-agents
155
+
156
+ ```python
157
+ from pydantic_ai import Agent
158
+
159
+ reviewer = Agent(name="reviewer", description="Review a diff for bugs", instructions="...")
160
+ pcode.subagent(reviewer, timeout_seconds=600)
161
+ ```
162
+
163
+ The agent is listed beside the worker under `delegate_task`. Leave its model
164
+ unset to run on the session's model; keyword options are Harness `SubAgent`
165
+ fields (`usage_limits`, `timeout_seconds`, `max_calls`). Give it capabilities
166
+ of its own; the parent's tools are not inherited.
167
+
168
+ Without an explicit `usage_limits` the delegate gets its own uncapped request
169
+ counter, so a long delegation cannot exhaust the turn, and it has no timeout.
170
+ Pass `usage_limits=UsageLimits(request_limit=N)` or `timeout_seconds` to bound
171
+ it; either returns a steering message and discards the delegate's result.
172
+ Overriding it with `None` shares the parent's counter, where the library's
173
+ 50-request default aborts the run.
174
+
175
+ The built-in worker shares the parent's workspace by default. Isolated delegation
176
+ requires both `worktree=on` and `worker_isolation=on` in the active workspace's
177
+ effective preferences; `worker_isolation` defaults to `off`. With both on, default
178
+ delegation creates an isolated checkout, while explicit `shared` remains available.
179
+ Specialized delegates registered here remain shared and cannot request isolated
180
+ mode because pcode cannot reconstruct their tools.
181
+
182
+ For isolated workers, pcode re-runs each successfully loaded extension's `setup`
183
+ with the child's `pcode.workspace` and `pcode.is_worker=True` (false for the
184
+ parent). Treat setup as per-agent initialization: do not assume it runs only once
185
+ per terminal, and register child-owned resources with `on_close`. If an extension
186
+ borrows a process-global resource, only its parent setup should register that
187
+ resource's closer (`if not pcode.is_worker`). The bundled browser follows this
188
+ rule: workers may use the session browser but must not close it on return.
189
+ Setup, tool calls, and close hooks all run with the child's shell-job registry.
190
+ The child retains the parent's effective preferences and session storage root. Only child resources are closed afterward; child slash commands
191
+ and reload requests are not installed in the parent's UI. A failed child setup
192
+ fails that delegation rather than silently dropping tools or guardrails.
193
+
194
+ Programmatic callers should pass `load_extensions(...).capabilities` unchanged
195
+ when creating the agent. This list-compatible collection preserves the factories
196
+ needed to bind extensions to another workspace; flattening it to a plain list
197
+ loses that provenance, and isolated delegation then refuses the unsafe rebind.
198
+
199
+ To give a delegate a task plan that also shows in the Tasks widget beneath its
200
+ `delegate_task` row, use pcode's planning capability rather than Harness's:
201
+
202
+ ```python
203
+ from pcode.planning import IdentifiedPlanning
204
+
205
+ reviewer = Agent(name="reviewer", description="...", capabilities=[IdentifiedPlanning()])
206
+ ```
207
+
208
+ It is Harness `Planning` with guidance that tells the model the user watches
209
+ the plan, plus an announcement of the plan after each planning call; the plan
210
+ itself stays private to the delegate's run. A plain Harness `Planning()` works
211
+ for the delegate but never reaches the widget.
212
+
213
+ ### Notices and lifecycle
214
+
215
+ `pcode.ui.notify(text, level="info" | "warning" | "error")` prints a transient
216
+ line in the transcript. Safe to call from tools, hooks, and commands.
217
+
218
+ `pcode.ui.request_reload()` asks for `/reload` once the terminal is idle, for a
219
+ command that changes what `setup` contributes (see the bundled `browser.py`,
220
+ whose `/browser launch` adds tools). Mid-turn it waits for the turn to end, so
221
+ the running turn keeps the tools it started with. It raises `ValueError` without
222
+ a live model session, so call it before changing state. State that must survive the reload cannot live in the
223
+ extension module, which is re-imported.
224
+
225
+ `@pcode.on_close` registers an `async` function run when the terminal exits,
226
+ for a process or connection a tool started.
227
+
228
+ ### Anything else
229
+
230
+ ```python
231
+ pcode.add_capability(SomeCapability(...))
232
+ ```
233
+
234
+ Any Pydantic AI `AbstractCapability` subclass, or a Harness capability, can be
235
+ added directly. This is the full power of the system; the helpers above are
236
+ shortcuts to it. The capability gets `id="ext.<name>"` if it has none, so
237
+ `/status` can attribute its prompt cost.
238
+
239
+ ## Rules of thumb
240
+
241
+ - Do import work inside functions when it is slow or optional; `setup` runs on
242
+ every start and reload.
243
+ - Do not start background processes or threads in `setup`; start them lazily
244
+ from the tool or command that needs them.
245
+ - Extensions run with the user's full permissions, in-process. There is no
246
+ sandbox.
247
+ - Standard library and everything pcode depends on (`pydantic_ai`,
248
+ `pydantic_ai_harness`, `rich`, `httpx`) are importable. Other packages are
249
+ not unless installed into pcode's environment.
250
+ - Test the file imports cleanly before `/reload`: `python -c "import runpy;
251
+ runpy.run_path('path/to/ext.py')"` catches syntax errors early.
252
+
253
+ ## Pydantic AI reference
254
+
255
+ The helpers above wrap Pydantic AI directly, so for anything this guide only
256
+ names (hook signatures, `RunContext`, `ModelRetry`, capability classes, building
257
+ an `Agent` for a sub-agent) read Pydantic AI's docs rather than working from
258
+ memory. Read them at the installed version, not `main`; the website tracks
259
+ unreleased APIs:
260
+
261
+ ```sh
262
+ python -c "from importlib.metadata import version; print(version('pydantic-ai-slim'))"
263
+ ```
264
+
265
+ With that version as `vX.Y.Z`, fetch
266
+ `https://raw.githubusercontent.com/pydantic/pydantic-ai/vX.Y.Z/docs/<topic>.md`
267
+ for `hooks`, `tools`, `tools-advanced`, `toolsets`, or `agent`, and
268
+ `docs/capabilities/overview.md` or `docs/capabilities/custom.md` for
269
+ capabilities (there is no `capabilities.md`). The installed source is beside
270
+ this file's package (`import pydantic_ai; pydantic_ai.__file__`) and wins when
271
+ a doc disagrees. Harness capabilities (`pydantic_ai_harness`) live in the same
272
+ repository and release: their docs are under `docs/harness/<name>.md` at the
273
+ same `vX.Y.Z` tag. Every Harness capability reads and writes through the run's
274
+ workspace (`ctx.workspace`); pcode attaches a `LocalWorkspace` on the session's
275
+ directory.
276
+
277
+ ## Complete example
278
+
279
+ `~/.config/pcode/extensions/protect_env.py`:
280
+
281
+ ```python
282
+ """Refuse to edit secret files and offer a /secrets command to list them."""
283
+
284
+ from fnmatch import fnmatch
285
+
286
+ from pydantic_ai import ModelRetry
287
+
288
+ PROTECTED = (".env", ".env.*", "*.pem", "*.tfvars")
289
+ EDIT_TOOLS = {"write_file", "edit_file"}
290
+
291
+
292
+ def setup(pcode):
293
+ def protected(path: str) -> bool:
294
+ name = path.rsplit("/", 1)[-1]
295
+ return any(fnmatch(name, pattern) for pattern in PROTECTED)
296
+
297
+ @pcode.hooks.on.before_tool_execute
298
+ async def guard(ctx, *, call, tool_def, args):
299
+ if tool_def.name in EDIT_TOOLS and protected(str(args.get("path", ""))):
300
+ pcode.ui.notify(f"Refused to edit {args['path']}", "warning")
301
+ raise ModelRetry(f"{args['path']} holds secrets; ask the user to edit it.")
302
+ return args
303
+
304
+ pcode.instructions("Never write to .env, key, or tfvars files; ask the user instead.")
305
+
306
+ pcode.register_command(
307
+ "/secrets",
308
+ "Show which file patterns are write-protected",
309
+ lambda _: pcode.ui.notify("Protected: " + ", ".join(PROTECTED)),
310
+ )
311
+ ```
@@ -0,0 +1,284 @@
1
+ """A real Chrome the model can drive, with the user logging in by hand.
2
+
3
+ Off by default and per conversation: `/browser launch` or `/browser attach`
4
+ adds Harness's eighteen Playwright tools plus `browser_open` and
5
+ `browser_tabs`, and a `browser` sub-agent that runs multi-step flows without
6
+ the page text landing in the parent's context. `/browser off` closes it and
7
+ removes the tools.
8
+
9
+ The browser is the user's own Chrome, started with a debugging port and a
10
+ profile of its own that keeps logins between pcode sessions; `/browser attach`
11
+ joins the Chrome the user already has open instead (see `pcode.browser`).
12
+ The window is visible on purpose: a sign-in page is left to the user, who logs
13
+ in there and says so in the next message, and the user sees what the model
14
+ does with that session afterwards. There is no sandbox around it beyond the address bar: a
15
+ page the model reads can tell it to do things with the user's login, which is
16
+ the trade-off of turning this on.
17
+
18
+ The state itself lives in `pcode.browser`, since `/reload` re-imports this file.
19
+ Copy this file to `~/.config/pcode/extensions/browser.py` to change defaults;
20
+ an empty `setup` removes the feature.
21
+ """
22
+
23
+ import asyncio
24
+
25
+ from pcode.browser import REMOTE_DEBUGGING_PAGE, STATE, show_remote_debugging_toggle
26
+
27
+ DELEGATE_INSTRUCTIONS = (
28
+ "A `browser` sub-agent shares the same browser window and login. Delegate to it "
29
+ "when a browsing task takes several steps and you only need the outcome (find a "
30
+ "value, verify a flow works, reproduce a bug), so page text stays out of this "
31
+ "conversation. Drive the browser yourself for a single check or a screenshot, "
32
+ "and whenever a page needs the user to sign in."
33
+ )
34
+
35
+ LOGIN_INSTRUCTIONS = {
36
+ "attach": (
37
+ "This is the user's own Chrome: every site they are signed in to is already "
38
+ "signed in here, so just `navigate`."
39
+ ),
40
+ "own": (
41
+ "The browser profile persists between conversations, so a site the user logged "
42
+ "in to before is still logged in; just `navigate`."
43
+ ),
44
+ }
45
+
46
+ SIGN_IN_INSTRUCTIONS = (
47
+ "When a page turns out to be a sign-in page, never type credentials. Call "
48
+ "`browser_open` so the window is in front, then end your turn asking the user to "
49
+ "log in there and tell you when they are done; continue from the same page when "
50
+ "they do."
51
+ )
52
+
53
+ SUBAGENT_INSTRUCTIONS = (
54
+ "You drive a real browser through the tools available to you and report back "
55
+ "concisely: what you found, what worked, what failed, and the final URL. Do not "
56
+ "narrate each step. If a page needs a login that is not present, stop and say so "
57
+ "rather than guessing credentials; the parent will ask the user."
58
+ )
59
+
60
+
61
+ def _spawn(coroutine) -> None:
62
+ """Run a coroutine on the terminal's loop, or inline when there is none (tests)."""
63
+ try:
64
+ asyncio.get_running_loop().create_task(coroutine)
65
+ except RuntimeError:
66
+ asyncio.run(coroutine)
67
+
68
+
69
+ async def _start(pcode) -> None:
70
+ """Have Chrome up and the session armed, reporting a launch to the user."""
71
+ await STATE.arm()
72
+ if note := await STATE.ensure_chrome():
73
+ pcode.ui.notify(note)
74
+
75
+
76
+ async def _replace_lost_page(session, page) -> bool:
77
+ """Give the session a live tab after the user closed `page` or the browser.
78
+
79
+ Harness keeps pointing at the last page it had once that page closes, so a
80
+ later call would fail on it forever. Returns False when `page` is still
81
+ open, so an unrelated failure is reported rather than abandoning a live tab.
82
+ """
83
+ from playwright.async_api import Error as PlaywrightError
84
+
85
+ browser = session._browser
86
+ connected = browser is not None and browser.is_connected()
87
+ if connected and not page.is_closed():
88
+ return False
89
+ if connected and session._context is not None:
90
+ try:
91
+ # Same context, so the login survives; the Chromium fallback would
92
+ # lose its cookies to a reconnect.
93
+ await session.open_tab()
94
+ return True
95
+ except PlaywrightError:
96
+ pass # The context went with the window; reconnect below.
97
+ # The connection is gone: forget its pages so the next navigate reconnects.
98
+ session.page = None
99
+ session.pages = []
100
+ return True
101
+
102
+
103
+ def _capability(pcode, toolset):
104
+ """The browser tools, the open and login tools, and their guidance, sharing one session."""
105
+ from playwright.async_api import Error as PlaywrightError
106
+ from pydantic_ai.capabilities import Capability
107
+ from pydantic_ai_harness.playwright import BrowserUnavailableError, PlaywrightBrowser
108
+
109
+ browser_tools = set(toolset.tools) | {"browser_open", "browser_tabs"}
110
+
111
+ async def browser_tabs() -> str:
112
+ """List every tab open in the browser, including the user's own, as title and URL.
113
+
114
+ The `tabs` tool covers only tabs this session opened; this one also shows
115
+ what the user already has open, which tells you which sites and
116
+ accounts they use (their mail, their issue tracker) so you can open the
117
+ right URL in your own tab. Read only: act through `navigate`.
118
+ """
119
+ await _start(pcode)
120
+ try:
121
+ tabs = await STATE.list_tabs()
122
+ except (PlaywrightError, BrowserUnavailableError):
123
+ STATE.session.launch_error = None # Allow another attempt after a failed launch.
124
+ return (
125
+ "Could not list browser tabs: the browser is unavailable or did not respond. "
126
+ "Check /browser status and remote debugging; if needed, use /browser off "
127
+ "then /browser attach to reconnect."
128
+ )
129
+ mine = {page.url for page in STATE.session.pages}
130
+ lines = []
131
+ for title, url in tabs:
132
+ ours = " (yours)" if url in mine else ""
133
+ lines.append(f"- {title or '(untitled)'}{ours}: {url}")
134
+ return "\n".join(lines) or "No tabs are open."
135
+
136
+ async def browser_open() -> str:
137
+ """Bring the browser window to the front, opening it first if needed.
138
+
139
+ The current page stays as it is. Use it to show the user a page that
140
+ needs them, such as a sign-in page, before asking them to act there.
141
+ """
142
+ await _start(pcode)
143
+ session = STATE.session
144
+ for _ in range(2):
145
+ if session.page is None:
146
+ result = await toolset.navigate("about:blank")
147
+ if session.page is None:
148
+ return f"The browser could not be opened: {result}"
149
+ page = session.page
150
+ try:
151
+ await page.bring_to_front()
152
+ except PlaywrightError as error:
153
+ if not await _replace_lost_page(session, page):
154
+ return f"Could not bring the browser to the front: {error}"
155
+ continue
156
+ return f"The browser window is in front, showing {page.url}."
157
+ return (
158
+ "Could not bring the browser to the front: its window keeps closing. Check "
159
+ "/browser status; if needed, use /browser off then /browser launch or attach."
160
+ )
161
+
162
+ class Browser(Capability):
163
+ async def before_tool_execute(self, ctx, *, call, tool_def, args):
164
+ # Chrome starts on the first browser tool, for the parent and the
165
+ # sub-agent alike, on the loop the tools run on.
166
+ if tool_def.name in browser_tools:
167
+ await _start(pcode)
168
+ return args
169
+
170
+ guidance = PlaywrightBrowser(
171
+ headless=False, block_private_addresses=False, max_content_tokens=2500
172
+ ).get_instructions()(None)
173
+ return Browser(
174
+ id="browser",
175
+ toolsets=[toolset],
176
+ tools=[browser_open, browser_tabs],
177
+ instructions="\n".join(
178
+ (
179
+ guidance,
180
+ LOGIN_INSTRUCTIONS["attach" if STATE.attach else "own"],
181
+ SIGN_IN_INSTRUCTIONS,
182
+ )
183
+ ),
184
+ )
185
+
186
+
187
+ def _subagent(pcode, toolset):
188
+ from pydantic_ai import Agent
189
+
190
+ return Agent(
191
+ name="browser",
192
+ description=(
193
+ "Drive the shared browser window through a multi-step task and report the "
194
+ "outcome; the login the user made is already there"
195
+ ),
196
+ instructions=SUBAGENT_INSTRUCTIONS,
197
+ capabilities=[_capability(pcode, toolset)],
198
+ )
199
+
200
+
201
+ def setup(pcode) -> None:
202
+ def turn_on() -> None:
203
+ pcode.ui.request_reload() # Waits out a running turn; refuses with no live session.
204
+ STATE.enabled = True
205
+ pcode.ui.notify(
206
+ "Browser tools on for this conversation. Pages the model reads can act on "
207
+ "whatever you log in to there."
208
+ )
209
+
210
+ def browser(argument: str) -> None:
211
+ argument = argument.strip() or "status"
212
+ if argument == "launch":
213
+ if STATE.session is not None:
214
+ raise ValueError("A browser is already open; /browser off first.")
215
+ turn_on()
216
+ STATE.open()
217
+ _spawn(_launch())
218
+ elif argument == "attach":
219
+ if STATE.session is not None:
220
+ raise ValueError("A browser is already open; /browser off first.")
221
+ STATE.attach = True
222
+ try:
223
+ STATE.open()
224
+ except ValueError:
225
+ STATE.attach = False
226
+ if show_remote_debugging_toggle():
227
+ pcode.ui.notify(
228
+ f"Opened {REMOTE_DEBUGGING_PAGE} in your Chrome. Turn the switch on, "
229
+ "then run /browser attach again."
230
+ )
231
+ return
232
+ raise
233
+ if not STATE.enabled:
234
+ try:
235
+ turn_on()
236
+ except ValueError:
237
+ # Refused (no live session): leave nothing half-open behind.
238
+ _spawn(STATE.close())
239
+ raise
240
+ pcode.ui.notify(
241
+ f"Joining your browser at {STATE.cdp_url}: the model can act as every account "
242
+ "you are signed in to there. A new tab opens on first use.",
243
+ "warning",
244
+ )
245
+ elif argument == "off":
246
+ if not STATE.enabled:
247
+ raise ValueError("The browser is already off.")
248
+ pcode.ui.request_reload()
249
+ # The reload's `setup` closes it: a running turn keeps the tools until then.
250
+ STATE.enabled = False
251
+ pcode.ui.notify("Browser off; it closes as its tools leave.")
252
+ else:
253
+ pcode.ui.notify(f"Browser {STATE.describe()}. /browser launch|attach|off.")
254
+
255
+ async def _launch() -> None:
256
+ try:
257
+ await _start(pcode)
258
+ await STATE.toolset.navigate("about:blank")
259
+ except Exception as error: # noqa: BLE001 - a failed launch is a notice, not a crash.
260
+ pcode.ui.notify(f"Browser launch failed: {error}", "error")
261
+
262
+ pcode.register_command(
263
+ "/browser",
264
+ "A real browser the model can drive; `launch` opens Chrome, `attach` joins yours",
265
+ browser,
266
+ arguments=("launch", "attach", "off", "status"),
267
+ argument_descriptions={
268
+ "launch": "Open pcode's own Chrome window, with its own logins",
269
+ "attach": "Join Chrome, Chromium, or Edge, logins included; needs remote debugging on",
270
+ "off": "Close the browser and remove the tools",
271
+ "status": "Show which browser is in use and where it is",
272
+ },
273
+ )
274
+ # Workers borrow the session browser, not ownership of its process/connection.
275
+ if not pcode.is_worker:
276
+ pcode.on_close(STATE.close)
277
+ if not STATE.enabled:
278
+ if not pcode.is_worker and STATE.session is not None:
279
+ _spawn(STATE.close()) # `/browser off`, now that no agent holds the tools.
280
+ return
281
+ toolset = STATE.open()
282
+ pcode.add_capability(_capability(pcode, toolset))
283
+ pcode.instructions(DELEGATE_INSTRUCTIONS)
284
+ pcode.subagent(_subagent(pcode, toolset), timeout_seconds=900)