pcode 0.1.0__py3-none-any.whl

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