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/delta.py ADDED
@@ -0,0 +1,295 @@
1
+ """Diffs rendered by delta (https://dandavison.github.io/delta/) when it is installed.
2
+
3
+ delta never reads the user's git config (`--no-gitconfig`, which also ignores
4
+ `--config`) or its own `DELTA_*`/`BAT_*` environment: `delta_args` is the one
5
+ place to customize it, so pcode's diffs look the same whatever `git diff` is set
6
+ up to do. pcode sets width, dark or light, no pager or headers, layout, and
7
+ syntax colors on deletions. It leaves out defaults the user's own arguments
8
+ already pass: delta rejects a flag given twice. Anything that goes wrong (delta
9
+ missing, unknown arguments, a timeout) returns None, and the caller falls back
10
+ to its own Rich rendering.
11
+ """
12
+
13
+ import os
14
+ import secrets
15
+ import shlex
16
+ import shutil
17
+ import subprocess
18
+ import threading
19
+ from collections import OrderedDict
20
+ from dataclasses import dataclass
21
+
22
+ from rich.cells import chop_cells
23
+ from rich.text import Text
24
+
25
+ LAYOUTS = ("auto", "unified", "side-by-side")
26
+ WIDTH_FLAGS = {"-w", "--width"}
27
+ SIDE_BY_SIDE_FLAGS = {"-s", "--side-by-side"}
28
+ LINE_NUMBER_FLAGS = {"-n", "--line-numbers"}
29
+ # `auto` switches to side-by-side at this width: each half then keeps about 90
30
+ # columns, enough for most code lines beside their line numbers.
31
+ SIDE_BY_SIDE_WIDTH = 180
32
+ TIMEOUT = 2.0
33
+ # A batch gets a little longer per patch, up to this.
34
+ MAX_TIMEOUT = 5.0
35
+ # Patches per delta run, so a batch fits within MAX_TIMEOUT.
36
+ BATCH = 60
37
+ # delta extends a background to the edge with "erase to end of line", which
38
+ # neither Rich nor prompt_toolkit keeps; it is replaced with padding instead.
39
+ ERASE_LINE = "\x1b[0K"
40
+ _MARK = "\ue000"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Delta:
45
+ executable: str
46
+ args: tuple[str, ...] = ()
47
+ layout: str = "unified"
48
+ light: bool = False
49
+
50
+ def side_by_side(self, width: int) -> bool:
51
+ if self.given() & SIDE_BY_SIDE_FLAGS:
52
+ return True
53
+ if self.layout == "auto":
54
+ return width >= SIDE_BY_SIDE_WIDTH
55
+ return self.layout == "side-by-side"
56
+
57
+ def given(self) -> set[str]:
58
+ """The flags the user's own arguments set."""
59
+ return {arg.split("=", 1)[0] for arg in self.args if arg.startswith("-")}
60
+
61
+ def command(self, width: int) -> list[str]:
62
+ given = self.given()
63
+ defaults = [
64
+ ({"--paging"}, "--paging=never"),
65
+ (WIDTH_FLAGS, f"--width={width}"),
66
+ # Match additions' syntax colors without changing delta's backgrounds.
67
+ ({"--minus-style"}, "--minus-style=syntax auto"),
68
+ ({"--minus-emph-style"}, "--minus-emph-style=syntax auto"),
69
+ ({"--dark", "--light"}, "--light" if self.light else "--dark"),
70
+ # The block heading already names the file.
71
+ ({"--file-style"}, "--file-style=omit"),
72
+ # A boxed `line: function` heading that often holds only a number.
73
+ ({"--hunk-header-style"}, "--hunk-header-style=omit"),
74
+ ]
75
+ if self.side_by_side(width):
76
+ defaults.append((SIDE_BY_SIDE_FLAGS, "--side-by-side"))
77
+ added = [flag for names, flag in defaults if not names & given]
78
+ isolated = [] if "--no-gitconfig" in given else ["--no-gitconfig"]
79
+ return [self.executable, *isolated, *added, *self.args]
80
+
81
+ def render(self, patch: str, width: int, *, cache: bool = True) -> list[Text] | None:
82
+ """One styled Rich row per output row, or None if delta failed.
83
+
84
+ Safe to call off the event loop. Without `cache`, the output is not
85
+ kept, so the live preview's many short-lived bodies do not push the
86
+ settled blocks' renderings out.
87
+ """
88
+ command = tuple(self.command(max(1, width)))
89
+ key = (command, patch)
90
+ with _lock:
91
+ cached = key in _cache
92
+ if cached:
93
+ _cache.move_to_end(key)
94
+ output = _cache[key]
95
+ if not cached:
96
+ output = _run(command, patch)
97
+ if cache:
98
+ _remember(key, output)
99
+ groups = self._groups(command, width, output)
100
+ return None if groups is None else [row for group in groups for row in group]
101
+
102
+ def render_all(self, patches: list[str], width: int) -> list[list[list[Text]] | None]:
103
+ """Each patch through one delta run, as one group of rows per output line.
104
+
105
+ Unlike `render`, the results come back rather than through the shared
106
+ cache, so a review with more hunks than the cache holds is not evicted
107
+ while it is laid out. A line delta folds keeps its rows together, so a
108
+ caller can match output lines to patch lines. None marks a patch delta
109
+ failed on.
110
+ """
111
+ command = tuple(self.command(max(1, width)))
112
+ outputs = self._outputs(command, patches)
113
+ return [self._groups(command, width, outputs.get(patch)) for patch in patches]
114
+
115
+ def prefetch(self, patches: list[str], width: int) -> None:
116
+ """Render many patches through one delta process, ready for `render`."""
117
+ self._outputs(tuple(self.command(max(1, width))), patches)
118
+
119
+ def _outputs(self, command: tuple[str, ...], patches: list[str]) -> dict[str, str | None]:
120
+ """delta's raw output for each patch, running only those not cached.
121
+
122
+ Starting delta costs tens of milliseconds, and a scrollback rebuild
123
+ renders every edit block at once. The patches go in one input,
124
+ separated by a marker line delta passes through untouched, since only
125
+ `+`, `-`, space and backslash lines continue a hunk. A big set goes in
126
+ batches, each within one run's timeout. If a batch's output does not
127
+ split back into one piece per patch, its patches are left unrendered
128
+ (None, and not cached) rather than each started alone: a review can hold
129
+ hundreds. If delta fails outright, every patch is recorded as failed,
130
+ so the blocks fall back to Rich at once instead of each waiting on its
131
+ own failing run.
132
+ """
133
+ outputs: dict[str, str | None] = {}
134
+ with _lock:
135
+ for patch in dict.fromkeys(patches):
136
+ if (command, patch) in _cache:
137
+ outputs[patch] = _cache[(command, patch)]
138
+ missing = [patch for patch in dict.fromkeys(patches) if patch not in outputs]
139
+ if len(missing) == 1:
140
+ outputs[missing[0]] = _remember((command, missing[0]), _run(command, missing[0]))
141
+ return outputs
142
+ for start in range(0, len(missing), BATCH):
143
+ batch = missing[start : start + BATCH]
144
+ marker = f"pcode-delta-{secrets.token_hex(8)}"
145
+ output = _run(command, f"\n{marker}\n".join(batch), len(batch))
146
+ pieces = None if output is None else output.split(f"{marker}\n")
147
+ if pieces is not None and len(pieces) != len(batch):
148
+ outputs.update(dict.fromkeys(batch))
149
+ continue
150
+ for patch, piece in zip(batch, pieces or [None] * len(batch), strict=True):
151
+ outputs[patch] = _remember(
152
+ (command, patch), piece if piece and piece.strip() else None
153
+ )
154
+ return outputs
155
+
156
+ def _groups(
157
+ self, command: tuple[str, ...], width: int, output: str | None
158
+ ) -> list[list[Text]] | None:
159
+ if output is None:
160
+ return None
161
+ lines = output.splitlines()
162
+ # With the file section omitted, delta still opens with its blank line.
163
+ # Only that one: a hunk's first context line can be blank too.
164
+ if lines and not lines[0].strip():
165
+ lines.pop(0)
166
+ # Padding to pcode's width would wrap every row of a wider user width.
167
+ pad = 0 if self.given() & WIDTH_FLAGS else width
168
+ gutter = bool(set(command) & LINE_NUMBER_FLAGS)
169
+ return [_rows(line, pad, gutter) for line in lines]
170
+
171
+
172
+ def from_preferences(preferences: dict[str, str], *, light: bool = False) -> Delta | None:
173
+ """The configured delta, or None to render diffs with Rich."""
174
+ if preferences.get("diff_renderer", "delta") != "delta":
175
+ return None
176
+ executable = find_delta()
177
+ if executable is None:
178
+ return None
179
+ try:
180
+ args = tuple(shlex.split(preferences.get("delta_args", "")))
181
+ except ValueError:
182
+ args = ()
183
+ layout = preferences.get("diff_layout", "unified")
184
+ return Delta(executable, args, layout if layout in LAYOUTS else "unified", light)
185
+
186
+
187
+ def find_delta() -> str | None:
188
+ return shutil.which("delta")
189
+
190
+
191
+ # delta's output by (command, patch): a rebuild at the same width and palette
192
+ # reuses it rather than starting delta again.
193
+ _cache: OrderedDict[tuple[tuple[str, ...], str], str | None] = OrderedDict()
194
+ CACHE_SIZE = 256
195
+
196
+
197
+ # The live edit preview renders from a worker thread.
198
+ _lock = threading.Lock()
199
+
200
+
201
+ def _remember(key: tuple[tuple[str, ...], str], output: str | None) -> str | None:
202
+ with _lock:
203
+ _cache[key] = output
204
+ _cache.move_to_end(key)
205
+ while len(_cache) > CACHE_SIZE:
206
+ _cache.popitem(last=False)
207
+ return output
208
+
209
+
210
+ def preview_patch(path: str, body: str) -> str:
211
+ """A unified diff of a streaming edit preview's `-`/`+` lines.
212
+
213
+ The preview has no line numbers, so the hunk is numbered from 1; its
214
+ header is never drawn. The path's extension picks the
215
+ syntax highlighting. A line the preview's tail clip cut short of its
216
+ prefix is dropped.
217
+ """
218
+ lines = [line for line in body.split("\n") if line[:1] in ("-", "+")]
219
+ removed = sum(line.startswith("-") for line in lines)
220
+ header = [f"--- a/{path}", f"+++ b/{path}", f"@@ -1,{removed} +1,{len(lines) - removed} @@"]
221
+ return "\n".join(header + lines)
222
+
223
+
224
+ def _run(command: tuple[str, ...], patch: str, count: int = 1) -> str | None:
225
+ try:
226
+ done = subprocess.run(
227
+ command,
228
+ input=patch + "\n",
229
+ capture_output=True,
230
+ text=True,
231
+ encoding="utf-8",
232
+ errors="replace",
233
+ timeout=min(TIMEOUT + 0.05 * count, MAX_TIMEOUT),
234
+ # A user's --paging=always would start a pager holding the pipes.
235
+ env=_environment(),
236
+ # No controlling terminal: delta must never query or write the
237
+ # terminal pcode is drawing on.
238
+ start_new_session=True,
239
+ )
240
+ except (OSError, subprocess.SubprocessError):
241
+ return None
242
+ if done.returncode != 0 or not done.stdout.strip():
243
+ return None
244
+ return done.stdout
245
+
246
+
247
+ def _environment() -> dict[str, str]:
248
+ # DELTA_FEATURES, BAT_THEME and the like configure delta behind the
249
+ # user's arguments. A pager could hold the pipes open, so it is `cat`.
250
+ env = {
251
+ key: value for key, value in os.environ.items() if not key.startswith(("DELTA_", "BAT_"))
252
+ }
253
+ return {**env, "DELTA_PAGER": "cat"}
254
+
255
+
256
+ def _rows(raw: str, width: int, gutter: bool) -> list[Text]:
257
+ """Parse one ANSI line into rows of at most `width` cells (0 for any width).
258
+
259
+ The unified layout never wraps, and a long row left to the terminal would
260
+ restart under the line numbers, so it is folded here, each continuation
261
+ indented past the `gutter` when `--line-numbers` draws one. Where delta erased to the
262
+ end of the line, every row is padded in that background.
263
+ """
264
+ if ERASE_LINE not in raw:
265
+ text, style = Text.from_ansi(raw), None
266
+ else:
267
+ text = Text.from_ansi(raw.split(ERASE_LINE, 1)[0] + _MARK)
268
+ style = next(
269
+ (span.style for span in reversed(text.spans) if span.start <= len(text) - 1 < span.end),
270
+ "",
271
+ )
272
+ text.right_crop(1)
273
+ rows = _fold(text, width, gutter) if width else [text]
274
+ if style is not None:
275
+ for row in rows:
276
+ if (pad := width - row.cell_len) > 0:
277
+ row.append(" " * pad, style)
278
+ return rows
279
+
280
+
281
+ def _fold(text: Text, width: int, gutter: bool) -> list[Text]:
282
+ if text.cell_len <= width:
283
+ return [text]
284
+ # The line-number gutter ends at its first bar, like ` 24 ⋮ 25 │`.
285
+ bar = text.plain.find("│") if gutter else -1
286
+ indent = bar + 1 if 0 < bar < width // 2 else 0
287
+ prefix = Text(" " * (indent - 1)) + text[bar:indent] if indent else Text()
288
+ # chop_cells measures whole grapheme clusters, so an emoji never splits.
289
+ first = chop_cells(text.plain, width)[0]
290
+ start = len(first)
291
+ rows = [text[:start]]
292
+ for piece in chop_cells(text.plain[start:], width - indent):
293
+ rows.append(prefix + text[start : start + len(piece)])
294
+ start += len(piece)
295
+ return rows
pcode/diagnostics.py ADDED
@@ -0,0 +1,226 @@
1
+ """Useful provider errors without HTTP headers or credential values."""
2
+
3
+ import os
4
+ import re
5
+ import sysconfig
6
+ import traceback
7
+ from importlib.metadata import version
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit, urlunsplit
10
+
11
+ RESTART = "Restart pcode to load it; /resume picks this conversation back up."
12
+
13
+
14
+ def stale_install() -> str | None:
15
+ """Why this process's code may no longer match what is on disk.
16
+
17
+ pcode imports much of itself lazily, and the global install is editable, so
18
+ a long-lived session that outlives a merge mixes old modules with new ones
19
+ (a new module reading a setting the old one never defined), and one that
20
+ outlives a reinstall on another Python finds its site-packages deleted.
21
+ Both surface as unrelated-looking errors far from the cause.
22
+ """
23
+ import pcode
24
+
25
+ if not Path(sysconfig.get_paths()["purelib"]).is_dir():
26
+ return f"pcode was reinstalled since this session started. {RESTART}"
27
+ try:
28
+ changed = any(
29
+ path.stat().st_mtime > pcode.STARTED
30
+ for path in Path(pcode.__file__).parent.rglob("*.py")
31
+ )
32
+ except OSError:
33
+ changed = True # A file vanished mid-scan: the source is changing.
34
+ return f"pcode's source changed since this session started. {RESTART}" if changed else None
35
+
36
+
37
+ _TOKEN = re.compile(
38
+ r"\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{12,}|"
39
+ r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b"
40
+ )
41
+ _ASSIGNMENT = re.compile(
42
+ r"(?i)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|"
43
+ r"password|secret|authorization)[\"']?\s*[:=]\s*[\"']?)([^\s\"',;}]+)"
44
+ )
45
+
46
+
47
+ def redact(text: str) -> str:
48
+ for name, value in os.environ.items():
49
+ if len(value) >= 8 and re.search(r"TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY", name):
50
+ text = text.replace(value, "[redacted]")
51
+ text = re.sub(r"(?i)\bBearer\s+[^\s\"',;}]+", "Bearer [redacted]", text)
52
+ text = re.sub(r"(?i)(https?://)[^/\s@]+@", r"\1[redacted]@", text)
53
+ text = _TOKEN.sub("[redacted]", text)
54
+ text = _ASSIGNMENT.sub(r"\1[redacted]", text)
55
+ return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", text)
56
+
57
+
58
+ # Enough for a deep cause chain through the agent graph, small enough that a
59
+ # failing loop cannot fill a session directory.
60
+ REPORT_CHARS = 32_000
61
+
62
+
63
+ def error_report(error: BaseException) -> str:
64
+ """The frames `error_details` deliberately omits, redacted and bounded.
65
+
66
+ Frames and exception messages only: locals are never captured, so a request
67
+ object or credential held in a stack frame cannot reach the file. Keep the
68
+ end when truncating -- the innermost frames are where the failure is.
69
+ """
70
+ text = redact("".join(traceback.format_exception(error)))
71
+ if len(text) <= REPORT_CHARS:
72
+ return text
73
+ return "[earlier frames omitted]\n" + text[-REPORT_CHARS:]
74
+
75
+
76
+ def error_details(error: BaseException) -> dict:
77
+ """Keep bounded, redacted causes without traceback locals or request objects."""
78
+ return _error_details(error, set())
79
+
80
+
81
+ def _error_details(error: BaseException, seen: set[int]) -> dict:
82
+ if id(error) in seen or len(seen) >= 16:
83
+ return {"type": type(error).__name__, "truncated": True}
84
+ seen.add(id(error))
85
+ if isinstance(error, BaseExceptionGroup) and error.exceptions:
86
+ return _error_details(error.exceptions[0], seen)
87
+ result = {"type": type(error).__name__}
88
+ status = getattr(error, "status_code", None)
89
+ if isinstance(status, int):
90
+ result["status"] = status
91
+ body = getattr(error, "body", None)
92
+ if isinstance(body, dict):
93
+ detail = body.get("error", body)
94
+ if isinstance(detail, dict):
95
+ for key in ("message", "code", "param", "type"):
96
+ value = detail.get(key)
97
+ if isinstance(value, str):
98
+ result[f"provider_{key}"] = redact(value)[:4000]
99
+ if "provider_message" not in result:
100
+ result["message"] = redact(str(error))[:4000]
101
+ if error.__cause__ is not None:
102
+ result["cause"] = _error_details(error.__cause__, seen)
103
+ elif error.__context__ is not None and not error.__suppress_context__:
104
+ result["context"] = _error_details(error.__context__, seen)
105
+ return result
106
+
107
+
108
+ TRANSIENT_TRANSPORT = frozenset(
109
+ {
110
+ "RemoteProtocolError",
111
+ "APIConnectionError",
112
+ "ConnectError",
113
+ "ConnectTimeout",
114
+ "ReadError",
115
+ "ReadTimeout",
116
+ "WriteError",
117
+ "WriteTimeout",
118
+ "APITimeoutError",
119
+ "IncompleteRead",
120
+ # A `claude:` CLI process ended mid-request; the retry starts another.
121
+ "ClaudeProcessError",
122
+ # A `claude:` request got no HTTP response; the retry forks the transcript.
123
+ "ClaudeConnectionError",
124
+ }
125
+ )
126
+
127
+
128
+ def transport_types(error: BaseException) -> set[str]:
129
+ """Name every exception in the bounded cause chain, never its text."""
130
+ names, detail = set(), error_details(error)
131
+ while detail:
132
+ names.add(detail["type"])
133
+ detail = detail.get("cause", detail.get("context", {}))
134
+ return names
135
+
136
+
137
+ def transient(error: BaseException) -> bool:
138
+ """Whether the provider dropped the connection rather than answering.
139
+
140
+ A status code means the provider did answer: rate limits and server errors
141
+ need their own handling, so they are deliberately not transient here.
142
+ """
143
+ detail = error_details(error)
144
+ while detail:
145
+ if "status" in detail:
146
+ return False
147
+ detail = detail.get("cause", detail.get("context", {}))
148
+ return bool(transport_types(error) & TRANSIENT_TRANSPORT)
149
+
150
+
151
+ def provider_context(model) -> dict[str, str]:
152
+ """Best-effort configured route, never requests, headers, or proxy credentials.
153
+
154
+ This names the parent model, not necessarily a failing delegated request.
155
+ Unresolved model strings must not trigger credential loading during failure.
156
+ """
157
+ result = {}
158
+ try:
159
+ if isinstance(model, str):
160
+ result["model"] = redact(model)
161
+ return result
162
+ result["model"] = redact(model.model_name)
163
+ provider = model.provider
164
+ if provider is not None:
165
+ result["provider"] = redact(provider.name)
166
+ url = urlsplit(str(provider.client.base_url))
167
+ if url.scheme in {"http", "https"} and url.hostname:
168
+ host = url.hostname
169
+ if ":" in host:
170
+ host = f"[{host}]"
171
+ if url.port is not None:
172
+ host += f":{url.port}"
173
+ # Drop userinfo, query, and fragment even for unfamiliar secrets.
174
+ result["base_url"] = redact(urlunsplit((url.scheme, host, url.path, "", "")))
175
+ except Exception:
176
+ # Diagnostics must never replace the original failure.
177
+ pass
178
+ return result
179
+
180
+
181
+ def quota_message(error: BaseException) -> str | None:
182
+ """Classify bounded provider causes; never echo arbitrary response text."""
183
+ detail = error_details(error)
184
+ rate_limited = False
185
+ http_status = None
186
+ while detail:
187
+ if http_status is None:
188
+ http_status = detail.get("status")
189
+ text = " ".join(
190
+ str(detail.get(key, ""))
191
+ for key in ("provider_code", "provider_type", "provider_message", "message")
192
+ ).lower()
193
+ if any(
194
+ marker in text
195
+ for marker in (
196
+ "insufficient_quota",
197
+ "no credits remaining",
198
+ "credit balance is too low",
199
+ "billing_hard_limit_reached",
200
+ "exceeded your current quota",
201
+ )
202
+ ):
203
+ return (
204
+ "Provider quota or credits exhausted. Check usage and billing for the "
205
+ "selected provider account; a new login may not help. "
206
+ "See the saved session diagnostics."
207
+ )
208
+ rate_limited |= detail.get("status") == 429 or (
209
+ "status" not in detail
210
+ and any(marker in text for marker in ("rate_limit_error", "rate_limit_exceeded"))
211
+ )
212
+ detail = detail.get("cause", detail.get("context", {}))
213
+ if rate_limited:
214
+ return (
215
+ f"Provider rate limit reached{f' (HTTP {http_status})' if http_status else ''}. "
216
+ "Wait before retrying and check "
217
+ "the selected account's usage limits. See the saved session diagnostics."
218
+ )
219
+ return None
220
+
221
+
222
+ def versions() -> dict[str, str]:
223
+ return {
224
+ package: version(package)
225
+ for package in ("pcode", "pydantic-ai-slim", "pydantic-ai-harness")
226
+ }
pcode/edit_preview.py ADDED
@@ -0,0 +1,158 @@
1
+ """Transient, line-buffered projections of tool arguments; never execute them."""
2
+
3
+ from copy import deepcopy
4
+ from pathlib import Path
5
+ from time import monotonic
6
+
7
+ from pydantic_ai.messages import (
8
+ FunctionToolCallEvent,
9
+ FunctionToolResultEvent,
10
+ PartDeltaEvent,
11
+ PartEndEvent,
12
+ PartStartEvent,
13
+ ToolCallPart,
14
+ ToolCallPartDelta,
15
+ )
16
+ from pydantic_core import from_json
17
+
18
+ from pcode.edits import MAX_SOURCE, edit_text, sensitive_path
19
+ from pcode.runtime import EditPreview
20
+
21
+
22
+ class StreamingEditPreview:
23
+ def __init__(self, root: Path | None = None):
24
+ self.root = (root or Path.cwd()).resolve()
25
+ self.parts = {}
26
+ self.shown = {}
27
+ self.updated = {}
28
+ self.blocked = set()
29
+
30
+ def update(self, event):
31
+ clear = []
32
+ if isinstance(event, PartStartEvent):
33
+ if not isinstance(event.part, ToolCallPart):
34
+ return clear
35
+ index = event.index
36
+ if index in self.shown:
37
+ clear.append(EditPreview(f"edit-preview:{index}"))
38
+ self.shown.pop(index, None)
39
+ self.blocked.discard(index)
40
+ self.updated.pop(index, None)
41
+ self.parts[index] = deepcopy(event.part)
42
+ elif isinstance(event, PartDeltaEvent) and isinstance(event.delta, ToolCallPartDelta):
43
+ index = event.index
44
+ if index not in self.parts or index in self.blocked:
45
+ return clear
46
+ self.parts[index] = event.delta.apply(self.parts[index])
47
+ elif isinstance(event, PartEndEvent) and isinstance(event.part, ToolCallPart):
48
+ index = event.index
49
+ if index in self.blocked:
50
+ return clear
51
+ self.parts[index] = deepcopy(event.part)
52
+ elif isinstance(event, (FunctionToolCallEvent, FunctionToolResultEvent)):
53
+ call_id = event.part.tool_call_id
54
+ for index, part in list(self.parts.items()):
55
+ if part.tool_call_id == call_id:
56
+ self.parts.pop(index)
57
+ self.updated.pop(index, None)
58
+ if self.shown.pop(index, None) is not None:
59
+ clear.append(EditPreview(f"edit-preview:{index}"))
60
+ return clear
61
+ else:
62
+ return clear
63
+
64
+ part = self.parts[index]
65
+ if part.tool_name not in ("edit_file", "write_file", "run_code"):
66
+ return clear
67
+ if isinstance(part.args, str) and len(part.args) > MAX_SOURCE * 2:
68
+ self.blocked.add(index)
69
+ self.parts.pop(index)
70
+ if self.shown.pop(index, None) is not None:
71
+ clear.append(EditPreview(f"edit-preview:{index}"))
72
+ return clear
73
+ now = monotonic()
74
+ if isinstance(event, PartDeltaEvent) and now - self.updated.get(index, 0) < 0.05:
75
+ return clear
76
+ self.updated[index] = now
77
+ try:
78
+ if isinstance(part.args, str):
79
+ complete = from_json(part.args, allow_partial=True)
80
+ partial = from_json(part.args, allow_partial="trailing-strings")
81
+ else:
82
+ complete = partial = part.args
83
+ except ValueError:
84
+ return clear
85
+ if not isinstance(complete, dict) or not isinstance(partial, dict):
86
+ return clear
87
+ if part.tool_name == "run_code":
88
+ return clear + self._code_preview(index, partial, complete)
89
+ path = complete.get("path")
90
+ # Never expose content until the complete path is known and checked.
91
+ permitted = isinstance(path, str) and not sensitive_path(path)
92
+ if permitted:
93
+ try:
94
+ resolved = (self.root / path).resolve()
95
+ permitted = (
96
+ not Path(path).is_absolute()
97
+ and resolved.is_relative_to(self.root)
98
+ and not sensitive_path(str(resolved.relative_to(self.root)))
99
+ )
100
+ except (OSError, ValueError, RuntimeError):
101
+ permitted = False
102
+ if not permitted:
103
+ if self.shown.pop(index, None) is not None:
104
+ clear.append(EditPreview(f"edit-preview:{index}"))
105
+ return clear
106
+ lines = []
107
+ fields = (
108
+ [("content", "+")]
109
+ if part.tool_name == "write_file"
110
+ else [("old_text", "-"), ("new_text", "+")]
111
+ )
112
+ pairs = [(partial, complete)]
113
+ if part.tool_name == "edit_file" and isinstance(partial.get("replacements"), list):
114
+ finished = complete.get("replacements", [])
115
+ if not isinstance(finished, list):
116
+ finished = []
117
+ pairs = [
118
+ (item, finished[i] if i < len(finished) and isinstance(finished[i], dict) else {})
119
+ for i, item in enumerate(partial["replacements"])
120
+ if isinstance(item, dict)
121
+ ]
122
+ for proposed, finished in pairs:
123
+ for field, prefix in fields:
124
+ text = proposed.get(field)
125
+ if not isinstance(text, str) or len(text) > MAX_SOURCE:
126
+ continue
127
+ # Sanitize before clipping; unfinished strings expose only whole lines.
128
+ text = edit_text(text)
129
+ if field not in finished:
130
+ text = text[: text.rfind("\n") + 1]
131
+ lines.extend(prefix + line for line in text.splitlines())
132
+ preview = EditPreview(f"edit-preview:{index}", edit_text(path), "\n".join(lines)[-8192:])
133
+ return clear + self._show(index, preview)
134
+
135
+ def _code_preview(self, index: int, partial: dict, complete: dict) -> list:
136
+ """Project a sandboxed snippet as it streams, before it is ever executed."""
137
+ code = partial.get("code")
138
+ if not isinstance(code, str) or len(code) > MAX_SOURCE:
139
+ if self.shown.pop(index, None) is not None:
140
+ return [EditPreview(f"edit-preview:{index}")]
141
+ return []
142
+ # Sanitize before clipping; an unfinished string exposes only whole lines.
143
+ text = edit_text(code)
144
+ if "code" not in complete:
145
+ text = text[: text.rfind("\n") + 1]
146
+ if not text:
147
+ # No complete line yet: an empty box would only flash open and shut.
148
+ return []
149
+ return self._show(
150
+ index,
151
+ EditPreview(f"edit-preview:{index}", "run_code", text[-8192:], kind="code"),
152
+ )
153
+
154
+ def _show(self, index: int, preview: EditPreview) -> list:
155
+ if preview == self.shown.get(index):
156
+ return []
157
+ self.shown[index] = preview
158
+ return [preview]