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/extension_guide.md
ADDED
|
@@ -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)
|