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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Scrollback-native terminal UI preview."""
2
+
3
+ import time
4
+
5
+ # When this process first loaded pcode. Modules imported lazily after a merge
6
+ # or reinstall come from newer files than the ones loaded at startup.
7
+ STARTED = time.time()
@@ -0,0 +1,65 @@
1
+ """Detached supervisor: owns one command's lifetime and publishes its exit status.
2
+
3
+ Runs in its own interpreter and its own session so the command survives agent
4
+ run teardown, event-loop shutdown, and pcode itself. It writes `status.json`
5
+ atomically, which is the only thing the parent polls: a supervisor killed
6
+ between publications leaves the last consistent snapshot rather than a partial
7
+ file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+
21
+ def main() -> None: # pragma: no cover -- exercised through a real subprocess.
22
+ directory = Path(sys.argv[1])
23
+ command = sys.argv[2]
24
+ status = directory / "status.json"
25
+ pending = directory / "status.tmp"
26
+
27
+ def publish(*, pid: int, exit_code: int | None, started_at: float) -> None:
28
+ pending.write_text(
29
+ json.dumps(
30
+ {
31
+ "pid": pid,
32
+ "exit_code": exit_code,
33
+ "started_at": started_at,
34
+ "ended_at": None if exit_code is None else time.time(),
35
+ }
36
+ ),
37
+ encoding="utf-8",
38
+ )
39
+ pending.replace(status)
40
+
41
+ with (directory / "output.log").open("ab", buffering=0) as output:
42
+ started_at = time.time()
43
+ process = subprocess.Popen(
44
+ command,
45
+ shell=True,
46
+ stdin=subprocess.DEVNULL,
47
+ stdout=output,
48
+ stderr=output,
49
+ )
50
+ publish(pid=process.pid, exit_code=None, started_at=started_at)
51
+ exit_code = process.wait()
52
+ publish(pid=process.pid, exit_code=exit_code, started_at=started_at)
53
+
54
+
55
+ if __name__ == "__main__": # pragma: no cover
56
+ # The parent starts a new session for us. Ignore terminal hangups without
57
+ # changing how the child command itself handles termination. A stop is
58
+ # SIGTERM to the whole group: survive it here so the command's own exit
59
+ # under it still gets published. A Python handler rather than SIG_IGN,
60
+ # because an ignored disposition is inherited across exec and would make
61
+ # the command itself immune to the stop; a handler resets to default.
62
+ if os.name != "nt":
63
+ signal.signal(signal.SIGHUP, signal.SIG_IGN)
64
+ signal.signal(signal.SIGTERM, lambda *_: None)
65
+ main()
pcode/agent.py ADDED
@@ -0,0 +1,592 @@
1
+ """Agent construction is independent of the terminal and runtime adapter."""
2
+
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from contextlib import asynccontextmanager
8
+ from contextvars import ContextVar
9
+ from copy import copy
10
+ from dataclasses import dataclass, fields, replace
11
+ from pathlib import Path
12
+
13
+ from pydantic_ai import Agent
14
+ from pydantic_ai.capabilities import (
15
+ AbstractCapability,
16
+ Capability,
17
+ CombinedCapability,
18
+ LocalWorkspace,
19
+ )
20
+ from pydantic_ai.models import Model
21
+ from pydantic_ai.models.openai_codex import OpenAICodexModel
22
+ from pydantic_ai.profiles.openai import OpenAIModelProfile
23
+ from pydantic_ai.providers.openai_codex import OpenAICodexProvider
24
+ from pydantic_ai.toolsets import CombinedToolset
25
+ from pydantic_ai.usage import UsageLimits
26
+ from pydantic_ai_harness.coder import Coder
27
+ from pydantic_ai_harness.compaction import ClearToolResults, WarnNearLimits
28
+ from pydantic_ai_harness.filesystem import FileSystem
29
+ from pydantic_ai_harness.repo_context import RepoContext
30
+ from pydantic_ai_harness.shell import LLM_API_KEY_ENV_PATTERNS, Shell
31
+ from pydantic_ai_harness.subagents import ModelOption, SubAgent
32
+ from pydantic_ai_harness.tool_output_limits import ToolOutputLimits
33
+
34
+ from pcode import remote_profile
35
+ from pcode.cache_settings import ProviderCacheSettings, model_settings
36
+ from pcode.cache_warnings import CacheBustReporting
37
+ from pcode.claude_sdk import ClaudeWorkspace
38
+ from pcode.code_mode import create_code_mode
39
+ from pcode.delegation import DelegationReporting, stream_child_activity
40
+ from pcode.ext import EXTENSION_GUIDE, ExtensionCapabilities
41
+ from pcode.filesystem import DisplayFileSystem
42
+ from pcode.isolated_delegation import WorkspaceSubAgents
43
+ from pcode.job_notices import JobNotices
44
+ from pcode.jobs import isolated_registry
45
+ from pcode.llm_proxy import ProxiedCodexProvider
46
+ from pcode.mcp import configured_servers
47
+ from pcode.mcp_notice import MCPServers
48
+ from pcode.meridian import MeridianSessionIdentity
49
+ from pcode.meridian_reminders import MeridianLimitWarnings
50
+ from pcode.output_limits import ModelOutputLimits
51
+ from pcode.planning import IdentifiedPlanning
52
+ from pcode.preferences import SETTINGS, load_preferences, subagent_models
53
+ from pcode.repo_context import create_repo_context
54
+ from pcode.shell_tools import JobShell
55
+ from pcode.strict_tools import create_strict_tools
56
+ from pcode.tool_output_limits import create_tool_output_limits
57
+ from pcode.workspace import WorkspaceGuard
58
+
59
+ # Harness gives a child its own usage counter only when its `SubAgent` carries
60
+ # `usage_limits`; without one the child shares the parent's counter and silently
61
+ # gets the library's 50-request default, which a busy session has already spent.
62
+ # The limits set no cap: like the parent turn, which is uncapped too, a child's
63
+ # activity is on screen and Ctrl+C stops it, while a fixed budget discarded a
64
+ # working child's whole result. `pcode.ext.subagent` applies this to extension
65
+ # delegates too.
66
+ SUBAGENT_USAGE_LIMITS = UsageLimits(request_limit=None)
67
+
68
+ # Coder's default prompt without "finish long-running work before responding",
69
+ # which kept the model waiting on jobs instead of answering steering. Job
70
+ # mechanics are in the shell tool descriptions; don't add workflow rules here.
71
+ CODER_INSTRUCTIONS = """\
72
+ You are a software engineering agent. Use tools to investigate, implement, and
73
+ verify the requested work. Read existing code and follow repository instructions
74
+ and conventions. Prefer focused changes that fix causes, not symptoms.
75
+
76
+ Apply DRY, YAGNI, SOLID, and the Zen of Python pragmatically: simple, explicit,
77
+ cohesive code beats abstractions without a present need.
78
+
79
+ Work autonomously until complete. Ask only for missing requirements, credentials,
80
+ consequential ambiguity, or approval for irreversible actions. Use reasonable
81
+ defaults for minor ambiguities. Run focused tests and appropriate lint/type checks;
82
+ report what you actually verified, assumptions, and remaining limitations.
83
+
84
+ Servers may remain running once readiness is verified; shut them down when no
85
+ longer needed.
86
+ """
87
+
88
+ AGENT_INSTRUCTIONS = (
89
+ "Responses are displayed in a terminal with Markdown rendering "
90
+ "and syntax highlighting. "
91
+ "Use fenced code blocks with a language tag for multiline code or shell examples, "
92
+ "and inline backticks for identifiers and short commands. Close all code fences. "
93
+ "Write ordinary prose outside code blocks. "
94
+ "Full URLs are clickable in the terminal, so use them liberally and "
95
+ "prefer them over bare references like issue numbers or page titles. "
96
+ # /copy offers quotes as their own choice, since the rendered rail and wrap
97
+ # make them hard to select by hand.
98
+ "Put text meant for the user to paste elsewhere in a > blockquote. "
99
+ # GPT-6 tends to edit through shell commands, bypassing captured edit diffs.
100
+ "Prefer edit_file and write_file for file changes over shell tools. "
101
+ # The model is the intended extension author, so it needs to know the
102
+ # mechanism exists without the reference text sitting in every prompt.
103
+ "pcode itself is extensible with small Python files (new slash commands, "
104
+ "tools, guardrails on tool calls, extra instructions). When asked to change "
105
+ f"how pcode behaves, first read {EXTENSION_GUIDE} and follow it."
106
+ )
107
+
108
+
109
+ _worker_toolsets: ContextVar[Sequence] = ContextVar("worker_toolsets", default=())
110
+
111
+
112
+ @dataclass
113
+ class WorkerRuntimeTools(CombinedToolset):
114
+ """Stands in for the delegating turn's runtime toolsets in each worker run.
115
+
116
+ Resolved once per run rather than by a per-step toolset function, which
117
+ would exit and re-enter them at every step, and through their own
118
+ `for_run`, so a live MCP toolset gets a fresh instance for this worker and
119
+ still follows /mcp changes at each of its steps.
120
+ """
121
+
122
+ toolsets: Sequence = ()
123
+
124
+ async def for_run(self, ctx):
125
+ return await CombinedToolset(list(_worker_toolsets.get())).for_run(ctx)
126
+
127
+
128
+ @asynccontextmanager
129
+ async def worker_toolsets(toolsets: Sequence):
130
+ """Give the built-in worker the same enabled runtime toolsets as its parent.
131
+
132
+ Harness's inherit_tools covers constructor toolsets, not per-run MCP tools,
133
+ and would also broaden specialized extension delegates. Context-local agent
134
+ bindings keep this scoped to the worker and to this turn, including errors
135
+ and cancellation; no stale MCP connection is retained after disable/reload.
136
+ """
137
+ token = _worker_toolsets.set(tuple(toolsets))
138
+ try:
139
+ yield
140
+ finally:
141
+ _worker_toolsets.reset(token)
142
+
143
+
144
+ def tool_retries() -> dict[str, int]:
145
+ """The correction budget for tool-argument validation and `ModelRetry`.
146
+
147
+ Pydantic AI defaults both tool and output retries to 1, so a second
148
+ malformed call to a tool ends the whole turn with `UnexpectedModelBehavior`.
149
+ A nested argument like `edit_file`'s `replacements` array is easy to mangle
150
+ twice in a row, and a correction costs one round trip where the failure
151
+ costs the turn. Output retries keep the stricter default: a model that
152
+ cannot produce the final output shape twice is not going to converge.
153
+ """
154
+ configured = load_preferences().get("tool_retries", SETTINGS["tool_retries"].default)
155
+ return {"tools": int(configured)}
156
+
157
+
158
+ def has_mcp_servers() -> bool:
159
+ """Whether mcp.json configures any server, deciding once per agent.
160
+
161
+ A broken file counts as none: nothing can be enabled from it, and `/mcp`
162
+ reports the error when it is used.
163
+ """
164
+ try:
165
+ return bool(configured_servers())
166
+ except ValueError:
167
+ return False
168
+
169
+
170
+ def _init_fields(capability) -> dict:
171
+ """A dataclass capability's constructor arguments, to rebuild it as a subclass."""
172
+ return {f.name: getattr(capability, f.name) for f in fields(capability) if f.init}
173
+
174
+
175
+ def _adapt(capability, workspace: Path, output_limits):
176
+ """Map one of Coder's capabilities to pcode's replacement, or keep it.
177
+
178
+ Ids must be set at construction (`replace(..., id=...)` or the factory):
179
+ a `Capability` binds its instructions to its id in `__init__`, so a later
180
+ `capability.id = ...` is silently ignored by /status attribution. Do not
181
+ blanket-rename everything either: `replace()`-copied children compare
182
+ fields with their parent and a renamed parent breaks that match.
183
+ """
184
+ match capability:
185
+ # Coder's sole plain Capability holds its base instructions (and a
186
+ # project line pcode's file-tool instructions already cover). Exact type:
187
+ # every capability class below would also match `Capability()`.
188
+ case Capability() if type(capability) is Capability:
189
+ return Capability(instructions=CODER_INSTRUCTIONS)
190
+ case RepoContext():
191
+ return create_repo_context(workspace)
192
+ # Named so /status can attribute its prompt; ids never reach the model.
193
+ # No per-toolset retry budget: the `tool_retries` preference governs.
194
+ case FileSystem():
195
+ return replace(
196
+ DisplayFileSystem.from_filesystem(capability), id="file_tools", max_retries=None
197
+ )
198
+ # Replace Coder's 64k truncation, so it cannot cut data before spilling.
199
+ case ToolOutputLimits():
200
+ return output_limits
201
+ case WarnNearLimits():
202
+ return MeridianLimitWarnings(**_init_fields(capability))
203
+ case Shell():
204
+ return _job_shell(capability, workspace)
205
+ case _:
206
+ return capability
207
+
208
+
209
+ def _job_shell(shell: Shell, workspace: Path) -> JobShell:
210
+ """Coder's persistent shell with commands run as named jobs.
211
+
212
+ Same execution model, but commands become named jobs the session can wait
213
+ on, report and stop. See `pcode.shell_tools`. Copied field by field
214
+ (including `id`) because a capability binds its instructions to its id in
215
+ `__init__`.
216
+ """
217
+ values = _init_fields(shell)
218
+ # direnv writes its status banner to stderr on every cd into a managed
219
+ # directory, which pollutes command output the agent parses (e.g.
220
+ # `... | jq`). An empty log format silences it.
221
+ values["env"] = {**(shell.env or os.environ), "DIRENV_LOG_FORMAT": ""}
222
+ # Commands inherit the whole host environment (see `JobShell`), so keep
223
+ # provider credentials out of it, as Coder did before it left the
224
+ # environment to the workspace.
225
+ values["denied_env_patterns"] = [*values["denied_env_patterns"], *LLM_API_KEY_ENV_PATTERNS]
226
+ return JobShell(**values, workdir=workspace)
227
+
228
+
229
+ def _ensure_bundled_rg() -> None:
230
+ """uv tool entry points do not activate their environment's bin directory.
231
+
232
+ Append it only when rg is missing, preserving the user's command precedence.
233
+ """
234
+ bundled_bin = Path(sys.executable).parent
235
+ if shutil.which("rg") is None and (bundled_bin / "rg").is_file():
236
+ os.environ["PATH"] = os.pathsep.join(
237
+ part for part in (os.environ.get("PATH", ""), str(bundled_bin)) if part
238
+ )
239
+
240
+
241
+ def _cache_notices() -> CacheBustReporting | None:
242
+ preferences = load_preferences()
243
+ if preferences.get("cache_notices", SETTINGS["cache_notices"].default) != "on":
244
+ return None
245
+ return CacheBustReporting(
246
+ dump_fingerprints=preferences.get("debug", SETTINGS["debug"].default) == "on"
247
+ )
248
+
249
+
250
+ class TurnLimits(AbstractCapability):
251
+ """Charges every model request and tool call to a remote host's turn budget.
252
+
253
+ In the agent's capabilities and in delegation's shared ones, so a
254
+ sub-agent's work counts against the turn that delegated it. A sub-agent's
255
+ failure comes back to the parent as a retryable result; the parent's next
256
+ request then fails too, as the budget stays spent.
257
+ """
258
+
259
+ async def before_model_request(self, ctx, request_context):
260
+ remote_profile.BUDGET.charge_request()
261
+ return request_context
262
+
263
+ async def before_tool_execute(self, ctx, *, call, tool_def, args):
264
+ remote_profile.BUDGET.charge_tool_call()
265
+ return args
266
+
267
+
268
+ def turn_limits() -> list[TurnLimits]:
269
+ """The limits when a remote profile is active (`pcode.remote_profile`), else none."""
270
+ return [TurnLimits()] if remote_profile.active() is not None else []
271
+
272
+
273
+ def _worker_capabilities(capabilities: Sequence) -> list:
274
+ """The parent's capabilities, copied for the built-in worker.
275
+
276
+ Compose the worker from the same capabilities rather than maintaining a
277
+ second tool/policy list. Per-run capability state is still managed upstream.
278
+ The shared types are supplied by SubAgents.shared_capabilities instead (also
279
+ for extension delegates); delegation itself is intentionally parent-only.
280
+ """
281
+ shared_types = (
282
+ ToolOutputLimits,
283
+ MeridianSessionIdentity,
284
+ ModelOutputLimits,
285
+ CacheBustReporting,
286
+ TurnLimits,
287
+ )
288
+ return [
289
+ copy(capability)
290
+ for capability in capabilities
291
+ if not isinstance(capability, (*shared_types, ClearToolResults, DelegationReporting))
292
+ ]
293
+
294
+
295
+ def _delegation(
296
+ workspace: Path,
297
+ worker: Agent,
298
+ subagents: Sequence,
299
+ extensions: Sequence,
300
+ output_limits,
301
+ cache_notices: CacheBustReporting | None,
302
+ ) -> WorkspaceSubAgents:
303
+ """The `delegate_task` tool: the worker beside extension sub-agents."""
304
+
305
+ @asynccontextmanager
306
+ async def isolated_worker(child_workspace: Path):
307
+ if extensions and not isinstance(extensions, ExtensionCapabilities):
308
+ raise ValueError(
309
+ "Isolated workers require rebindable extensions from load_extensions(); "
310
+ "use workspace_mode='shared' for directly supplied capabilities."
311
+ )
312
+ rebound = (
313
+ extensions
314
+ if isinstance(extensions, ExtensionCapabilities)
315
+ else ExtensionCapabilities([])
316
+ )
317
+ with isolated_registry() as jobs:
318
+ async with rebound.for_workspace(child_workspace) as child_extensions:
319
+ child_coder = create_coder(child_workspace, delegation=False)
320
+ child_coder.capabilities.append(JobNotices(jobs))
321
+ yield _create_worker(child_coder.capabilities, child_extensions, isolated=True)
322
+
323
+ return WorkspaceSubAgents(
324
+ workspace=workspace,
325
+ worker_factory=isolated_worker,
326
+ agents=[
327
+ SubAgent(worker, usage_limits=SUBAGENT_USAGE_LIMITS),
328
+ *subagents,
329
+ ],
330
+ agent_folders=None,
331
+ # Unavailable names are left out here and reported by /subagents.
332
+ models=subagent_menu(subagent_models())[0],
333
+ event_stream_handler=stream_child_activity,
334
+ # One child's crash (a dropped connection past its retries, a bug in a
335
+ # tool) comes back to the parent as a retryable failure. Propagating it
336
+ # fails the parent's turn, which cancels every sibling delegation.
337
+ contain_errors=True,
338
+ shared_capabilities=[
339
+ # Extension sub-agents have no coder of their own to set it.
340
+ ClaudeWorkspace(workspace, fallback=True),
341
+ MeridianSessionIdentity(),
342
+ ModelOutputLimits(),
343
+ *([replace(cache_notices)] if cache_notices else []),
344
+ ProviderCacheSettings(),
345
+ replace(output_limits),
346
+ # A remote host's turn budget counts its sub-agents' work too.
347
+ *turn_limits(),
348
+ ],
349
+ )
350
+
351
+
352
+ def create_coder(
353
+ workspace: Path, subagents: Sequence = (), extensions: Sequence = (), *, delegation: bool = True
354
+ ) -> CombinedCapability:
355
+ """Compose Harness's Coder with pcode's repository context and planning.
356
+
357
+ `subagents` are extension-contributed Harness `SubAgent` entries, listed
358
+ beside the worker under the one `delegate_task` tool.
359
+ """
360
+ workspace = workspace.resolve()
361
+ _ensure_bundled_rg()
362
+ # Delegation is pcode's own `WorkspaceSubAgents`, added below.
363
+ coder = Coder(sub_agents=False)
364
+ output_limits = create_tool_output_limits()
365
+ # Keep Coder's tool selection, including its persistent shell (as a
366
+ # `JobShell`). File display and repository discovery remain local adapters;
367
+ # planning is now opt-in.
368
+ capabilities = [_adapt(c, workspace, output_limits) for c in coder.capabilities]
369
+ capabilities = [
370
+ # Ahead of the other capabilities in the list, so a tool call in a
371
+ # deleted workspace stops before anything tries to read or write in it.
372
+ WorkspaceGuard(workspace),
373
+ # Harness capabilities read and write through `ctx.workspace`. Commands
374
+ # bypass it (see `JobShell`), so it needs none of the host's environment.
375
+ LocalWorkspace(workspace),
376
+ *capabilities,
377
+ # Isolated workers build their own coder, so each CLI runs in its checkout.
378
+ ClaudeWorkspace(workspace),
379
+ IdentifiedPlanning(),
380
+ DelegationReporting(),
381
+ MeridianSessionIdentity(),
382
+ ModelOutputLimits(),
383
+ *turn_limits(),
384
+ ]
385
+ # Ahead of the worker copy below, so a delegate edits under the same schema.
386
+ if strict_tools := create_strict_tools():
387
+ capabilities.append(strict_tools)
388
+ # Ahead of the worker copy below, so a delegate that inherits MCP tools also
389
+ # learns which servers they come from.
390
+ capabilities.append(MCPServers(instruct=has_mcp_servers()))
391
+ if cache_notices := _cache_notices():
392
+ capabilities.append(cache_notices)
393
+
394
+ worker_capabilities = _worker_capabilities(capabilities)
395
+ if code_mode := create_code_mode():
396
+ capabilities.append(code_mode)
397
+ worker_capabilities.append(copy(code_mode))
398
+ if not delegation:
399
+ return CombinedCapability(worker_capabilities)
400
+
401
+ worker = _create_worker(worker_capabilities, extensions)
402
+ capabilities.append(
403
+ _delegation(workspace, worker, subagents, extensions, output_limits, cache_notices)
404
+ )
405
+ # Summarize evidence before discarding it; pcode owns compaction.
406
+ return CombinedCapability([c for c in capabilities if not isinstance(c, ClearToolResults)])
407
+
408
+
409
+ def _create_worker(
410
+ capabilities: Sequence, extensions: Sequence, *, isolated: bool = False
411
+ ) -> Agent:
412
+ return Agent(
413
+ name="worker",
414
+ retries=tool_retries(),
415
+ description=(
416
+ "Complete a self-contained task using the main agent's tools and permissions, "
417
+ "including file edits, shell commands, tests, web research, and enabled MCP tools"
418
+ ),
419
+ instructions=AGENT_INSTRUCTIONS
420
+ + (
421
+ " You are a general-purpose worker. Complete only the delegated task and report "
422
+ "your changes, verification, and remaining limitations. You inherit the main "
423
+ "agent's instructions, tools, and permission checks, but not its conversation. "
424
+ "Your plan is independent. You cannot delegate further or manage other task worktrees. "
425
+ "Stop jobs you no longer need with stop_job. "
426
+ )
427
+ + (
428
+ "You have an isolated checkout. Commit completed changes here; do not push or merge "
429
+ "into any other checkout. Parent integration and cleanup happen after you return."
430
+ if isolated
431
+ else "You share the parent's workspace: coordinate edits with the parent."
432
+ ),
433
+ capabilities=[*capabilities, *extensions],
434
+ toolsets=[WorkerRuntimeTools()],
435
+ )
436
+
437
+
438
+ def codex_model(model: str) -> OpenAICodexModel:
439
+ """Build a Codex model on pcode's stored login, else the CLI's `auth.json`.
440
+
441
+ Without a pcode store the provider reads the CLI file at construction time
442
+ and keeps refreshed tokens in memory only.
443
+ """
444
+ from pcode.codex_login import credential_source
445
+
446
+ proxy = os.environ.get("PCODE_LLM_PROXY", "").strip()
447
+ source = credential_source()
448
+ if proxy:
449
+ provider = ProxiedCodexProvider(proxy, credential_source=source)
450
+ elif source is not None:
451
+ provider = OpenAICodexProvider(credential_source=source)
452
+ else:
453
+ provider = None
454
+ # Subscription endpoints reject the explicit cache markers that Harness
455
+ # Planning adds after write_plan. Keep the native provider/auth/model name;
456
+ # override only this advertised capability (verified against AI 2.43.0).
457
+ # The tool-search deferral and addition modes come from the provider's
458
+ # profile, which layers `OpenAIProvider.model_profile` under the Codex
459
+ # dialect since AI 2.49.0 (pydantic/pydantic-ai#8693).
460
+ return OpenAICodexModel(
461
+ model.removeprefix("openai-codex:"),
462
+ profile=OpenAIModelProfile(openai_supports_prompt_cache_breakpoints=False),
463
+ **({"provider": provider} if provider is not None else {}),
464
+ )
465
+
466
+
467
+ def resolve_model(model: str) -> Model | str:
468
+ """The model object for a name, on pcode's own logins where it manages them.
469
+
470
+ Names pcode has no special handling for come back unchanged for Pydantic
471
+ AI's inference. So does an Anthropic name when API-key auth has no key yet,
472
+ which lets the terminal open and reach /login.
473
+ """
474
+ if model.startswith("openai-codex:"):
475
+ return codex_model(model)
476
+ if model.startswith("meridian:"):
477
+ from pcode.models import LEGACY_ANTHROPIC_AUTH
478
+
479
+ if not LEGACY_ANTHROPIC_AUTH:
480
+ name = model.removeprefix("meridian:")
481
+ raise ValueError(f"Meridian is turned off; switch to claude:{name} with /model.")
482
+ from pcode.meridian import meridian_model
483
+
484
+ return meridian_model(model)
485
+ if model.startswith("claude:"):
486
+ from pcode.claude_sdk import claude_model
487
+
488
+ return claude_model(model)
489
+ if not model.startswith("anthropic:"):
490
+ return model
491
+ from pcode.anthropic_oauth import anthropic_auth_source
492
+ from pcode.auth import anthropic_model
493
+
494
+ auth_source = anthropic_auth_source()
495
+ if auth_source == "oauth":
496
+ from pcode.anthropic_oauth import AnthropicOAuthModel
497
+
498
+ return AnthropicOAuthModel(model)
499
+ if auth_source == "api-key":
500
+ key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
501
+ return anthropic_model(model, key) if key else model
502
+ raise ValueError("PCODE_ANTHROPIC_AUTH must be api-key or oauth.")
503
+
504
+
505
+ @dataclass(frozen=True)
506
+ class SideModel:
507
+ """A model a single run uses instead of the agent's, with its own settings."""
508
+
509
+ name: str
510
+ model: Model
511
+ settings: dict | None
512
+
513
+
514
+ def side_model(name: str, effort: str = "") -> SideModel:
515
+ """Resolve `name` now, failing with a clear message rather than on first request.
516
+
517
+ The settings are the ones the model would get as the conversation's model:
518
+ its defaults plus its own saved /effort, or `effort` when one was asked
519
+ for. None of the conversation model's settings carry over, since they
520
+ belong to another model or provider.
521
+ """
522
+ from pydantic_ai.exceptions import UserError
523
+ from pydantic_ai.models import infer_model
524
+
525
+ from pcode.preferences import effort_for
526
+
527
+ try:
528
+ resolved = resolve_model(name)
529
+ if isinstance(resolved, str):
530
+ if name.startswith("anthropic:"):
531
+ from pcode.models import anthropic_credential_hint
532
+
533
+ raise ValueError(f"no Anthropic credentials. {anthropic_credential_hint()}")
534
+ resolved = infer_model(resolved)
535
+ except (UserError, ValueError, ImportError) as error:
536
+ raise ValueError(f"Cannot use {name}: {error}") from error
537
+ return SideModel(
538
+ name,
539
+ resolved,
540
+ with_effort(name, resolved, model_settings(name), effort or effort_for(name)),
541
+ )
542
+
543
+
544
+ def subagent_menu(names: Sequence[str]) -> tuple[dict[str, ModelOption], list[str]]:
545
+ """The `delegate_task` model menu for `names`, and why any name was left out.
546
+
547
+ Each option is resolved as /btw resolves another model: on pcode's own
548
+ logins, with that model's defaults and saved /effort. Omitting `model` in a
549
+ delegation still runs the sub-agent on the session's model.
550
+ """
551
+ menu: dict[str, ModelOption] = {}
552
+ problems: list[str] = []
553
+ for name in names:
554
+ try:
555
+ chosen = side_model(name)
556
+ except ValueError as error:
557
+ problems.append(str(error))
558
+ continue
559
+ menu[name] = ModelOption(chosen.model, settings=chosen.settings)
560
+ return menu, problems
561
+
562
+
563
+ def with_effort(name: str, model, settings: dict | None, effort: str | None) -> dict | None:
564
+ """`settings` with `effort` applied for `name` the way /effort applies it.
565
+
566
+ `settings` itself is never mutated: it may be captured by a run in flight.
567
+ """
568
+ from types import SimpleNamespace
569
+
570
+ from pcode.preferences import apply_effort
571
+
572
+ holder = SimpleNamespace(model=model, model_settings=settings)
573
+ apply_effort(holder, name, effort)
574
+ return holder.model_settings
575
+
576
+
577
+ def create_agent(
578
+ model: str, workspace: Path, extensions: Sequence = (), subagents: Sequence = ()
579
+ ) -> Agent:
580
+ """Build the terminal's agent; `extensions` and `subagents` come from `pcode.ext`."""
581
+ resolved = resolve_model(model)
582
+ # Allow the terminal to open so /login is reachable without credentials.
583
+ defer_model_check = model.startswith("anthropic:") and isinstance(resolved, str)
584
+ return Agent(
585
+ resolved,
586
+ defer_model_check=defer_model_check,
587
+ model_settings=model_settings(model),
588
+ name="pcode",
589
+ retries=tool_retries(),
590
+ instructions=AGENT_INSTRUCTIONS,
591
+ capabilities=[create_coder(workspace, subagents, extensions), *extensions],
592
+ )