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/meridian.py ADDED
@@ -0,0 +1,233 @@
1
+ """Anthropic-compatible Meridian transport with client-owned tool execution."""
2
+
3
+ import hashlib
4
+ import os
5
+ from dataclasses import replace
6
+ from functools import cached_property
7
+
8
+ import httpx2
9
+ from anthropic import AsyncAnthropic
10
+ from pydantic_ai import RunContext
11
+ from pydantic_ai.capabilities import AbstractCapability
12
+ from pydantic_ai.messages import ModelRequest, UserPromptPart
13
+ from pydantic_ai.models import ModelRequestContext
14
+ from pydantic_ai.models.anthropic import AnthropicModel
15
+ from pydantic_ai.providers.anthropic import AnthropicProvider
16
+
17
+ DEFAULT_BASE_URL = "http://127.0.0.1:3456"
18
+
19
+
20
+ def meridian_base_url() -> str:
21
+ value = os.environ.get("PCODE_MERIDIAN_BASE_URL", "").strip() or DEFAULT_BASE_URL
22
+ try:
23
+ url = httpx2.URL(value)
24
+ if url.scheme not in {"http", "https"} or not url.host or url.userinfo:
25
+ raise ValueError
26
+ if url.query or url.fragment:
27
+ raise ValueError
28
+ except (ValueError, httpx2.InvalidURL):
29
+ raise ValueError(
30
+ "PCODE_MERIDIAN_BASE_URL must be an HTTP(S) URL without credentials, query, or fragment"
31
+ ) from None
32
+ return value.rstrip("/")
33
+
34
+
35
+ def _failed_url(error: BaseException) -> str | None:
36
+ """URL of the request an SDK error came from, found along its cause chain."""
37
+ seen = set()
38
+ while error is not None and id(error) not in seen and len(seen) < 16:
39
+ seen.add(id(error))
40
+ try:
41
+ # httpx raises RuntimeError, not AttributeError, for an unset request.
42
+ url = getattr(getattr(error, "request", None), "url", None)
43
+ except Exception:
44
+ url = None
45
+ if url is not None:
46
+ return str(url)
47
+ error = error.__cause__ or error.__context__
48
+ return None
49
+
50
+
51
+ def failure_hint(error: BaseException) -> str | None:
52
+ """What to do about a failed Meridian request, or None for any other provider."""
53
+ from pcode.diagnostics import error_details, transport_types
54
+ from pcode.meridian_process import managed_base_url
55
+
56
+ url = _failed_url(error)
57
+ if url is None:
58
+ return None
59
+ managed = managed_base_url()
60
+ try:
61
+ external = meridian_base_url()
62
+ except ValueError:
63
+ external = None
64
+ base = next((b for b in (managed, external) if b and url.startswith(b + "/")), None)
65
+ if base is None:
66
+ return None
67
+ detail = error_details(error)
68
+ while detail and "status" not in detail:
69
+ detail = detail.get("cause", detail.get("context", {}))
70
+ if detail.get("status") == 401:
71
+ if "api key" in detail.get("provider_message", "").lower():
72
+ return f"Meridian at {base} rejected pcode's key. Check PCODE_MERIDIAN_API_KEY."
73
+ return "Meridian could not use your Claude login. Run /login meridian, then retry."
74
+ if transport_types(error) & {"APIConnectionError", "ConnectError"}:
75
+ if base == managed:
76
+ return "pcode's Meridian stopped and is being restarted. Retry in a moment."
77
+ return (
78
+ f"Meridian is not answering at {base}. Start it with `meridian`, or restart "
79
+ "pcode with meridian_managed set to auto or on so it runs its own."
80
+ )
81
+ return None
82
+
83
+
84
+ def thinking_passthrough(base_url: str, api_key: str | None) -> bool | None:
85
+ """Whether the proxy forwards readable thinking; None when it cannot say.
86
+
87
+ A read-only settings lookup with a short timeout: this never changes a
88
+ shared proxy's configuration.
89
+ """
90
+ headers = {"x-api-key": api_key} if api_key else {}
91
+ try:
92
+ response = httpx2.get(
93
+ base_url.rstrip("/") + "/settings/api/features",
94
+ headers=headers,
95
+ timeout=0.5,
96
+ trust_env=False,
97
+ )
98
+ value = response.json().get("passthrough", {}).get("thinkingPassthrough")
99
+ except (httpx2.HTTPError, ValueError, AttributeError):
100
+ return None
101
+ return value if isinstance(value, bool) else None
102
+
103
+
104
+ class MeridianProvider(AnthropicProvider):
105
+ @property
106
+ def name(self) -> str:
107
+ return "meridian"
108
+
109
+ def __init__(self) -> None:
110
+ from pcode.meridian_process import managed_endpoint
111
+
112
+ endpoint = managed_endpoint()
113
+ base_url = endpoint[0] if endpoint else meridian_base_url()
114
+ api_key = (
115
+ endpoint[1]
116
+ if endpoint
117
+ else os.environ.get("PCODE_MERIDIAN_API_KEY", "").strip() or "meridian-local"
118
+ )
119
+
120
+ # Never inherit upstream Anthropic credentials or global HTTP proxies.
121
+ def make_client():
122
+ return httpx2.AsyncClient(trust_env=False, timeout=600)
123
+
124
+ client = make_client()
125
+ super().__init__(
126
+ anthropic_client=AsyncAnthropic(
127
+ base_url=base_url,
128
+ api_key=api_key,
129
+ auth_token="",
130
+ http_client=client,
131
+ default_headers={"x-meridian-agent": "passthrough"},
132
+ # The SDK default of 2 would retry 408/409/429/5xx and connection
133
+ # errors silently, outside the runtime's visible retry budget and
134
+ # against the transient-error policy in `diagnostics.py`. The
135
+ # runtime owns transport retries here, as it does for API keys.
136
+ max_retries=0,
137
+ )
138
+ )
139
+ # Empty token prevents environment lookup during construction; None then
140
+ # suppresses the SDK's otherwise empty Bearer header on requests.
141
+ self.client.auth_token = None
142
+ self._own_http_client = client
143
+ self._http_client_factory = make_client
144
+
145
+
146
+ class MeridianModel(AnthropicModel):
147
+ @cached_property
148
+ def profile(self):
149
+ # Meridian answers any Anthropic server tool (`web_search_*`, `web_fetch_*`)
150
+ # with a 400: the Claude Max path cannot emit server_tool_use blocks. Drop
151
+ # them from the profile so the web capabilities fall back to local tools.
152
+ #
153
+ # Tool search fails quietly rather than with a 400. Meridian re-registers
154
+ # client tools with the Agent SDK, which drops `defer_loading` and
155
+ # `tool_reference`: the model sees every deferred schema, but no search
156
+ # result it could produce counts as discovery, so Pydantic AI refuses each
157
+ # deferred call as "not available yet". Use the local `search_tools` and
158
+ # no wire deferral: hidden tools are withheld, then sent in full once found.
159
+ from pydantic_ai.native_tools import WebFetchTool, WebSearchTool
160
+ from pydantic_ai.native_tools._tool_search import ToolSearchTool
161
+ from pydantic_ai.profiles import SUPPORTED_NATIVE_TOOLS, merge_profile
162
+ from pydantic_ai.profiles.anthropic import AnthropicModelProfile
163
+
164
+ profile = super().profile
165
+ native = profile.get("supported_native_tools", SUPPORTED_NATIVE_TOOLS)
166
+ unsupported = {WebSearchTool, WebFetchTool, ToolSearchTool}
167
+ return merge_profile(
168
+ profile,
169
+ AnthropicModelProfile(
170
+ supported_native_tools=native - unsupported,
171
+ tool_deferral_mode=None,
172
+ tool_addition_mode=None,
173
+ ),
174
+ )
175
+
176
+
177
+ def meridian_model(model: str) -> AnthropicModel:
178
+ name = model.removeprefix("meridian:")
179
+ if not name.strip():
180
+ raise ValueError("Meridian requires a model ID: meridian:<model-id>")
181
+ return MeridianModel(name, provider=MeridianProvider())
182
+
183
+
184
+ def compaction_summary(messages) -> str | None:
185
+ """The summary text when compaction has replaced the start of `messages`."""
186
+ from pcode.compaction import SUMMARY_PREFIX
187
+
188
+ first = messages[0] if messages else None
189
+ if not isinstance(first, ModelRequest):
190
+ return None
191
+ for part in first.parts:
192
+ if isinstance(part, UserPromptPart) and isinstance(part.content, str):
193
+ if part.content.startswith(SUMMARY_PREFIX):
194
+ return part.content
195
+ return None
196
+
197
+
198
+ def session_identity(conversation_id: str, messages) -> str:
199
+ """Meridian session for this history, which changes each time it is compacted.
200
+
201
+ Meridian classifies a compacted history whose recent messages still match as a
202
+ continuation and resumes the uncompacted transcript, so the summary never
203
+ reaches the model. A session keyed on the summary starts fresh instead, once
204
+ per compaction; an uncompacted history keeps the plain conversation ID.
205
+ """
206
+ summary = compaction_summary(messages)
207
+ if summary is None:
208
+ return conversation_id
209
+ return f"{conversation_id}.{hashlib.sha256(summary.encode()).hexdigest()[:16]}"
210
+
211
+
212
+ class MeridianSessionIdentity(AbstractCapability):
213
+ """Bind requests, not shared clients, to the current conversation.
214
+
215
+ Harness children inherit the model but get fresh Pydantic conversation IDs.
216
+ This also preserves identity across saved-session resume and model switches,
217
+ and moves to a new session after compaction (see `session_identity`).
218
+ """
219
+
220
+ async def before_model_request(
221
+ self, ctx: RunContext, request_context: ModelRequestContext
222
+ ) -> ModelRequestContext:
223
+ if request_context.model.system != "meridian":
224
+ return request_context
225
+ settings = dict(request_context.model_settings or {})
226
+ headers = dict(settings.get("extra_headers") or {})
227
+ # Headers are case-insensitive; never leave an alternate-cased stale ID.
228
+ headers = {k: v for k, v in headers.items() if k.lower() != "x-litellm-session-id"}
229
+ headers["x-litellm-session-id"] = session_identity(
230
+ ctx.conversation_id, request_context.messages
231
+ )
232
+ settings["extra_headers"] = headers
233
+ return replace(request_context, model_settings=settings)
@@ -0,0 +1,337 @@
1
+ """Process-owned Meridian instances (not a shared daemon)."""
2
+
3
+ import atexit
4
+ import json
5
+ import os
6
+ import re
7
+ import secrets
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import httpx2
17
+
18
+ from pcode.preferences import load_preferences
19
+
20
+ # Oldest release whose configuration contract this module relies on
21
+ # (`sdk-features.json`, `/settings/api/features`, the MERIDIAN_* variables below).
22
+ # Newer releases start as long as they pass the same readiness checks.
23
+ MINIMUM_VERSION = "1.71.1"
24
+ MODES = ("auto", "on", "off")
25
+ WATCH_INTERVAL_SECONDS = 1.0
26
+ # A crash loop is not worth chasing: stop after this many restarts in the window.
27
+ MAX_RESTARTS = 3
28
+ RESTART_WINDOW_SECONDS = 300.0
29
+ _lock = threading.Lock()
30
+ _instance = None
31
+
32
+
33
+ def version_tuple(text: str | None) -> tuple[int, ...] | None:
34
+ match = re.search(r"\d+(?:\.\d+)+", text or "")
35
+ return tuple(int(part) for part in match.group().split(".")) if match else None
36
+
37
+
38
+ def supported(version: str | None) -> bool:
39
+ found = version_tuple(version)
40
+ return found is not None and found >= version_tuple(MINIMUM_VERSION)
41
+
42
+
43
+ def meridian_config_dir() -> Path:
44
+ """The user's own Meridian configuration (profiles, settings)."""
45
+ return Path(os.environ.get("MERIDIAN_CONFIG_DIR") or Path.home() / ".config" / "meridian")
46
+
47
+
48
+ def meridian_profiles() -> list[dict]:
49
+ """Account profiles from the user's `profiles.json`; tokens are never read out."""
50
+ try:
51
+ data = json.loads((meridian_config_dir() / "profiles.json").read_text())
52
+ except (OSError, ValueError):
53
+ return []
54
+ if not isinstance(data, list):
55
+ return []
56
+ return [p for p in data if isinstance(p, dict) and isinstance(p.get("id"), str)]
57
+
58
+
59
+ def profile_dir(profile: dict) -> str:
60
+ """The Claude config directory whose login a profile uses."""
61
+ configured = profile.get("claudeConfigDir")
62
+ if isinstance(configured, str) and configured:
63
+ return configured
64
+ return str(meridian_config_dir() / "profiles" / profile["id"])
65
+
66
+
67
+ def default_profile() -> dict | None:
68
+ """The profile a managed instance uses: the saved active one, else the first."""
69
+ profiles = meridian_profiles()
70
+ try:
71
+ settings = json.loads((meridian_config_dir() / "settings.json").read_text())
72
+ saved = settings.get("activeProfile") if isinstance(settings, dict) else None
73
+ except (OSError, ValueError):
74
+ saved = None
75
+ return next((p for p in profiles if p["id"] == saved), profiles[0] if profiles else None)
76
+
77
+
78
+ def session_store_dir() -> Path:
79
+ """Meridian's session store, kept across pcode runs so resumes stay warm.
80
+
81
+ With a temporary store a restarted Meridian no longer recognises the
82
+ conversation and replays its whole history as flattened text. Meridian locks
83
+ the store, so concurrent pcode processes can share it.
84
+ """
85
+ state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
86
+ return state / "pcode" / "meridian" / "sessions"
87
+
88
+
89
+ class ManagedMeridian:
90
+ def __init__(self):
91
+ self.process = None
92
+ self.directory = None
93
+ self.executable = None
94
+ self.port = None
95
+ self.base_url = ""
96
+ self.api_key = secrets.token_urlsafe(32)
97
+ self.profile: str | None = None
98
+ self.restarts: list[float] = []
99
+ self.failure: str | None = None
100
+ self._lock = threading.RLock()
101
+ self._stopped = threading.Event()
102
+ self._watchdog = None
103
+
104
+ def start(self):
105
+ executable = shutil.which("meridian")
106
+ if not executable:
107
+ raise ValueError("Managed Meridian requires meridian on PATH; install it first.")
108
+ try:
109
+ version = subprocess.run(
110
+ [executable, "--version"], capture_output=True, text=True, timeout=10, check=True
111
+ ).stdout.strip()
112
+ except (OSError, subprocess.SubprocessError):
113
+ raise ValueError("Could not verify the installed Meridian version.") from None
114
+ if not supported(version):
115
+ raise ValueError(
116
+ f"Managed Meridian needs {MINIMUM_VERSION} or newer "
117
+ f"(found {version or 'unknown'}). Run `pcode --upgrade-meridian`."
118
+ )
119
+ self.executable = executable
120
+ self.directory = tempfile.TemporaryDirectory(prefix="pcode-meridian-")
121
+ root = Path(self.directory.name)
122
+ (root / "sdk-features.json").write_text(
123
+ json.dumps({"passthrough": {"thinkingPassthrough": True}})
124
+ )
125
+ (root / "plugins").mkdir()
126
+ (root / "plugins.json").write_text("[]")
127
+ (root / "workdir").mkdir()
128
+ profile = default_profile()
129
+ if profile is not None:
130
+ # 1.72 reads profiles from ~/.config/meridian whatever MERIDIAN_CONFIG_DIR
131
+ # says, while 1.76 reads them from the config directory. Link them in
132
+ # (never copy: a profile can hold a token) so every release sees the
133
+ # same ones, and name the profile so /login meridian targets its login.
134
+ for name in ("profiles.json", "profiles"):
135
+ source = meridian_config_dir() / name
136
+ if source.exists():
137
+ (root / name).symlink_to(source)
138
+ self.profile = profile["id"]
139
+ session_store_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
140
+ with socket.socket() as sock:
141
+ sock.bind(("127.0.0.1", 0))
142
+ self.port = sock.getsockname()[1]
143
+ # The port and key stay fixed across restarts, so clients built against
144
+ # this instance keep working after the watchdog replaces the process.
145
+ self.base_url = f"http://127.0.0.1:{self.port}"
146
+ try:
147
+ self._spawn()
148
+ except BaseException:
149
+ self.close()
150
+ raise
151
+ self._watchdog = threading.Thread(
152
+ target=self._watch, name="pcode-meridian-watchdog", daemon=True
153
+ )
154
+ self._watchdog.start()
155
+ return self
156
+
157
+ def _spawn(self):
158
+ root = Path(self.directory.name)
159
+ # Do not inherit shared proxy routing, plugins, or administrative settings.
160
+ # HOME and Claude credentials remain unchanged: Meridian owns upstream auth.
161
+ env = {
162
+ k: v for k, v in os.environ.items() if not k.startswith(("MERIDIAN_", "CLAUDE_PROXY_"))
163
+ }
164
+ env.update(
165
+ {
166
+ "MERIDIAN_HOST": "127.0.0.1",
167
+ "MERIDIAN_PORT": str(self.port),
168
+ "MERIDIAN_API_KEY": self.api_key,
169
+ "MERIDIAN_CONFIG_DIR": str(root),
170
+ "MERIDIAN_SESSION_DIR": str(session_store_dir()),
171
+ "MERIDIAN_PLUGIN_DIR": str(root / "plugins"),
172
+ "MERIDIAN_PLUGIN_CONFIG": str(root / "plugins.json"),
173
+ "MERIDIAN_DESIGN_TOKEN_PATH": str(root / "design-token.json"),
174
+ "MERIDIAN_NO_UPDATE_CHECK": "1",
175
+ "MERIDIAN_TELEMETRY_PERSIST": "0",
176
+ "MERIDIAN_PASSTHROUGH": "1",
177
+ # Claude Code counts the files under its cwd on every start
178
+ # (`rg --files --hidden`, skipped only for $HOME), and Meridian
179
+ # starts one per request. Tools run in pcode, so give it an
180
+ # empty directory rather than wherever pcode was launched.
181
+ "MERIDIAN_WORKDIR": str(root / "workdir"),
182
+ }
183
+ )
184
+ if self.profile:
185
+ env["MERIDIAN_DEFAULT_PROFILE"] = self.profile
186
+ # Never let server output corrupt the terminal or expose credentials.
187
+ self.process = subprocess.Popen(
188
+ [self.executable],
189
+ env=env,
190
+ cwd=root / "workdir",
191
+ stdin=subprocess.DEVNULL,
192
+ stdout=subprocess.DEVNULL,
193
+ stderr=subprocess.DEVNULL,
194
+ )
195
+ try:
196
+ self.wait_ready()
197
+ except BaseException:
198
+ self._stop_process()
199
+ raise
200
+
201
+ def wait_ready(self):
202
+ deadline = time.monotonic() + 30
203
+ with httpx2.Client(trust_env=False, timeout=2) as client:
204
+ while time.monotonic() < deadline:
205
+ if self.process.poll() is not None:
206
+ raise ValueError(
207
+ "Managed Meridian exited during startup (possibly a port conflict)."
208
+ )
209
+ try:
210
+ headers = {"x-api-key": self.api_key}
211
+ health = client.get(self.base_url + "/health", headers=headers).json()
212
+ if health.get("auth", {}).get("loggedIn") is False:
213
+ raise ValueError(
214
+ "Meridian is not logged in. Run /login meridian, then retry."
215
+ )
216
+ if health.get("status") == "healthy" and supported(health.get("version")):
217
+ response = client.get(
218
+ self.base_url + "/settings/api/features", headers=headers
219
+ )
220
+ response.raise_for_status()
221
+ if (
222
+ response.json().get("passthrough", {}).get("thinkingPassthrough")
223
+ is not True
224
+ ):
225
+ raise ValueError(
226
+ "Managed Meridian did not enable thinking passthrough."
227
+ )
228
+ return
229
+ except (httpx2.HTTPError, json.JSONDecodeError):
230
+ pass
231
+ time.sleep(0.1)
232
+ raise ValueError("Managed Meridian was not ready within 30 seconds; check Claude login.")
233
+
234
+ def ensure_running(self):
235
+ """Replace an exited process on the same port, within the restart budget."""
236
+ with self._lock:
237
+ if self.failure:
238
+ raise ValueError(self.failure)
239
+ if self._stopped.is_set():
240
+ raise ValueError("Managed Meridian was stopped. Restart pcode.")
241
+ if self.process is not None and self.process.poll() is None:
242
+ return
243
+ now = time.monotonic()
244
+ self.restarts = [t for t in self.restarts if now - t < RESTART_WINDOW_SECONDS]
245
+ if len(self.restarts) >= MAX_RESTARTS:
246
+ self.failure = (
247
+ "Managed Meridian keeps exiting. Run `meridian` in a terminal to see why, "
248
+ "then restart pcode."
249
+ )
250
+ raise ValueError(self.failure)
251
+ self.restarts.append(now)
252
+ self._spawn()
253
+
254
+ def _watch(self):
255
+ # Requests in flight when the process died are not replayed; the
256
+ # runtime's own retry, or the next prompt, reaches the replacement.
257
+ while not self._stopped.wait(WATCH_INTERVAL_SECONDS):
258
+ try:
259
+ self.ensure_running()
260
+ except ValueError:
261
+ if self.failure:
262
+ return
263
+
264
+ def _stop_process(self):
265
+ if self.process is not None:
266
+ if self.process.poll() is None:
267
+ self.process.terminate()
268
+ try:
269
+ self.process.wait(timeout=5)
270
+ except subprocess.TimeoutExpired:
271
+ self.process.kill()
272
+ self.process.wait(timeout=5)
273
+ self.process = None
274
+
275
+ def close(self):
276
+ self._stopped.set()
277
+ with self._lock:
278
+ self._stop_process()
279
+ if self.directory is not None:
280
+ self.directory.cleanup()
281
+ self.directory = None
282
+
283
+
284
+ def managed_mode() -> str:
285
+ """`on`, `off`, or `auto`; `PCODE_MERIDIAN_MANAGED` overrides the saved choice."""
286
+ override = os.environ.get("PCODE_MERIDIAN_MANAGED", "").strip()
287
+ if override:
288
+ if override not in {"0", "1"}:
289
+ raise ValueError("PCODE_MERIDIAN_MANAGED must be 0 or 1.")
290
+ return "on" if override == "1" else "off"
291
+ mode = load_preferences().get("meridian_managed", "auto")
292
+ return mode if mode in MODES else "auto"
293
+
294
+
295
+ def external_proxy_running(base_url: str) -> bool:
296
+ """True when something that looks like Meridian answers at `base_url`."""
297
+ try:
298
+ response = httpx2.get(base_url + "/health", timeout=1, trust_env=False)
299
+ except httpx2.HTTPError:
300
+ return False
301
+ if response.status_code in (401, 403):
302
+ return True # A key-protected proxy still counts as the user's own.
303
+ try:
304
+ return "status" in response.json()
305
+ except (ValueError, AttributeError, TypeError):
306
+ return False
307
+
308
+
309
+ def managed_base_url() -> str | None:
310
+ """The owned instance's URL, if this process started one."""
311
+ instance = _instance
312
+ return instance.base_url if instance is not None else None
313
+
314
+
315
+ def managed_endpoint():
316
+ """Return private connection details, or None to use an external proxy.
317
+
318
+ `auto` prefers a proxy already answering at the default address, then starts
319
+ a private instance when `meridian` is installed.
320
+ """
321
+ if os.environ.get("PCODE_MERIDIAN_BASE_URL", "").strip():
322
+ return None
323
+ mode = managed_mode()
324
+ if mode == "off":
325
+ return None
326
+ global _instance
327
+ with _lock:
328
+ if _instance is None:
329
+ if mode == "auto":
330
+ from pcode.meridian import DEFAULT_BASE_URL
331
+
332
+ if shutil.which("meridian") is None or external_proxy_running(DEFAULT_BASE_URL):
333
+ return None
334
+ _instance = ManagedMeridian().start()
335
+ atexit.register(_instance.close)
336
+ _instance.ensure_running()
337
+ return _instance.base_url, _instance.api_key
@@ -0,0 +1,107 @@
1
+ """Durable reminder helper and append-only limit warnings for Meridian and Claude Code."""
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from dataclasses import replace
6
+
7
+ from pydantic_ai.messages import ModelRequest, UserPromptPart
8
+ from pydantic_ai_harness.compaction import WarnNearLimits
9
+
10
+ from pcode.model_metadata import ContextWindowError, context_window, refresh_context
11
+
12
+ LIMITS_TAG = "[WarnNearLimits]"
13
+
14
+
15
+ def last_reminder(messages, tag: str) -> str | None:
16
+ """Return the most recent reminder text for `tag`, or None when none was sent.
17
+
18
+ Detection is by content, not message metadata: Pydantic AI merges consecutive
19
+ `ModelRequest`s when history is resumed and keeps only its own reserved
20
+ metadata namespace, so a marker stored there survives a run but not a resume.
21
+ Only a `UserPromptPart` that *starts* with the tag counts, so a tool result or
22
+ quoted prompt that merely mentions it is not mistaken for a sent reminder.
23
+ """
24
+ for message in reversed(messages):
25
+ if not isinstance(message, ModelRequest):
26
+ continue
27
+ for part in reversed(message.parts):
28
+ content = getattr(part, "content", None)
29
+ if isinstance(part, UserPromptPart) and isinstance(content, str):
30
+ if content.startswith(tag):
31
+ return content
32
+ return None
33
+
34
+
35
+ def append_reminder(
36
+ request_context, tag: str, text: str, normalize: Callable[[str], str] | None = None
37
+ ) -> None:
38
+ """Append `text` as durable history unless the last reminder already says it.
39
+
40
+ Appending, rather than moving a mutable tail, keeps previously sent messages
41
+ byte-identical so the provider's cached prefix stays reusable as the
42
+ conversation grows. `normalize` collapses differences that should not count
43
+ as a change (for example, warning percentages within the same decile).
44
+ """
45
+ messages = request_context.messages
46
+ if not messages or not isinstance(messages[-1], ModelRequest):
47
+ return
48
+ previous = last_reminder(messages, tag)
49
+ key = normalize or (lambda value: value)
50
+ if previous is not None and key(previous) == key(text):
51
+ return
52
+ messages.append(ModelRequest(parts=[UserPromptPart(content=text)]))
53
+
54
+
55
+ def _decile_key(text: str) -> str:
56
+ """Treat percentages within the same decile as the same warning."""
57
+ return re.sub(r"\d+", "#", text) + str([int(p) // 10 for p in re.findall(r"(\d+)%", text)])
58
+
59
+
60
+ # Providers that keep the history themselves, where editing a sent message
61
+ # forces a replay: Meridian's lineage check and the `claude:` CLI transcript.
62
+ APPEND_ONLY_SYSTEMS = frozenset({"meridian", "claude"})
63
+
64
+
65
+ class MeridianLimitWarnings(WarnNearLimits):
66
+ """Warn against pcode's resolved window, and never remove text already sent
67
+ to a provider that keeps the history (`APPEND_ONLY_SYSTEMS`)."""
68
+
69
+ async def _for_model(self, model) -> WarnNearLimits:
70
+ """This capability, bound to the window the footer and compaction use.
71
+
72
+ Harness resolves windows from genai-prices, which knows no `meridian:` ids
73
+ and silently assumes 200k (a 1M Meridian session was told it was 90% full
74
+ at 162k), and gives `openai-codex:` the direct API's window by model name
75
+ (1.05M where Codex serves 272k). Explicit windows and models pcode cannot
76
+ resolve keep Harness's own resolution.
77
+ """
78
+ if self.max_context_fraction is None or self.context_window is not None:
79
+ return self
80
+ await refresh_context(model)
81
+ try:
82
+ window = context_window(model)
83
+ except ContextWindowError:
84
+ # Compaction reports the actionable configuration error.
85
+ window = None
86
+ return self if window is None else replace(self, context_window=window)
87
+
88
+ async def before_model_request(self, ctx, request_context):
89
+ warnings = await self._for_model(request_context.model)
90
+ if request_context.model.system not in APPEND_ONLY_SYSTEMS:
91
+ return await WarnNearLimits.before_model_request(warnings, ctx, request_context)
92
+ original = request_context.messages
93
+ if not original or not isinstance(original[-1], ModelRequest):
94
+ return request_context
95
+ # Upstream strips prior warnings and appends exactly one request when a
96
+ # threshold fires. Evaluate on a disposable list; retain the real history.
97
+ clean_count = len(self._strip_old_warnings(original))
98
+ candidate = await WarnNearLimits.before_model_request(
99
+ warnings, ctx, replace(request_context, messages=list(original))
100
+ )
101
+ if len(candidate.messages) > clean_count:
102
+ # Warn at percentage deciles rather than on every token increase.
103
+ # Keep severity and warning kinds so new limits/escalations still fire.
104
+ append_reminder(
105
+ request_context, LIMITS_TAG, candidate.messages[-1].parts[0].content, _decile_key
106
+ )
107
+ return request_context