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/ext.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""User extensions: Python files that add tools, hooks, instructions, and commands.
|
|
2
|
+
|
|
3
|
+
An extension is a module defining `setup(pcode)`, where `pcode` is an
|
|
4
|
+
`ExtensionAPI`. The model-facing surface (tools, instructions, lifecycle hooks)
|
|
5
|
+
is Pydantic AI's own capability system, so `add_capability` alone is complete;
|
|
6
|
+
the `tool`, `instructions`, and `hooks` helpers exist so common cases need no
|
|
7
|
+
knowledge of that class hierarchy. Commands and notices are pcode's.
|
|
8
|
+
|
|
9
|
+
Extensions run in-process with the user's permissions: the same trust boundary
|
|
10
|
+
as the shell tool. Project-local extensions load only for a repository the user
|
|
11
|
+
has trusted (`project_trust`), so cloning one cannot run its code at launch.
|
|
12
|
+
|
|
13
|
+
A discovered extension runs unless the user turned it off (`extensions_off`), or
|
|
14
|
+
it declares `DEFAULT_ENABLED = False` and the user has not turned it on
|
|
15
|
+
(`extensions_on`). That is how a bundled default ships opt-in. `/extensions`
|
|
16
|
+
lists the state and writes both preferences.
|
|
17
|
+
|
|
18
|
+
Loading happens with the rest of agent construction, off the terminal's startup
|
|
19
|
+
path. Every failure is recorded on the extension and reported, never raised: a
|
|
20
|
+
broken extension must not prevent a coding session.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import importlib.util
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
import traceback
|
|
29
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
30
|
+
from contextlib import asynccontextmanager
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from uuid import uuid4
|
|
34
|
+
|
|
35
|
+
from pcode import remote_profile
|
|
36
|
+
from pcode.commands import Command
|
|
37
|
+
from pcode.preferences import SETTINGS, load_preferences, preferences_path, update_preferences
|
|
38
|
+
|
|
39
|
+
PROJECT_DIR = Path(".pcode") / "extensions"
|
|
40
|
+
# Defaults shipped with pcode, written against the same API as user extensions.
|
|
41
|
+
# Searched last, so a user or project file of the same name replaces one (an
|
|
42
|
+
# empty `setup` disables it). Keep this the only place they are special.
|
|
43
|
+
BUNDLED_DIR = Path(__file__).with_name("extensions")
|
|
44
|
+
ID_PREFIX = "ext."
|
|
45
|
+
# Forced on, from BUNDLED_DIR, for a remote host (`pcode.remote_profile`).
|
|
46
|
+
SANDBOX = "sandbox"
|
|
47
|
+
# A module declaring `DEFAULT_ENABLED = False` is opt-in: discovered and listed,
|
|
48
|
+
# but `setup` runs only once its name is in `extensions_on`.
|
|
49
|
+
DEFAULT_FLAG = "DEFAULT_ENABLED"
|
|
50
|
+
# The authoring reference, shipped with the package so the model can read it
|
|
51
|
+
# with its file tools instead of the API being repeated in every prompt.
|
|
52
|
+
EXTENSION_GUIDE = Path(__file__).with_name("extension_guide.md")
|
|
53
|
+
Notify = Callable[[str, str], None]
|
|
54
|
+
LEVELS = ("info", "warning", "error")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def name_list(key: str) -> set[str]:
|
|
58
|
+
"""A comma-separated preference read as a set of extension names."""
|
|
59
|
+
value = load_preferences().get(key, SETTINGS[key].default) or ""
|
|
60
|
+
return {entry.strip() for entry in value.split(",") if entry.strip()}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def set_enabled(name: str, enabled: bool) -> None:
|
|
64
|
+
"""Record that `name` should (not) load, clearing the opposite list.
|
|
65
|
+
|
|
66
|
+
Both lists are written because "on" must beat a `DEFAULT_ENABLED = False`
|
|
67
|
+
module *and* an earlier "off", and neither knows which applied.
|
|
68
|
+
"""
|
|
69
|
+
off, on = name_list("extensions_off"), name_list("extensions_on")
|
|
70
|
+
off, on = (off - {name}, on | {name}) if enabled else (off | {name}, on - {name})
|
|
71
|
+
values = {"extensions_off": ",".join(sorted(off)), "extensions_on": ",".join(sorted(on))}
|
|
72
|
+
# An empty list is the default, so drop the key rather than saving "".
|
|
73
|
+
update_preferences(
|
|
74
|
+
{key: value for key, value in values.items() if value},
|
|
75
|
+
remove=tuple(key for key, value in values.items() if not value),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def user_extension_dir() -> Path:
|
|
80
|
+
"""The always-searched user directory, beside preferences.json."""
|
|
81
|
+
return preferences_path().parent / "extensions"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def extension_dirs(workspace: Path) -> list[tuple[Path, str]]:
|
|
85
|
+
"""Resolve the searched directories with their scope, first match winning."""
|
|
86
|
+
from pcode.project_trust import is_trusted
|
|
87
|
+
|
|
88
|
+
preferences = load_preferences()
|
|
89
|
+
directories: list[tuple[Path, str]] = []
|
|
90
|
+
if is_trusted(workspace):
|
|
91
|
+
directories.append((workspace / PROJECT_DIR, "project"))
|
|
92
|
+
directories.append((user_extension_dir(), "user"))
|
|
93
|
+
configured = preferences.get("extension_dirs", SETTINGS["extension_dirs"].default) or ""
|
|
94
|
+
for entry in configured.split(os.pathsep):
|
|
95
|
+
entry = entry.strip()
|
|
96
|
+
if not entry:
|
|
97
|
+
continue
|
|
98
|
+
path = Path(entry).expanduser()
|
|
99
|
+
directories.append((path if path.is_absolute() else workspace / path, "configured"))
|
|
100
|
+
directories.append((BUNDLED_DIR, "bundled"))
|
|
101
|
+
return directories
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def discover_extensions(workspace: Path) -> list["Extension"]:
|
|
105
|
+
"""Locate `*.py` files and `*/__init__.py` packages, keeping the first of each name."""
|
|
106
|
+
found: dict[str, Extension] = {}
|
|
107
|
+
for directory, scope in extension_dirs(workspace.resolve()):
|
|
108
|
+
if not directory.is_dir():
|
|
109
|
+
continue
|
|
110
|
+
for path in sorted(directory.iterdir()):
|
|
111
|
+
if path.name.startswith(("_", ".")):
|
|
112
|
+
continue
|
|
113
|
+
if path.is_file() and path.suffix == ".py":
|
|
114
|
+
name = path.stem
|
|
115
|
+
elif path.is_dir() and (path / "__init__.py").is_file():
|
|
116
|
+
name = path.name
|
|
117
|
+
else:
|
|
118
|
+
continue
|
|
119
|
+
if name not in found:
|
|
120
|
+
found[name] = Extension(name, path, scope)
|
|
121
|
+
if remote_profile.active() is not None:
|
|
122
|
+
# A remote host's guardrail is the bundled one, never a same-named file.
|
|
123
|
+
found[SANDBOX] = Extension(SANDBOX, BUNDLED_DIR / f"{SANDBOX}.py", "bundled")
|
|
124
|
+
return list(found.values())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ExtensionUI:
|
|
128
|
+
"""What an extension may ask of the terminal. Bound to it by the app."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self, notify: Notify | None = None, request_reload: Callable[[], None] | None = None
|
|
132
|
+
) -> None:
|
|
133
|
+
self._notify = notify
|
|
134
|
+
self._request_reload = request_reload
|
|
135
|
+
|
|
136
|
+
def notify(self, text: str, level: str = "info") -> None:
|
|
137
|
+
"""Print a transient notice in the transcript."""
|
|
138
|
+
if level not in LEVELS:
|
|
139
|
+
raise ValueError(f"level must be one of {', '.join(LEVELS)}")
|
|
140
|
+
if self._notify is not None:
|
|
141
|
+
self._notify(str(text), level)
|
|
142
|
+
|
|
143
|
+
def request_reload(self) -> None:
|
|
144
|
+
"""Ask for `/reload` once the terminal is idle.
|
|
145
|
+
|
|
146
|
+
For an extension whose contributions depend on state a command just
|
|
147
|
+
changed: `setup` runs again and the agent is rebuilt around the same
|
|
148
|
+
conversation. Called mid-turn, it waits for the turn to end, so the
|
|
149
|
+
current turn keeps the old tools. Raises `ValueError` without a live
|
|
150
|
+
model session.
|
|
151
|
+
"""
|
|
152
|
+
if self._request_reload is not None:
|
|
153
|
+
self._request_reload()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ExtensionAPI:
|
|
157
|
+
"""The object handed to `setup`. Collects contributions; `capabilities()` builds them."""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
name: str,
|
|
162
|
+
workspace: Path,
|
|
163
|
+
ui: ExtensionUI,
|
|
164
|
+
*,
|
|
165
|
+
session_dir: Path | None = None,
|
|
166
|
+
is_worker: bool = False,
|
|
167
|
+
) -> None:
|
|
168
|
+
from pcode.sessions import session_root
|
|
169
|
+
|
|
170
|
+
self.session_dir = (session_dir or session_root()).resolve()
|
|
171
|
+
self.name = name
|
|
172
|
+
self.workspace = workspace
|
|
173
|
+
self.is_worker = is_worker
|
|
174
|
+
self.ui = ui
|
|
175
|
+
self.id = ID_PREFIX + name
|
|
176
|
+
self._tools: list = []
|
|
177
|
+
self._instructions: list[str] = []
|
|
178
|
+
self._capabilities: list = []
|
|
179
|
+
self._hooks = None
|
|
180
|
+
self.commands: list[Command] = []
|
|
181
|
+
self.subagents: list = []
|
|
182
|
+
self.closers: list[Callable[[], Awaitable[None]]] = []
|
|
183
|
+
|
|
184
|
+
def tool(self, function):
|
|
185
|
+
"""Register a plain function as a model-callable tool (decorator).
|
|
186
|
+
|
|
187
|
+
The docstring becomes the description and type hints the schema, as
|
|
188
|
+
with Pydantic AI's `Agent.tool_plain`.
|
|
189
|
+
"""
|
|
190
|
+
self._tools.append(function)
|
|
191
|
+
return function
|
|
192
|
+
|
|
193
|
+
def instructions(self, text: str) -> None:
|
|
194
|
+
"""Add static system-prompt text. Keep it fixed: it is part of the cached prefix."""
|
|
195
|
+
self._instructions.append(text)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def hooks(self):
|
|
199
|
+
"""Lifecycle hooks: `@pcode.hooks.on.before_tool_execute`, `after_model_request`, ...
|
|
200
|
+
|
|
201
|
+
Names are Pydantic AI's `Hooks` decorators; raise `ModelRetry` from a
|
|
202
|
+
`before_tool_execute` hook to block a call and tell the model why.
|
|
203
|
+
"""
|
|
204
|
+
if self._hooks is None:
|
|
205
|
+
from pydantic_ai.capabilities import Hooks
|
|
206
|
+
|
|
207
|
+
self._hooks = Hooks(id=self.id + ".hooks")
|
|
208
|
+
return self._hooks
|
|
209
|
+
|
|
210
|
+
def add_capability(self, capability) -> None:
|
|
211
|
+
"""Add any Pydantic AI capability; the escape hatch for everything else."""
|
|
212
|
+
if getattr(capability, "id", None) is None:
|
|
213
|
+
try:
|
|
214
|
+
capability.id = self.id
|
|
215
|
+
except (AttributeError, TypeError):
|
|
216
|
+
pass # Frozen or slotted: it stays anonymous in /status.
|
|
217
|
+
self._capabilities.append(capability)
|
|
218
|
+
|
|
219
|
+
def subagent(self, agent, **options) -> None:
|
|
220
|
+
"""Offer `agent` to the model through `delegate_task`, beside the worker.
|
|
221
|
+
|
|
222
|
+
`agent` is a Pydantic AI `Agent` with a `name` and `description`; leave
|
|
223
|
+
its model unset to run on the session's model. `options` are the
|
|
224
|
+
Harness `SubAgent` fields (`usage_limits`, `timeout_seconds`, ...).
|
|
225
|
+
|
|
226
|
+
A delegate without `usage_limits` shares the parent run's usage counter
|
|
227
|
+
under the library's default 50-request cap, so a long session trips it
|
|
228
|
+
mid-delegation and `UsageLimitExceeded` aborts the whole turn instead of
|
|
229
|
+
steering the parent. Default to the worker's own uncapped counter; pass
|
|
230
|
+
`usage_limits` explicitly to set a budget.
|
|
231
|
+
"""
|
|
232
|
+
from pydantic_ai_harness.subagents import SubAgent
|
|
233
|
+
|
|
234
|
+
from pcode.agent import SUBAGENT_USAGE_LIMITS
|
|
235
|
+
|
|
236
|
+
options.setdefault("usage_limits", SUBAGENT_USAGE_LIMITS)
|
|
237
|
+
self.subagents.append(SubAgent(agent, **options))
|
|
238
|
+
|
|
239
|
+
def on_close(self, function: Callable[[], Awaitable[None]]):
|
|
240
|
+
"""Run `await function()` when the terminal exits, for resources a tool started."""
|
|
241
|
+
self.closers.append(function)
|
|
242
|
+
return function
|
|
243
|
+
|
|
244
|
+
def register_command(
|
|
245
|
+
self,
|
|
246
|
+
name: str,
|
|
247
|
+
description: str,
|
|
248
|
+
handler: Callable[[str], None],
|
|
249
|
+
*,
|
|
250
|
+
arguments: tuple[str, ...] = (),
|
|
251
|
+
aliases: tuple[str, ...] = (),
|
|
252
|
+
argument_descriptions: dict[str, str] | None = None,
|
|
253
|
+
complete_paths: bool = False,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Add a slash command. `handler(argument)` runs on the terminal's event loop.
|
|
256
|
+
|
|
257
|
+
Without `arguments`, any text is accepted; with them, only those values,
|
|
258
|
+
and `argument_descriptions` labels each in the completion menu.
|
|
259
|
+
`complete_paths` completes the argument, after any leading `--option`
|
|
260
|
+
words, as a filesystem path. Names taken by pcode itself are reported
|
|
261
|
+
and skipped, never overridden.
|
|
262
|
+
"""
|
|
263
|
+
if not name.startswith("/"):
|
|
264
|
+
name = "/" + name
|
|
265
|
+
completer = None
|
|
266
|
+
if complete_paths:
|
|
267
|
+
from pcode.path_completion import complete_paths as complete
|
|
268
|
+
|
|
269
|
+
workspace = Path(self.workspace)
|
|
270
|
+
|
|
271
|
+
# The name matters: an attached terminal runs its own completer of
|
|
272
|
+
# this name (`RemoteController.path_completions`).
|
|
273
|
+
def path_completions(argument: str):
|
|
274
|
+
return complete(argument, workspace)
|
|
275
|
+
|
|
276
|
+
completer = path_completions
|
|
277
|
+
self.commands.append(
|
|
278
|
+
Command(
|
|
279
|
+
name,
|
|
280
|
+
description,
|
|
281
|
+
handler,
|
|
282
|
+
arguments=arguments,
|
|
283
|
+
aliases=aliases,
|
|
284
|
+
free_arguments=not arguments,
|
|
285
|
+
group="Extensions",
|
|
286
|
+
argument_descriptions=argument_descriptions,
|
|
287
|
+
argument_completer=completer,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def capabilities(self) -> list:
|
|
292
|
+
"""Everything this extension contributes to the agent."""
|
|
293
|
+
result = list(self._capabilities)
|
|
294
|
+
if self._tools or self._instructions:
|
|
295
|
+
from pydantic_ai.capabilities import Capability
|
|
296
|
+
|
|
297
|
+
result.append(
|
|
298
|
+
Capability(
|
|
299
|
+
id=self.id,
|
|
300
|
+
tools=self._tools,
|
|
301
|
+
instructions="\n\n".join(self._instructions) or None,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
if self._hooks is not None and self._hooks._registry:
|
|
305
|
+
result.append(self._hooks)
|
|
306
|
+
return result
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass
|
|
310
|
+
class Extension:
|
|
311
|
+
"""One discovered extension and the outcome of loading it."""
|
|
312
|
+
|
|
313
|
+
name: str
|
|
314
|
+
path: Path
|
|
315
|
+
scope: str
|
|
316
|
+
error: str | None = None
|
|
317
|
+
# Why `setup` was not run: the user turned it off, or it ships opt-in.
|
|
318
|
+
disabled: str | None = None
|
|
319
|
+
capabilities: list = field(default_factory=list)
|
|
320
|
+
commands: list[Command] = field(default_factory=list)
|
|
321
|
+
subagents: list = field(default_factory=list)
|
|
322
|
+
closers: list[Callable[[], Awaitable[None]]] = field(default_factory=list)
|
|
323
|
+
|
|
324
|
+
# Loading provenance is optional for backwards-compatible manual construction.
|
|
325
|
+
workspace: Path | None = None
|
|
326
|
+
session_dir: Path | None = None
|
|
327
|
+
ui: ExtensionUI = field(default_factory=ExtensionUI, repr=False)
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def enabled(self) -> bool:
|
|
331
|
+
return self.disabled is None
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def loaded(self) -> bool:
|
|
335
|
+
return self.enabled and self.error is None
|
|
336
|
+
|
|
337
|
+
def state(self) -> str:
|
|
338
|
+
"""One phrase for the report: what it contributed, or why it did not."""
|
|
339
|
+
if self.disabled is not None:
|
|
340
|
+
return f"{self.disabled} (/extensions on {self.name})"
|
|
341
|
+
if self.error is not None:
|
|
342
|
+
return f"failed, {self.error}"
|
|
343
|
+
return self.summary()
|
|
344
|
+
|
|
345
|
+
def summary(self) -> str:
|
|
346
|
+
counts = []
|
|
347
|
+
if tools := sum(_tool_count(c) for c in self.capabilities):
|
|
348
|
+
counts.append(f"{tools} tool{'s' if tools != 1 else ''}")
|
|
349
|
+
if hooks := [c for c in self.capabilities if type(c).__name__ == "Hooks"]:
|
|
350
|
+
counts.append(f"{sum(len(h._registry) for h in hooks)} hooks")
|
|
351
|
+
if self.subagents:
|
|
352
|
+
counts.append(", ".join(f"@{s.resolved_name}" for s in self.subagents))
|
|
353
|
+
if self.commands:
|
|
354
|
+
counts.append(", ".join(c.name for c in self.commands))
|
|
355
|
+
return ", ".join(counts) or "no contributions"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _tool_count(capability) -> int:
|
|
359
|
+
return _toolset_size(capability.get_toolset() if hasattr(capability, "get_toolset") else None)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _toolset_size(toolset) -> int:
|
|
363
|
+
# Native-or-local capabilities wrap their function toolset in a prepared one;
|
|
364
|
+
# a capability given both `tools` and `toolsets` combines them.
|
|
365
|
+
while toolset is not None and not hasattr(toolset, "tools"):
|
|
366
|
+
if (parts := getattr(toolset, "toolsets", None)) is not None:
|
|
367
|
+
return sum(_toolset_size(part) for part in parts)
|
|
368
|
+
toolset = getattr(toolset, "wrapped", None)
|
|
369
|
+
tools = getattr(toolset, "tools", None)
|
|
370
|
+
return len(tools) if isinstance(tools, dict) else 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _module_name(extension: Extension) -> str:
|
|
374
|
+
return "pcode_ext_" + re.sub(r"\W", "_", extension.name)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _failure(error: BaseException, path: Path) -> str:
|
|
378
|
+
"""One line naming the error and, when it is in the extension, the offending line."""
|
|
379
|
+
message = f"{type(error).__name__}: {error}"
|
|
380
|
+
for frame in reversed(traceback.extract_tb(error.__traceback__)):
|
|
381
|
+
if Path(frame.filename).resolve().is_relative_to(path.resolve().parent):
|
|
382
|
+
return f"{message} ({Path(frame.filename).name}:{frame.lineno})"
|
|
383
|
+
return message
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def load_extension(
|
|
387
|
+
extension: Extension,
|
|
388
|
+
workspace: Path,
|
|
389
|
+
ui: ExtensionUI,
|
|
390
|
+
off: set[str] | None = None,
|
|
391
|
+
on: set[str] | None = None,
|
|
392
|
+
*,
|
|
393
|
+
session_dir: Path | None = None,
|
|
394
|
+
module_name: str | None = None,
|
|
395
|
+
is_worker: bool = False,
|
|
396
|
+
) -> Extension:
|
|
397
|
+
"""Import the module, run `setup`, and validate what it contributed.
|
|
398
|
+
|
|
399
|
+
An extension the user turned off is never imported; one that ships opt-in is
|
|
400
|
+
imported (that is where the flag lives) but its `setup` does not run.
|
|
401
|
+
"""
|
|
402
|
+
extension.workspace = workspace.resolve()
|
|
403
|
+
extension.session_dir = session_dir
|
|
404
|
+
extension.ui = ui
|
|
405
|
+
extension.error = None
|
|
406
|
+
extension.disabled = None
|
|
407
|
+
extension.capabilities = []
|
|
408
|
+
extension.commands = []
|
|
409
|
+
extension.subagents = []
|
|
410
|
+
extension.closers = []
|
|
411
|
+
off = name_list("extensions_off") if off is None else off
|
|
412
|
+
on = name_list("extensions_on") if on is None else on
|
|
413
|
+
if extension.name in off:
|
|
414
|
+
extension.disabled = "off"
|
|
415
|
+
return extension
|
|
416
|
+
target = extension.path / "__init__.py" if extension.path.is_dir() else extension.path
|
|
417
|
+
name = module_name or _module_name(extension)
|
|
418
|
+
try:
|
|
419
|
+
spec = importlib.util.spec_from_file_location(
|
|
420
|
+
name,
|
|
421
|
+
target,
|
|
422
|
+
submodule_search_locations=[str(extension.path)] if extension.path.is_dir() else None,
|
|
423
|
+
)
|
|
424
|
+
if spec is None or spec.loader is None:
|
|
425
|
+
raise ImportError(f"cannot load {target}")
|
|
426
|
+
module = importlib.util.module_from_spec(spec)
|
|
427
|
+
# Registered first so dataclasses and pickling inside the module resolve.
|
|
428
|
+
sys.modules[name] = module
|
|
429
|
+
spec.loader.exec_module(module)
|
|
430
|
+
if not getattr(module, DEFAULT_FLAG, True) and extension.name not in on:
|
|
431
|
+
extension.disabled = "off by default"
|
|
432
|
+
sys.modules.pop(name, None)
|
|
433
|
+
return extension
|
|
434
|
+
setup = getattr(module, "setup", None)
|
|
435
|
+
if not callable(setup):
|
|
436
|
+
raise AttributeError("extension defines no setup(pcode) function")
|
|
437
|
+
api = ExtensionAPI(
|
|
438
|
+
extension.name, workspace, ui, session_dir=session_dir, is_worker=is_worker
|
|
439
|
+
)
|
|
440
|
+
# Keep partial setup's resources available for cleanup on failure too.
|
|
441
|
+
extension.closers = api.closers
|
|
442
|
+
extension.session_dir = api.session_dir
|
|
443
|
+
setup(api)
|
|
444
|
+
capabilities = api.capabilities()
|
|
445
|
+
for capability in capabilities:
|
|
446
|
+
# Exercise the static getters now, the way Harness validates authored
|
|
447
|
+
# capabilities, so a broken tool schema fails at load rather than mid-turn.
|
|
448
|
+
capability.get_instructions()
|
|
449
|
+
capability.get_toolset()
|
|
450
|
+
extension.capabilities = capabilities
|
|
451
|
+
extension.commands = api.commands
|
|
452
|
+
extension.subagents = api.subagents
|
|
453
|
+
extension.closers = api.closers
|
|
454
|
+
except BaseException as error: # noqa: BLE001 - a bad extension must not stop launch.
|
|
455
|
+
if isinstance(error, (KeyboardInterrupt, SystemExit, asyncio.CancelledError)):
|
|
456
|
+
raise
|
|
457
|
+
extension.error = _failure(error, extension.path)
|
|
458
|
+
# Rebound packages stay importable until their partial setup is closed.
|
|
459
|
+
if module_name is None:
|
|
460
|
+
sys.modules.pop(name, None)
|
|
461
|
+
return extension
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class ExtensionCapabilities(list):
|
|
465
|
+
"""A normal capability list carrying the sources needed for isolated workers.
|
|
466
|
+
|
|
467
|
+
Rebinding uses only successful parent loads, not discovery or child preferences.
|
|
468
|
+
Project sources follow the checkout; all other sources retain their original path.
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
def __init__(self, extensions: list[Extension]) -> None:
|
|
472
|
+
super().__init__(c for extension in extensions for c in extension.capabilities)
|
|
473
|
+
self._extensions = tuple(extension for extension in extensions if extension.loaded)
|
|
474
|
+
|
|
475
|
+
@asynccontextmanager
|
|
476
|
+
async def for_workspace(self, workspace: Path) -> AsyncIterator["ExtensionCapabilities"]:
|
|
477
|
+
"""Re-run setup for a child workspace and own only that child's resources.
|
|
478
|
+
|
|
479
|
+
The active parent's preference overlay is deliberately left untouched. A
|
|
480
|
+
failed rebind is fatal: silently losing a guardrail is not safe.
|
|
481
|
+
"""
|
|
482
|
+
workspace = workspace.resolve()
|
|
483
|
+
children = LoadedExtensions()
|
|
484
|
+
module_names: list[str] = []
|
|
485
|
+
try:
|
|
486
|
+
for parent in self._extensions:
|
|
487
|
+
if parent.workspace is None:
|
|
488
|
+
raise ValueError(f"Extension {parent.name} has no workspace loading provenance")
|
|
489
|
+
path = parent.path
|
|
490
|
+
if parent.scope == "project":
|
|
491
|
+
path = workspace / path.relative_to(parent.workspace)
|
|
492
|
+
child = Extension(parent.name, path, parent.scope)
|
|
493
|
+
children.extensions.append(child)
|
|
494
|
+
name = f"{_module_name(child)}_rebind_{uuid4().hex}"
|
|
495
|
+
module_names.append(name)
|
|
496
|
+
load_extension(
|
|
497
|
+
child,
|
|
498
|
+
workspace,
|
|
499
|
+
ExtensionUI(notify=parent.ui.notify),
|
|
500
|
+
off=set(),
|
|
501
|
+
on={parent.name},
|
|
502
|
+
session_dir=parent.session_dir,
|
|
503
|
+
module_name=name,
|
|
504
|
+
is_worker=True,
|
|
505
|
+
)
|
|
506
|
+
if child.error is not None:
|
|
507
|
+
raise ValueError(f"Failed to rebind extension {child.name}: {child.error}")
|
|
508
|
+
yield children.capabilities
|
|
509
|
+
finally:
|
|
510
|
+
# Shield cleanup so cancellation of a worker cannot strand its resources.
|
|
511
|
+
cleanup = asyncio.create_task(children.close())
|
|
512
|
+
try:
|
|
513
|
+
cancelled = False
|
|
514
|
+
while not cleanup.done():
|
|
515
|
+
try:
|
|
516
|
+
await asyncio.shield(cleanup)
|
|
517
|
+
except asyncio.CancelledError:
|
|
518
|
+
cancelled = True
|
|
519
|
+
cleanup.result()
|
|
520
|
+
if cancelled:
|
|
521
|
+
raise asyncio.CancelledError
|
|
522
|
+
finally:
|
|
523
|
+
for name in tuple(sys.modules):
|
|
524
|
+
if any(name == root or name.startswith(root + ".") for root in module_names):
|
|
525
|
+
sys.modules.pop(name, None)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@dataclass
|
|
529
|
+
class LoadedExtensions:
|
|
530
|
+
"""The result of one discovery-and-load pass."""
|
|
531
|
+
|
|
532
|
+
extensions: list[Extension] = field(default_factory=list)
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def capabilities(self) -> ExtensionCapabilities:
|
|
536
|
+
return ExtensionCapabilities(self.extensions)
|
|
537
|
+
|
|
538
|
+
@property
|
|
539
|
+
def commands(self) -> list[Command]:
|
|
540
|
+
return [c for extension in self.extensions for c in extension.commands]
|
|
541
|
+
|
|
542
|
+
@property
|
|
543
|
+
def subagents(self) -> list:
|
|
544
|
+
return [s for extension in self.extensions for s in extension.subagents]
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def failed(self) -> list[Extension]:
|
|
548
|
+
return [extension for extension in self.extensions if extension.error is not None]
|
|
549
|
+
|
|
550
|
+
@property
|
|
551
|
+
def disabled(self) -> list[Extension]:
|
|
552
|
+
return [extension for extension in self.extensions if not extension.enabled]
|
|
553
|
+
|
|
554
|
+
async def close(self) -> None:
|
|
555
|
+
"""Run every extension's close hooks; one failing does not skip the rest."""
|
|
556
|
+
for extension in self.extensions:
|
|
557
|
+
for closer in extension.closers:
|
|
558
|
+
try:
|
|
559
|
+
await closer()
|
|
560
|
+
except Exception as error: # noqa: BLE001 - exit must not stall on an extension.
|
|
561
|
+
print(f"Extension {extension.name} failed to close: {error}", file=sys.stderr)
|
|
562
|
+
|
|
563
|
+
def report(self, workspace: Path) -> list[str]:
|
|
564
|
+
"""Human lines for /extensions and the startup notice."""
|
|
565
|
+
if not self.extensions:
|
|
566
|
+
return []
|
|
567
|
+
lines = []
|
|
568
|
+
for extension in self.extensions:
|
|
569
|
+
path = extension.path
|
|
570
|
+
shown = (
|
|
571
|
+
"bundled"
|
|
572
|
+
if extension.scope == "bundled"
|
|
573
|
+
else path.relative_to(workspace).as_posix()
|
|
574
|
+
if path.is_relative_to(workspace)
|
|
575
|
+
else path
|
|
576
|
+
)
|
|
577
|
+
lines.append(f"{extension.name} ({shown}): {extension.state()}")
|
|
578
|
+
return lines
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def load_extensions(
|
|
582
|
+
workspace: Path, ui: ExtensionUI | None = None, *, session_dir: Path | None = None
|
|
583
|
+
) -> LoadedExtensions:
|
|
584
|
+
"""Discover and load every extension; failures are recorded, not raised."""
|
|
585
|
+
workspace = workspace.resolve()
|
|
586
|
+
ui = ui or ExtensionUI()
|
|
587
|
+
off, on = name_list("extensions_off"), name_list("extensions_on")
|
|
588
|
+
remote = remote_profile.active() is not None
|
|
589
|
+
if remote:
|
|
590
|
+
off, on = off - {SANDBOX}, on | {SANDBOX}
|
|
591
|
+
loaded = LoadedExtensions(
|
|
592
|
+
[
|
|
593
|
+
load_extension(extension, workspace, ui, off, on, session_dir=session_dir)
|
|
594
|
+
for extension in discover_extensions(workspace)
|
|
595
|
+
]
|
|
596
|
+
)
|
|
597
|
+
if remote:
|
|
598
|
+
guard = next(e for e in loaded.extensions if e.name == SANDBOX)
|
|
599
|
+
if guard.error is not None or guard.disabled is not None:
|
|
600
|
+
# Running unattended without it is not an option; fail startup.
|
|
601
|
+
raise RuntimeError(f"The sandbox extension did not load: {guard.state()}")
|
|
602
|
+
return loaded
|