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
@@ -0,0 +1,336 @@
1
+ """Name what moved the cacheable prefix when a cache collapse fires.
2
+
3
+ A collapse warning reports only the provider's token verdict, which cannot say
4
+ whether the prompt prefix was rewritten or the cache simply expired. This keeps a
5
+ small rolling fingerprint of each model request so the next collapse answers that
6
+ question partially: unchanged fingerprints leave the cause unknown, and a
7
+ changed prefix names the message index that moved.
8
+
9
+ Fingerprints are content-free by construction: digests, sizes and kinds only,
10
+ never prompt text. Prompts carry file contents and command output, so writing them
11
+ to a debug file would leak exactly what the rest of pcode refuses to log.
12
+ """
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import time
18
+ from collections import deque
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from pydantic_ai.messages import CachePoint
24
+
25
+ from pcode.diagnostics import versions
26
+
27
+ HISTORY = 8
28
+ """Requests retained per run. A collapse is diagnosed from the preceding request,
29
+ so this only needs enough depth to show the trend that led into it."""
30
+
31
+ CACHE_SETTINGS = (
32
+ "anthropic_cache",
33
+ "anthropic_cache_instructions",
34
+ "anthropic_cache_tool_definitions",
35
+ )
36
+
37
+
38
+ def diagnostics_root() -> Path:
39
+ """`PCODE_CACHE_DIAGNOSTICS` overrides the directory; `off` disables dumps."""
40
+ override = os.environ.get("PCODE_CACHE_DIAGNOSTICS", "").strip()
41
+ if override and override.lower() != "off":
42
+ return Path(override).expanduser()
43
+ state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
44
+ return state / "pcode" / "cache-diagnostics"
45
+
46
+
47
+ def enabled() -> bool:
48
+ return os.environ.get("PCODE_CACHE_DIAGNOSTICS", "").strip().lower() != "off"
49
+
50
+
51
+ def _digest(text: str) -> str:
52
+ return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:12]
53
+
54
+
55
+ def _content_text(value) -> str:
56
+ """Render content for hashing, marking cache points so a moved one is visible."""
57
+ if value is None:
58
+ return ""
59
+ if isinstance(value, str):
60
+ return value
61
+ if isinstance(value, CachePoint):
62
+ return f"<cache-point:{value.ttl}>"
63
+ if isinstance(value, (list, tuple)):
64
+ return "".join(_content_text(item) for item in value)
65
+ # Binary and multimodal parts have no stable text; size and type still detect
66
+ # a change without pulling the payload into the digest.
67
+ data = getattr(value, "data", None)
68
+ size = len(data) if isinstance(data, (bytes, bytearray)) else ""
69
+ return f"<{type(value).__name__}:{size}>"
70
+
71
+
72
+ def _cache_points(value) -> int:
73
+ if isinstance(value, CachePoint):
74
+ return 1
75
+ if isinstance(value, (list, tuple)):
76
+ return sum(_cache_points(item) for item in value)
77
+ return 0
78
+
79
+
80
+ def _part_text(part) -> str:
81
+ content = getattr(part, "content", None)
82
+ if content is None and hasattr(part, "args"):
83
+ # Tool calls carry their payload in `args`; `args_as_json_str` normalizes
84
+ # the dict/str union so an unchanged call hashes identically either way.
85
+ try:
86
+ content = part.args_as_json_str()
87
+ except Exception:
88
+ content = repr(part.args)
89
+ kind = getattr(part, "part_kind", type(part).__name__)
90
+ name = getattr(part, "tool_name", "") or ""
91
+ return f"{kind}|{name}|{_content_text(content)}"
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class MessageFingerprint:
96
+ kind: str
97
+ parts: tuple[str, ...]
98
+ chars: int
99
+ digest: str
100
+ cache_points: int
101
+
102
+ def moved(self, other: "MessageFingerprint") -> bool:
103
+ return self.kind != other.kind or self.digest != other.digest
104
+
105
+ def as_dict(self) -> dict:
106
+ return {
107
+ "kind": self.kind,
108
+ "parts": list(self.parts),
109
+ "chars": self.chars,
110
+ "digest": self.digest,
111
+ "cache_points": self.cache_points,
112
+ }
113
+
114
+
115
+ def fingerprint_message(message) -> MessageFingerprint:
116
+ parts = tuple(getattr(part, "part_kind", type(part).__name__) for part in message.parts)
117
+ text = "\n".join(_part_text(part) for part in message.parts)
118
+ points = sum(_cache_points(getattr(part, "content", None)) for part in message.parts)
119
+ return MessageFingerprint(
120
+ kind=getattr(message, "kind", type(message).__name__),
121
+ parts=parts,
122
+ chars=len(text),
123
+ digest=_digest(text),
124
+ cache_points=points,
125
+ )
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class RequestFingerprint:
130
+ step: int
131
+ model: str
132
+ at: float
133
+ instructions: str
134
+ instruction_chars: int
135
+ tools: str
136
+ tool_names: tuple[str, ...]
137
+ settings: dict
138
+ messages: tuple[MessageFingerprint, ...]
139
+ cache_read: int
140
+ cache_write: int
141
+ input_tokens: int
142
+
143
+ @property
144
+ def cache_point_indexes(self) -> list[int]:
145
+ return [i for i, message in enumerate(self.messages) if message.cache_points]
146
+
147
+ def as_dict(self) -> dict:
148
+ return {
149
+ "step": self.step,
150
+ "model": self.model,
151
+ "at": self.at,
152
+ "instructions": self.instructions,
153
+ "instruction_chars": self.instruction_chars,
154
+ "tools": self.tools,
155
+ "tool_names": list(self.tool_names),
156
+ "settings": self.settings,
157
+ "cache_read": self.cache_read,
158
+ "cache_write": self.cache_write,
159
+ "input_tokens": self.input_tokens,
160
+ "cache_point_indexes": self.cache_point_indexes,
161
+ "messages": [message.as_dict() for message in self.messages],
162
+ }
163
+
164
+
165
+ def _sent_tools(request_context) -> list[tuple[Any, str]]:
166
+ """Each tool whose definition the request carries, with how the provider sends it.
167
+
168
+ A deferred tool stays in `function_tools` whether or not tool search has revealed
169
+ it, so the list alone cannot see a reveal. Whether a reveal moves the prefix is the
170
+ provider's call: one that withholds hidden schemas (`claude:`) adds the definition
171
+ ahead of every cached message, while native deferral keeps sending it as deferred.
172
+ The model's own `prepare_request` resolves that, exactly as the request did.
173
+ """
174
+ parameters = request_context.model_request_parameters
175
+ model = getattr(request_context, "model", None)
176
+ if getattr(parameters, "tool_visibility", None) is None and model is not None:
177
+ try:
178
+ _, parameters = model.prepare_request(request_context.model_settings, parameters)
179
+ except Exception:
180
+ parameters = request_context.model_request_parameters
181
+ visibility_of = getattr(parameters, "visibility_of", None)
182
+ sent = []
183
+ for tool in [
184
+ *(getattr(parameters, "function_tools", None) or []),
185
+ *(getattr(parameters, "output_tools", None) or []),
186
+ ]:
187
+ visibility = visibility_of(tool.name) if visibility_of is not None else "visible"
188
+ if visibility in ("visible", "deferred"):
189
+ sent.append((tool, visibility))
190
+ return sent
191
+
192
+
193
+ def fingerprint(request_context, response, step: int) -> RequestFingerprint:
194
+ parameters = request_context.model_request_parameters
195
+ instruction_text = "\n".join(
196
+ _content_text(getattr(part, "content", None))
197
+ for part in (getattr(parameters, "instruction_parts", None) or [])
198
+ )
199
+ sent = _sent_tools(request_context)
200
+ tools = [tool for tool, _ in sent]
201
+ # Definitions reach the provider in list order: a reordered toolset invalidates
202
+ # the cached block just as an edited schema does, so order stays in the digest.
203
+ tool_text = "\n".join(
204
+ f"{tool.name}|{visibility}|{tool.description or ''}|"
205
+ f"{json.dumps(tool.parameters_json_schema, sort_keys=True, default=str)}"
206
+ for tool, visibility in sent
207
+ )
208
+ settings = request_context.model_settings or {}
209
+ usage = response.usage
210
+ return RequestFingerprint(
211
+ step=step,
212
+ model="/".join(part for part in (response.provider_name, response.model_name) if part),
213
+ at=time.time(),
214
+ instructions=_digest(instruction_text),
215
+ instruction_chars=len(instruction_text),
216
+ tools=_digest(tool_text),
217
+ tool_names=tuple(tool.name for tool in tools),
218
+ settings={key: settings[key] for key in CACHE_SETTINGS if key in settings},
219
+ messages=tuple(fingerprint_message(message) for message in request_context.messages),
220
+ cache_read=usage.cache_read_tokens,
221
+ cache_write=usage.cache_write_tokens,
222
+ input_tokens=usage.input_tokens,
223
+ )
224
+
225
+
226
+ def divergence(previous: RequestFingerprint, current: RequestFingerprint) -> str:
227
+ """Describe the first prefix change, or report the prefix as intact.
228
+
229
+ Order matters: instructions and tool definitions sit ahead of every message, so
230
+ a change there explains a collapse that a message-level diff would misattribute.
231
+
232
+ Read this line and the dumped fingerprints before theorizing about Harness's
233
+ own warning text. Its generic suffix ``(e.g. a gap longer than the cache TTL)``
234
+ means the gap was *under* the TTL: Harness names the measured gap when it
235
+ actually exceeds it. Its ``model request N`` is per-run, not per-session.
236
+ """
237
+ if previous.instructions != current.instructions:
238
+ return (
239
+ "Instructions changed "
240
+ f"({previous.instruction_chars} -> {current.instruction_chars} chars): "
241
+ "request prefix differs."
242
+ )
243
+ if previous.tools != current.tools:
244
+ added = [name for name in current.tool_names if name not in previous.tool_names]
245
+ removed = [name for name in previous.tool_names if name not in current.tool_names]
246
+ detail = ", ".join(
247
+ part
248
+ for part in (
249
+ f"added {', '.join(added)}" if added else "",
250
+ f"removed {', '.join(removed)}" if removed else "",
251
+ )
252
+ if part
253
+ )
254
+ return "Tool definitions changed" + (
255
+ f" ({detail})." if detail else " (same names, edited schema or order)."
256
+ )
257
+ if previous.settings != current.settings:
258
+ return f"Cache settings changed ({previous.settings} -> {current.settings})."
259
+ shared = min(len(previous.messages), len(current.messages))
260
+ for index in range(shared):
261
+ before, after = previous.messages[index], current.messages[index]
262
+ if before.moved(after):
263
+ return (
264
+ f"Message {index} of {len(previous.messages)} changed "
265
+ f"(kind {before.kind} -> {after.kind}, {before.chars} -> {after.chars} chars, "
266
+ f"cache points {before.cache_points} -> {after.cache_points}). "
267
+ f"{len(previous.messages) - index} prior message(s) from that index."
268
+ )
269
+ if len(current.messages) < len(previous.messages):
270
+ return (
271
+ f"History shrank from {len(previous.messages)} to {len(current.messages)} "
272
+ "messages: the tail was dropped or rewritten by a capability."
273
+ )
274
+ appended = len(current.messages) - len(previous.messages)
275
+ points = (
276
+ f" Cache points moved {previous.cache_point_indexes} -> {current.cache_point_indexes}."
277
+ if previous.cache_point_indexes != current.cache_point_indexes
278
+ else ""
279
+ )
280
+ gap = current.at - previous.at
281
+ return (
282
+ f"Request fingerprints unchanged; {appended} messages appended (~{gap:.0f}s gap), "
283
+ f"cause unknown.{points}"
284
+ )
285
+
286
+
287
+ @dataclass
288
+ class CacheDiagnostics:
289
+ """Per-run rolling fingerprints, written out only when a collapse fires."""
290
+
291
+ records: deque = field(default_factory=lambda: deque(maxlen=HISTORY))
292
+ step: int = 0
293
+
294
+ def record(self, request_context, response) -> RequestFingerprint:
295
+ # Count separately from the window: the deque is bounded, so its length
296
+ # stops growing and would repeat step numbers for exactly the long runs
297
+ # a collapse is most likely to show up in.
298
+ self.step += 1
299
+ current = fingerprint(request_context, response, step=self.step)
300
+ self.records.append(current)
301
+ return current
302
+
303
+ def summary(self) -> str:
304
+ if len(self.records) < 2:
305
+ return ""
306
+ return divergence(self.records[-2], self.records[-1])
307
+
308
+ def dump(self) -> Path | None:
309
+ """Write the rolling window; return the path, or None when unavailable."""
310
+ if not enabled() or not self.records:
311
+ return None
312
+ current = self.records[-1]
313
+ root = diagnostics_root()
314
+ stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime(current.at))
315
+ # Two runs in one process can collapse in the same second at the same step,
316
+ # so the obvious name is not unique. Overwriting would discard the only
317
+ # record of the earlier collapse.
318
+ base = f"{stamp}-{os.getpid()}-step{current.step}"
319
+ path = root / f"{base}.json"
320
+ for attempt in range(1, 100):
321
+ if not path.exists():
322
+ break
323
+ path = root / f"{base}-{attempt}.json"
324
+ payload = {
325
+ "versions": versions(),
326
+ "model": current.model,
327
+ "summary": self.summary(),
328
+ "requests": [record.as_dict() for record in self.records],
329
+ }
330
+ try:
331
+ root.mkdir(parents=True, exist_ok=True)
332
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
333
+ except OSError:
334
+ # A diagnostic must never take down the run that produced it.
335
+ return None
336
+ return path
@@ -0,0 +1,58 @@
1
+ """Prompt-cache settings that follow a run, including delegated ones.
2
+
3
+ `agent.model_settings` covers the main agent only. A sub-agent inherits the
4
+ parent's *model object* but is run with `model_settings=None`
5
+ (`subagents/_toolset.py`), and cache settings live on the agent, not the model,
6
+ so a delegated run would otherwise request no caching at all. Applying them per
7
+ request also tracks a model switch without rebuilding the sub-agent.
8
+ """
9
+
10
+ from dataclasses import replace
11
+ from typing import Any
12
+
13
+ from pydantic_ai import RunContext
14
+ from pydantic_ai.capabilities import AbstractCapability
15
+ from pydantic_ai.models import ModelRequestContext
16
+
17
+ # Pydantic AI 2.45.0 adds no `cache_control` of its own: without these settings an
18
+ # Anthropic conversation re-reads its whole prefix at full price every request
19
+ # (confirmed against captured request bodies and saved-session usage records).
20
+ # `anthropic_cache` is the server-side automatic breakpoint, which moves forward as
21
+ # history grows; the two explicit breakpoints keep instructions and tool definitions
22
+ # cached. Meridian is excluded on purpose: its passthrough proxy strips client
23
+ # `cache_control` and drives caching from its own lineage hash.
24
+ ANTHROPIC_CACHE_SETTINGS = {
25
+ "anthropic_cache": "5m",
26
+ "anthropic_cache_instructions": True,
27
+ "anthropic_cache_tool_definitions": True,
28
+ }
29
+
30
+
31
+ def model_settings(model: str) -> dict | None:
32
+ """Default settings for a model name, as `Agent(model_settings=...)` wants them."""
33
+ if model.startswith("openai-codex:"):
34
+ # Codex does not emit visible reasoning unless summaries are requested.
35
+ # Always receive them so Ctrl+T can reveal the preview mid-turn; the
36
+ # display preference remains local and never changes reasoning effort.
37
+ return {"openai_reasoning_summary": "detailed"}
38
+ if model.startswith("anthropic:"):
39
+ return dict(ANTHROPIC_CACHE_SETTINGS)
40
+ return None
41
+
42
+
43
+ class ProviderCacheSettings(AbstractCapability):
44
+ """Request prompt caching on providers that need it asked for explicitly.
45
+
46
+ Settings already on the request win, so `/effort`, `/thinking` and an
47
+ explicit override are never overwritten.
48
+ """
49
+
50
+ async def before_model_request(
51
+ self, ctx: RunContext[Any], request_context: ModelRequestContext
52
+ ) -> ModelRequestContext:
53
+ if request_context.model.system != "anthropic":
54
+ return request_context
55
+ settings = request_context.model_settings or {}
56
+ if any(key in settings for key in ANTHROPIC_CACHE_SETTINGS):
57
+ return request_context
58
+ return replace(request_context, model_settings={**ANTHROPIC_CACHE_SETTINGS, **settings})
@@ -0,0 +1,139 @@
1
+ """Report prompt-cache reuse drops, from Harness's detector.
2
+
3
+ The full notice goes to the session journal for diagnosis; the terminal shows
4
+ only a short footer label (`footer_label`), not a scrollback line.
5
+
6
+ A drop is information, not a fault: /compact, a new tool, or an expired
7
+ provider cache all shorten what the next request can reuse. The notice says
8
+ how much was reused so the cost is visible; it never claims a cause.
9
+ """
10
+
11
+ import re
12
+ import warnings
13
+ from dataclasses import dataclass, field
14
+
15
+ from pydantic_ai import CapabilityEvent
16
+ from pydantic_ai.messages import NativeToolCallPart
17
+ from pydantic_ai_harness.warn_on_cache_busts import CacheBustWarning, WarnOnCacheBusts
18
+
19
+ from pcode.cache_diagnostics import CacheDiagnostics
20
+ from pcode.context_usage import compact_tokens
21
+ from pcode.tool_display import command_text
22
+
23
+
24
+ @dataclass(kw_only=True)
25
+ class CacheBustEvent(CapabilityEvent, namespace="pcode_cache", name="bust"):
26
+ text: str
27
+
28
+
29
+ def server_tool_iterations(response) -> int:
30
+ """Count server-side tool calls (web search, code execution) in a response.
31
+
32
+ Each one is an extra sampling pass inside a single API call, and the provider
33
+ reports one ``usage`` summed over every pass: ``cache_read_tokens`` is then
34
+ roughly ``passes * prefix``, not a prefix anyone can read back.
35
+ """
36
+ return sum(isinstance(part, NativeToolCallPart) for part in response.parts)
37
+
38
+
39
+ def notice_text(step: int, read: int, established: int, model: str, *, earlier_turn: bool) -> str:
40
+ """One neutral line: what was reused, against what an earlier request cached."""
41
+ source = "tokens cached in an earlier turn" if earlier_turn else "previously cached tokens"
42
+ suffix = f" ({model})" if model else ""
43
+ return f"Prompt cache: request {step} reused {read:,} of ~{established:,} {source}{suffix}."
44
+
45
+
46
+ _REUSE = re.compile(r"reused ([\d,]+) of ~([\d,]+)")
47
+
48
+
49
+ def footer_label(text: str) -> str:
50
+ """The footer's short form of a notice, e.g. `cache miss 0/166k`.
51
+
52
+ The notice itself is kept whole in the session journal for diagnosis; the
53
+ footer only says a drop happened and how large it was.
54
+ """
55
+ found = _REUSE.search(text)
56
+ if found is None:
57
+ return "cache drop"
58
+ read, established = (int(value.replace(",", "")) for value in found.groups())
59
+ kind = "miss" if read == 0 else "drop"
60
+ label = f"cache {kind} {compact_tokens(read)}/{compact_tokens(established)}"
61
+ return f"sub-agent {label}" if text.startswith("Sub-agent:") else label
62
+
63
+
64
+ @dataclass
65
+ class CacheBustReporting(WarnOnCacheBusts):
66
+ """Keep Harness's detector, thresholds, latch, and warning filters.
67
+
68
+ Upstream keys its marks by `RunContext.conversation_id`, which pcode keeps
69
+ stable per session, so the first request of a turn is compared with what
70
+ the previous turn cached (until the conversation idles past the cache TTL).
71
+ A notice after /compact is therefore expected: it reports the reset.
72
+ """
73
+
74
+ # Write each notice's fingerprint window to disk (the `debug` setting).
75
+ dump_fingerprints: bool = False
76
+ # `for_run` copies the capability with `replace()`, which re-initializes
77
+ # `init=False` fields, so each run fingerprints its own requests -- matching
78
+ # the upstream detector's per-run step numbering.
79
+ diagnostics: CacheDiagnostics = field(
80
+ init=False, default_factory=CacheDiagnostics, compare=False, repr=False
81
+ )
82
+
83
+ async def after_model_request(self, ctx, *, request_context, response):
84
+ # Harness hooks are filters, not listeners: the return value replaces the
85
+ # response (or request context, for `before_model_request`). A hook that
86
+ # only records something must still return it, or the loss surfaces far
87
+ # away as `AttributeError: 'NoneType' object has no attribute 'usage'`
88
+ # from the upstream `WarnOnCacheBusts` this class extends.
89
+ #
90
+ # The pinned upstream hook does not suspend: only its synchronous warning
91
+ # emission is captured, never model/tool execution or another task's work.
92
+ # Emit outside this scope; ctx.emit can suspend. No global warning handler.
93
+ key = (response.provider_name, response.model_name)
94
+ prior = self._state.conversation.keys.get(key)
95
+ with warnings.catch_warnings(record=True) as caught:
96
+ result = await super().after_model_request(
97
+ ctx, request_context=request_context, response=response
98
+ )
99
+ if server_tool_iterations(response):
100
+ # Upstream raises its high-water mark to `read + write` of every
101
+ # response, but a summed multi-pass usage puts the mark several
102
+ # times above the real prefix, and the next healthy request then
103
+ # "collapses" against it (seen: 148k established from a 29k prefix
104
+ # after three web searches; the next request read exactly 47k).
105
+ # Keep the previous mark; the next single-pass response sets a real
106
+ # one from its own usage.
107
+ self._state.conversation.keys[key].prefix = prior.prefix if prior else 0
108
+ # Fingerprint every request, not just collapsing ones: diagnosing a
109
+ # collapse needs the healthy request before it to compare against.
110
+ # Part shapes vary by provider and capability, so a diagnostic that
111
+ # cannot read one must degrade to silence rather than end the run.
112
+ try:
113
+ self.diagnostics.record(request_context, response)
114
+ except Exception:
115
+ self.diagnostics.records.clear()
116
+ for warning in caught:
117
+ if issubclass(warning.category, CacheBustWarning):
118
+ # Report measured reuse, not upstream's speculative expiry cause
119
+ # or an inferred number of tokens billed uncached.
120
+ model = "/".join(
121
+ part for part in (response.provider_name, response.model_name) if part
122
+ )
123
+ text = notice_text(
124
+ self._state.step,
125
+ response.usage.cache_read_tokens,
126
+ prior.prefix if prior else 0,
127
+ model,
128
+ earlier_turn=prior is not None and prior.run_id != ctx.run_id,
129
+ )
130
+ text = "\n".join(part for part in (text, self.diagnostics.summary()) if part)
131
+ path = self.diagnostics.dump() if self.dump_fingerprints else None
132
+ if path is not None:
133
+ text += f"\nRequest fingerprints: {path}"
134
+ await ctx.emit(CacheBustEvent(text=command_text(text)))
135
+ else:
136
+ warnings.warn_explicit(
137
+ warning.message, warning.category, warning.filename, warning.lineno
138
+ )
139
+ return result
@@ -0,0 +1,96 @@
1
+ """`claude:` models: Claude Code's own login through the Claude Agent SDK, no proxy.
2
+
3
+ `ClaudeModel` (in `model`) is an `AnthropicModel` whose requests never touch
4
+ its HTTP client. Each conversation keeps one `claude` CLI process (the one
5
+ bundled with `claude-agent-sdk`) alive across requests, where Meridian starts a
6
+ fresh one per request behind a Node proxy. pcode's tools are served to that
7
+ process as an in-process MCP server whose handlers *park*: the model calls a
8
+ tool, the CLI invokes the handler, the streamed assistant message ends, and
9
+ Pydantic AI runs the tool itself. The next request carries the result, which
10
+ releases the parked handler, and the CLI goes on to its next API call. The CLI
11
+ streams raw Anthropic events, so parsing reuses `AnthropicModel`'s
12
+ streamed-response code. `session` holds one such process (`ClaudeSession`);
13
+ `cli` the binary and environment it runs with.
14
+
15
+ pcode's history stays the source of truth; the CLI transcript is a disposable
16
+ copy. `messages` normalizes and hashes that history so it can be compared. A
17
+ request continues a live process only when that process holds exactly the
18
+ history before the new user message. Otherwise it forks the CLI transcript at
19
+ the last assistant message both share (`resume` + `fork_session` +
20
+ `resume_session_at`: structured history, warm cache), found through a persisted
21
+ index of history hashes (`resume`), and failing that starts over with the
22
+ history replayed as text, as Meridian does. `session_pool` makes that choice
23
+ and keeps idle processes within limits. See dev/anthropic-providers.md.
24
+
25
+ `ClaudeWorkspace` (in `workspace`) points each request's CLI at the agent's
26
+ workspace, and `errors` holds the failures and what to tell the user about
27
+ them. pcode holds no credentials here: the CLI signs in itself (`/login claude`).
28
+
29
+ Tests patch the submodule that defines a name (`pcode.claude_sdk.session_pool`),
30
+ never this package: a re-export is a copy, so patching it changes nothing the
31
+ submodules read. Tuning knobs and pool state are left out of the re-exports for
32
+ that reason.
33
+ """
34
+
35
+ from pcode.claude_sdk.cli import CLI_ENV, LOGIN_ENV, cli_path
36
+ from pcode.claude_sdk.errors import (
37
+ MISSING_SDK,
38
+ ClaudeConnectionError,
39
+ ClaudeHTTPError,
40
+ ClaudeProcessError,
41
+ ClaudeSDKMissing,
42
+ ClaudeStartError,
43
+ failure_hint,
44
+ )
45
+ from pcode.claude_sdk.messages import REPLAY_INTRO, lineage, normalize, replay
46
+ from pcode.claude_sdk.model import PREFIX, ClaudeModel, ClaudeProvider, claude_model
47
+ from pcode.claude_sdk.resume import ForkPoint, ResumeIndex, index_path
48
+ from pcode.claude_sdk.session import (
49
+ SERVER,
50
+ TOOL_PREFIX,
51
+ TOOL_USE_ID,
52
+ ClaudeSession,
53
+ SessionConfig,
54
+ )
55
+ from pcode.claude_sdk.session_pool import (
56
+ Checkout,
57
+ SessionPool,
58
+ pool,
59
+ shutdown,
60
+ )
61
+ from pcode.claude_sdk.workspace import CWD_SETTING, ClaudeWorkspace
62
+
63
+ __all__ = [
64
+ "CLI_ENV",
65
+ "CWD_SETTING",
66
+ "LOGIN_ENV",
67
+ "MISSING_SDK",
68
+ "PREFIX",
69
+ "REPLAY_INTRO",
70
+ "SERVER",
71
+ "TOOL_PREFIX",
72
+ "TOOL_USE_ID",
73
+ "Checkout",
74
+ "ClaudeConnectionError",
75
+ "ClaudeHTTPError",
76
+ "ClaudeModel",
77
+ "ClaudeProcessError",
78
+ "ClaudeProvider",
79
+ "ClaudeSDKMissing",
80
+ "ClaudeSession",
81
+ "ClaudeStartError",
82
+ "ClaudeWorkspace",
83
+ "ForkPoint",
84
+ "ResumeIndex",
85
+ "SessionConfig",
86
+ "SessionPool",
87
+ "claude_model",
88
+ "cli_path",
89
+ "failure_hint",
90
+ "index_path",
91
+ "lineage",
92
+ "normalize",
93
+ "pool",
94
+ "replay",
95
+ "shutdown",
96
+ ]
@@ -0,0 +1,52 @@
1
+ """The `claude` CLI binary pcode runs, and the environment it runs with."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ # The child inherits pcode's environment. An empty value is unset to the CLI
7
+ # (verified: requests then bill the subscription), so neither pcode's own
8
+ # Anthropic key, token or endpoint nor a cloud route exported for other Claude
9
+ # Code use can silently redirect or bill a `claude:` request.
10
+ LOGIN_ENV = {
11
+ "ANTHROPIC_API_KEY": "",
12
+ "ANTHROPIC_AUTH_TOKEN": "",
13
+ "ANTHROPIC_BASE_URL": "",
14
+ "CLAUDE_CODE_USE_BEDROCK": "",
15
+ "CLAUDE_CODE_USE_VERTEX": "",
16
+ "CLAUDE_CODE_USE_FOUNDRY": "",
17
+ "CLAUDE_CODE_USE_GATEWAY": "",
18
+ "CLAUDE_CODE_USE_MANTLE": "",
19
+ "CLAUDE_CODE_USE_ANTHROPIC_AWS": "",
20
+ "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD": "",
21
+ }
22
+ CLI_ENV = {
23
+ **LOGIN_ENV,
24
+ # pcode owns retries (visible in the UI), compaction and tool deferral. A
25
+ # transcript the CLI compacted itself would no longer match pcode's history.
26
+ "CLAUDE_CODE_MAX_RETRIES": "0",
27
+ "DISABLE_AUTO_COMPACT": "1",
28
+ "DISABLE_COMPACT": "1",
29
+ "ENABLE_TOOL_SEARCH": "false",
30
+ # pcode already bounds tool output; never let the CLI truncate it again.
31
+ "MAX_MCP_OUTPUT_TOKENS": "1000000",
32
+ # A subscription login defaults to 1-hour cache writes (2x input, against
33
+ # 1.25x). pcode's tool loops send requests well inside five minutes, so the
34
+ # hour rarely pays off (dev/anthropic-providers.md). This variable wins
35
+ # over ENABLE_PROMPT_CACHING_1H; only FORCE_PROMPT_CACHING_5M outranks it.
36
+ "CLAUDE_CODE_PROMPT_CACHE_TTL": "5m",
37
+ # A parked handler lasts as long as the tool runs, delegations included.
38
+ "MCP_TOOL_TIMEOUT": str(7 * 24 * 3600 * 1000),
39
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
40
+ "DISABLE_AUTOUPDATER": "1",
41
+ }
42
+
43
+
44
+ def cli_path() -> str | None:
45
+ """The CLI the SDK runs: its bundled binary, else `claude` on PATH."""
46
+ import shutil
47
+
48
+ import claude_agent_sdk
49
+
50
+ name = "claude.exe" if sys.platform == "win32" else "claude"
51
+ bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" / name
52
+ return str(bundled) if bundled.is_file() else shutil.which("claude")