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/profiling.py ADDED
@@ -0,0 +1,566 @@
1
+ """Opt-in, local-only profiling. Never capture arguments, locals, or source text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ import platform
9
+ import re
10
+ import shutil
11
+ import sys
12
+ import threading
13
+ import time
14
+ import tracemalloc
15
+ from contextlib import contextmanager
16
+ from pathlib import Path
17
+ from typing import Iterator
18
+
19
+ PROFILE_MODES = ("off", "resources", "cpu", "memory")
20
+ """The `profile` preference: no capture, sampling only, or sampling plus one tracer."""
21
+
22
+ CAPTURES_KEPT = 20
23
+ """Automatically named captures retained. An always-on capture writes one directory
24
+ per session, so unbounded growth would be the cost of leaving the setting on."""
25
+
26
+ _AUTOMATIC_NAME = re.compile(r"\d{8}-\d{6}-\d+\Z")
27
+
28
+
29
+ def captures_root() -> Path:
30
+ """`PCODE_PROFILE_DIR` overrides where automatically named captures are written."""
31
+ override = os.environ.get("PCODE_PROFILE_DIR", "").strip()
32
+ if override:
33
+ return Path(override).expanduser()
34
+ state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
35
+ return state / "pcode" / "profiles"
36
+
37
+
38
+ def new_capture(root: Path | None = None) -> Path:
39
+ """A fresh per-session directory, since a capture refuses to reuse an existing one."""
40
+ root = captures_root() if root is None else root
41
+ return root / f"{time.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
42
+
43
+
44
+ def prune_captures(root: Path | None = None, keep: int | None = None) -> list[Path]:
45
+ """Delete all but the newest `keep` automatic captures, by name.
46
+
47
+ Only `new_capture` names are considered, so a capture written to a directory
48
+ the user named is never removed. Retention is best effort: a capture that
49
+ cannot be deleted is left in place rather than failing the session.
50
+ """
51
+ root = captures_root() if root is None else root
52
+ keep = CAPTURES_KEPT if keep is None else keep
53
+ try:
54
+ candidates = sorted(
55
+ path for path in root.iterdir() if path.is_dir() and _AUTOMATIC_NAME.match(path.name)
56
+ )
57
+ except OSError:
58
+ return []
59
+ removed = []
60
+ for path in candidates[: max(0, len(candidates) - keep)]:
61
+ try:
62
+ shutil.rmtree(path)
63
+ except OSError:
64
+ continue
65
+ removed.append(path)
66
+ return removed
67
+
68
+
69
+ class ActivityTotals:
70
+ """Wall and process CPU time spent inside named spans; the rest of a run is idle.
71
+
72
+ Spans are reference counted per label rather than stacked: concurrent work
73
+ opens one span, and the earliest still-open label names the current activity.
74
+ Overlapping labels each see the whole process's CPU for their own span, so
75
+ their CPU seconds can sum to more than the process spent.
76
+ """
77
+
78
+ def __init__(self) -> None:
79
+ self._lock = threading.Lock()
80
+ self._open: dict[str, tuple[int, float, float]] = {}
81
+ self._totals: dict[str, dict[str, float]] = {}
82
+
83
+ @property
84
+ def label(self) -> str | None:
85
+ """The longest-running open span's label, or None while nothing is running."""
86
+ return next(iter(self._open), None)
87
+
88
+ def enter(self, label: str) -> None:
89
+ with self._lock:
90
+ depth, wall, cpu = self._open.get(label, (0, 0.0, 0.0))
91
+ if depth == 0:
92
+ wall, cpu = time.monotonic(), time.process_time()
93
+ self._open[label] = (depth + 1, wall, cpu)
94
+ self._totals.setdefault(label, {"wall_seconds": 0.0, "cpu_seconds": 0.0, "spans": 0})
95
+ self._totals[label]["spans"] += 1
96
+
97
+ def exit(self, label: str) -> None:
98
+ with self._lock:
99
+ current = self._open.get(label)
100
+ if current is None:
101
+ return
102
+ depth, wall, cpu = current
103
+ if depth > 1:
104
+ # Assigning an existing key keeps insertion order, so a nested
105
+ # span does not make its label look like the newest one.
106
+ self._open[label] = (depth - 1, wall, cpu)
107
+ return
108
+ del self._open[label]
109
+ entry = self._totals[label]
110
+ entry["wall_seconds"] += time.monotonic() - wall
111
+ entry["cpu_seconds"] += time.process_time() - cpu
112
+
113
+ def snapshot(self) -> dict[str, dict[str, float]]:
114
+ """Totals including the elapsed part of spans that are still open."""
115
+ with self._lock:
116
+ now, cpu_now = time.monotonic(), time.process_time()
117
+ totals = {label: dict(entry) for label, entry in self._totals.items()}
118
+ for label, (_, wall, cpu) in self._open.items():
119
+ totals[label]["wall_seconds"] += now - wall
120
+ totals[label]["cpu_seconds"] += cpu_now - cpu
121
+ return totals
122
+
123
+
124
+ _ACTIVITY: ActivityTotals | None = None
125
+ """Set only while a capture runs, so `activity()` costs an attribute read otherwise."""
126
+
127
+
128
+ @contextmanager
129
+ def activity(label: str) -> Iterator[None]:
130
+ """Attribute the enclosed work to `label` when a capture is running.
131
+
132
+ Safe to call from anywhere: without a capture it does nothing, and a capture
133
+ that stops mid-span still receives that span's time.
134
+ """
135
+ totals = _ACTIVITY
136
+ if totals is None:
137
+ yield
138
+ return
139
+ totals.enter(label)
140
+ try:
141
+ yield
142
+ finally:
143
+ totals.exit(label)
144
+
145
+
146
+ class ResourceProfile:
147
+ """Optional function timings plus sampled process-tree resource usage.
148
+
149
+ Samples are streamed to disk, not retained in memory. Descendants can disappear
150
+ between samples; their totals are observations, not complete job accounting.
151
+ """
152
+
153
+ def __init__(
154
+ self,
155
+ directory: Path,
156
+ *,
157
+ cpu: bool = False,
158
+ memory: bool = False,
159
+ interval: float = 1.0,
160
+ discovery_interval: float = 5.0,
161
+ ):
162
+ self.directory = directory
163
+ self.memory = memory
164
+ self.cpu = cpu
165
+ self.interval = interval
166
+ # Finding descendants costs far more than measuring known ones, so it
167
+ # runs on its own slower clock; never faster than sampling itself.
168
+ self.discovery_interval = max(interval, discovery_interval)
169
+ self._stop = threading.Event()
170
+ self._tracked: dict[int, object] = {}
171
+ self._discovered = float("-inf")
172
+ self._previous: dict[tuple[int, float], tuple[float, float]] = {}
173
+ self._peak_rss = 0
174
+ self._peak_children_rss = 0
175
+ self._samples = 0
176
+ self._sampling_errors = 0
177
+ self.activity = ActivityTotals()
178
+ self._cpu = None
179
+ self._cpu_clock = None
180
+
181
+ def _open(self, name: str):
182
+ fd = os.open(self.directory / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
183
+ return os.fdopen(fd, "w", encoding="utf-8")
184
+
185
+ def _replace(self, name: str, payload: dict) -> None:
186
+ """Rewrite a file atomically, so an interrupted capture leaves the last one whole."""
187
+ temporary = self.directory / f".{name}.partial"
188
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
189
+ try:
190
+ with os.fdopen(fd, "w", encoding="utf-8") as output:
191
+ json.dump(payload, output, indent=2)
192
+ output.write("\n")
193
+ os.replace(temporary, self.directory / name)
194
+ except BaseException:
195
+ temporary.unlink(missing_ok=True)
196
+ raise
197
+
198
+ def start(self) -> None:
199
+ global _ACTIVITY
200
+ import psutil
201
+
202
+ if self.memory and tracemalloc.is_tracing():
203
+ raise ValueError("allocation tracing is already active")
204
+ if self.cpu:
205
+ # Not cProfile: on Python 3.14 it can observe worker threads, and a
206
+ # `time.thread_time` timer then produces negative timings. Yappi
207
+ # accounts CPU per thread.
208
+ import yappi
209
+
210
+ if yappi.is_running() or yappi.get_func_stats():
211
+ raise ValueError("function profiler is already in use")
212
+ self._cpu = yappi
213
+ self._cpu_clock = yappi.get_clock_type()
214
+ # Refuse reuse rather than clobbering an earlier capture or following a symlink.
215
+ self.directory.mkdir(mode=0o700, parents=True, exist_ok=False)
216
+ self._process = psutil.Process()
217
+ self._stream = self._open("resources.jsonl")
218
+ self._started = time.monotonic()
219
+ self._cpu_started = time.process_time()
220
+ self.activity = ActivityTotals()
221
+ _ACTIVITY = self.activity
222
+ self._thread = threading.Thread(target=self._monitor, name="resource-profile", daemon=True)
223
+ try:
224
+ if self.memory:
225
+ tracemalloc.start(10)
226
+ self._sample()
227
+ self._thread.start()
228
+ if self._cpu is not None:
229
+ self._cpu.set_clock_type("cpu")
230
+ self._cpu.start(builtins=True, profile_threads=True)
231
+ except BaseException:
232
+ _ACTIVITY = None
233
+ self._stop.set()
234
+ if self._thread.is_alive():
235
+ self._thread.join()
236
+ try:
237
+ self._stream.close()
238
+ finally:
239
+ if self.memory:
240
+ tracemalloc.stop()
241
+ self._clear_cpu()
242
+ raise
243
+
244
+ def _descendants(self, psutil, now: float) -> list:
245
+ """Known descendants, rediscovering the process tree only occasionally.
246
+
247
+ `children(recursive=True)` has to read every process on the machine to
248
+ build a parent map, which dominates a sample: about 11 ms of 12 ms on a
249
+ machine with a thousand processes, or a percent of a core at one sample
250
+ per second. Re-measuring processes already known is thousands of times
251
+ cheaper, so discovery runs every `discovery_interval` instead. A
252
+ descendant is therefore picked up within that window, and one that both
253
+ starts and exits inside a window is missed entirely -- as anything
254
+ shorter than `interval` always was.
255
+ """
256
+ if now - self._discovered < self.discovery_interval:
257
+ return list(self._tracked.values())
258
+ self._discovered = now
259
+ try:
260
+ found = self._process.children(recursive=True)
261
+ except psutil.Error:
262
+ self._sampling_errors += 1
263
+ return list(self._tracked.values())
264
+ # Keep the object already held for a surviving PID: psutil caches its
265
+ # creation time there, which is what detects a recycled PID later.
266
+ self._tracked = {process.pid: self._tracked.get(process.pid, process) for process in found}
267
+ return list(self._tracked.values())
268
+
269
+ def _sample(self) -> None:
270
+ import psutil
271
+
272
+ now = time.monotonic()
273
+ processes = []
274
+ previous = {}
275
+ for process in [self._process, *self._descendants(psutil, now)]:
276
+ try:
277
+ with process.oneshot():
278
+ identity = (process.pid, process.create_time())
279
+ cpu = process.cpu_times()
280
+ cpu_seconds = cpu.user + cpu.system
281
+ rss = process.memory_info().rss
282
+ threads = process.num_threads()
283
+ ppid = process.ppid()
284
+ before = self._previous.get(identity)
285
+ percent = None
286
+ if before is not None and now > before[0]:
287
+ percent = max(0.0, 100 * (cpu_seconds - before[1]) / (now - before[0]))
288
+ previous[identity] = (now, cpu_seconds)
289
+ processes.append(
290
+ dict(
291
+ pid=process.pid,
292
+ ppid=ppid,
293
+ role="pcode" if process.pid == self._process.pid else "child",
294
+ cpu_seconds=cpu_seconds,
295
+ cpu_percent=percent,
296
+ rss_bytes=rss,
297
+ threads=threads,
298
+ )
299
+ )
300
+ except psutil.Error:
301
+ # Gone, or a PID psutil no longer recognizes as the same process.
302
+ # Either way it is not a descendant to measure next time.
303
+ self._tracked.pop(process.pid, None)
304
+ self._sampling_errors += 1
305
+ self._previous = previous
306
+ own_rss = next((p["rss_bytes"] for p in processes if p["role"] == "pcode"), 0)
307
+ children_rss = sum(p["rss_bytes"] for p in processes if p["role"] == "child")
308
+ self._peak_rss = max(self._peak_rss, own_rss)
309
+ self._peak_children_rss = max(self._peak_children_rss, children_rss)
310
+ sample = dict(
311
+ elapsed_seconds=now - self._started,
312
+ activity=self.activity.label,
313
+ processes=processes,
314
+ )
315
+ if self.memory:
316
+ current, peak = tracemalloc.get_traced_memory()
317
+ sample.update(python_current_bytes=current, python_peak_bytes=peak)
318
+ self._stream.write(json.dumps(sample) + "\n")
319
+ self._stream.flush()
320
+ self._samples += 1
321
+ # A live session is usually killed with its terminal rather than exited
322
+ # cleanly, so the aggregate is republished with every sample instead of
323
+ # only at shutdown.
324
+ self._replace("summary.json", self._summary(complete=False))
325
+
326
+ def _monitor(self) -> None:
327
+ while not self._stop.wait(self.interval):
328
+ try:
329
+ self._sample()
330
+ except OSError:
331
+ self._sampling_errors += 1
332
+ return
333
+
334
+ def _clear_cpu(self) -> None:
335
+ if self._cpu is not None:
336
+ self._cpu.stop()
337
+ self._cpu.clear_stats()
338
+ self._cpu.set_clock_type(self._cpu_clock)
339
+
340
+ def _write_cpu(self) -> None:
341
+ if self._cpu is None:
342
+ return
343
+ with self._open("cpu.pstats"):
344
+ pass
345
+ stats = self._cpu.convert2pstats(self._cpu.get_func_stats())
346
+ stats.dump_stats(str(self.directory / "cpu.pstats"))
347
+ report = io.StringIO()
348
+ stats.stream = report
349
+ stats.strip_dirs()
350
+ report.write(
351
+ "Function CPU seconds across Python threads; sleeping/network waits excluded.\n"
352
+ )
353
+ report.write(
354
+ "Includes profiling overhead. Native-only threads/children are not attributed.\n"
355
+ )
356
+ report.write("\nTop cumulative CPU:\n")
357
+ stats.sort_stats("cumulative").print_stats(50)
358
+ report.write("\nTop self CPU:\n")
359
+ stats.sort_stats("tottime").print_stats(50)
360
+ with self._open("cpu.txt") as output:
361
+ output.write(report.getvalue())
362
+
363
+ def _summary(
364
+ self, *, complete: bool, elapsed: float | None = None, cpu_seconds: float | None = None
365
+ ) -> dict:
366
+ if elapsed is None:
367
+ elapsed = time.monotonic() - self._started
368
+ if cpu_seconds is None:
369
+ cpu_seconds = time.process_time() - self._cpu_started
370
+ attributed = self.activity.snapshot()
371
+ busy = sum(entry["wall_seconds"] for entry in attributed.values())
372
+ busy_cpu = sum(entry["cpu_seconds"] for entry in attributed.values())
373
+ return dict(
374
+ schema_version=3,
375
+ # A capture that was killed still has every field except this one.
376
+ complete=complete,
377
+ python=platform.python_version(),
378
+ platform=sys.platform,
379
+ elapsed_seconds=elapsed,
380
+ process_cpu_seconds=cpu_seconds,
381
+ average_cpu_percent=100 * cpu_seconds / elapsed if elapsed else 0,
382
+ sampled_peak_rss_bytes=self._peak_rss,
383
+ sampled_peak_children_rss_bytes=self._peak_children_rss,
384
+ samples=self._samples,
385
+ sampling_errors=self._sampling_errors,
386
+ interval_seconds=self.interval,
387
+ descendant_scan_seconds=self.discovery_interval,
388
+ memory_tracing=self.memory,
389
+ function_clock="cpu" if self._cpu is not None else None,
390
+ function_scope="python_threads" if self._cpu is not None else None,
391
+ activity=attributed,
392
+ # Time outside every span: what the session cost while doing nothing
393
+ # the app named. Overlapping spans can push these below zero.
394
+ idle_seconds=max(0.0, elapsed - busy),
395
+ idle_cpu_seconds=max(0.0, cpu_seconds - busy_cpu),
396
+ )
397
+
398
+ def stop(self) -> None:
399
+ global _ACTIVITY
400
+
401
+ if self._cpu is not None:
402
+ self._cpu.stop()
403
+ elapsed = time.monotonic() - self._started
404
+ cpu_seconds = time.process_time() - self._cpu_started
405
+ _ACTIVITY = None
406
+ self._stop.set()
407
+ self._thread.join()
408
+ try:
409
+ # One last full walk: the closing sample should describe the tree as
410
+ # it is now, not as the previous discovery left it.
411
+ self._discovered = float("-inf")
412
+ self._sample()
413
+ self._stream.close()
414
+ memory_usage = None
415
+ snapshot = None
416
+ if self.memory:
417
+ memory_usage = tracemalloc.get_traced_memory()
418
+ snapshot = tracemalloc.take_snapshot()
419
+ tracemalloc.stop()
420
+ self._write_cpu()
421
+ summary = self._summary(complete=True, elapsed=elapsed, cpu_seconds=cpu_seconds)
422
+ if snapshot is not None and memory_usage is not None:
423
+ current, peak = memory_usage
424
+ summary.update(python_current_bytes=current, python_peak_bytes=peak)
425
+ # No source lines or heap contents, only allocation locations/sizes.
426
+ allocations = [
427
+ dict(
428
+ filename=stat.traceback[0].filename,
429
+ line=stat.traceback[0].lineno,
430
+ size_bytes=stat.size,
431
+ count=stat.count,
432
+ )
433
+ for stat in snapshot.statistics("lineno")[:50]
434
+ ]
435
+ with self._open("allocations.json") as output:
436
+ json.dump(allocations, output, indent=2)
437
+ output.write("\n")
438
+ self._replace("summary.json", summary)
439
+ finally:
440
+ try:
441
+ self._stream.close()
442
+ finally:
443
+ if self.memory:
444
+ tracemalloc.stop()
445
+ self._clear_cpu()
446
+
447
+
448
+ @contextmanager
449
+ def profile_session(directory: Path, *, cpu: bool = False, memory: bool = False) -> Iterator[None]:
450
+ profile = ResourceProfile(directory, cpu=cpu, memory=memory)
451
+ profile.start()
452
+ try:
453
+ yield
454
+ finally:
455
+ try:
456
+ profile.stop()
457
+ except Exception as error:
458
+ # Profiling must not hide the application's error or change its exit code.
459
+ print(f"Profile could not be finalized ({type(error).__name__}).", file=sys.stderr)
460
+ else:
461
+ print(f"Profile saved to {directory}", file=sys.stderr)
462
+
463
+
464
+ def _megabytes(value: float) -> str:
465
+ return f"{value / 1_000_000:.1f} MB"
466
+
467
+
468
+ def report(directory: Path) -> str:
469
+ """A readable digest of one capture, so the artifacts need no extra tooling.
470
+
471
+ Reads only what the capture wrote. A killed session has no `cpu`/`allocations`
472
+ artifacts and an incomplete summary; both are reported rather than treated as
473
+ an error.
474
+ """
475
+ summary = json.loads((directory / "summary.json").read_text())
476
+ elapsed = summary.get("elapsed_seconds") or 0.0
477
+ lines = [
478
+ f"Capture: {directory}",
479
+ "Ended: clean shutdown" if summary.get("complete") else "Ended: killed (last sample)",
480
+ f"Duration: {elapsed:.1f} s over {summary.get('samples', 0)} samples",
481
+ f"Process CPU: {summary.get('process_cpu_seconds', 0):.1f} s"
482
+ f" ({summary.get('average_cpu_percent', 0):.1f}% of one core)",
483
+ f"Peak RSS: {_megabytes(summary.get('sampled_peak_rss_bytes', 0))}"
484
+ f" (children {_megabytes(summary.get('sampled_peak_children_rss_bytes', 0))})",
485
+ ]
486
+
487
+ def share(cpu: float) -> str:
488
+ return f" ({100 * cpu / elapsed:.1f}% of one core)" if elapsed else ""
489
+
490
+ idle_cpu = summary.get("idle_cpu_seconds", 0.0)
491
+ lines.append(
492
+ f"Unattributed CPU: {idle_cpu:.1f} s{share(idle_cpu)}"
493
+ f" over {summary.get('idle_seconds', 0):.1f} s"
494
+ )
495
+ for label, entry in sorted(
496
+ summary.get("activity", {}).items(), key=lambda item: -item[1]["cpu_seconds"]
497
+ ):
498
+ lines.append(
499
+ f"Activity {label}: {entry['cpu_seconds']:.1f} s CPU{share(entry['cpu_seconds'])}"
500
+ f" over {entry['wall_seconds']:.1f} s in {int(entry['spans'])} spans"
501
+ )
502
+ children: dict[int, tuple[float, int]] = {}
503
+ own_peak_threads = 0
504
+ try:
505
+ with (directory / "resources.jsonl").open(encoding="utf-8") as stream:
506
+ for line in stream:
507
+ sample = json.loads(line)
508
+ for process in sample["processes"]:
509
+ if process["role"] == "pcode":
510
+ own_peak_threads = max(own_peak_threads, process["threads"])
511
+ continue
512
+ # Per-PID CPU is a lifetime counter, so the last observation
513
+ # is that child's total, not a per-sample cost.
514
+ seen = children.get(process["pid"], (0.0, 0))
515
+ children[process["pid"]] = (
516
+ max(seen[0], process["cpu_seconds"]),
517
+ max(seen[1], process["rss_bytes"]),
518
+ )
519
+ except (OSError, ValueError, KeyError):
520
+ lines.append("Samples: unreadable or truncated")
521
+ else:
522
+ lines.append(f"Peak threads in pcode: {own_peak_threads}")
523
+ ranked = sorted(children.items(), key=lambda item: -item[1][0])[:5]
524
+ for pid, (cpu, rss) in ranked:
525
+ lines.append(f"Child {pid}: {cpu:.1f} s CPU seen, peak RSS {_megabytes(rss)}")
526
+ if not ranked:
527
+ lines.append("Children: none observed")
528
+ if summary.get("sampling_errors"):
529
+ lines.append(f"Incomplete coverage: {summary['sampling_errors']} sampling errors")
530
+ for artifact, description in (
531
+ ("cpu.txt", "function CPU"),
532
+ ("allocations.json", "live allocations"),
533
+ ):
534
+ if (directory / artifact).exists():
535
+ lines.append(f"See {directory / artifact} for {description}")
536
+ return "\n".join(lines)
537
+
538
+
539
+ def main(argv: list[str] | None = None) -> int:
540
+ import argparse
541
+
542
+ parser = argparse.ArgumentParser(
543
+ prog="python -m pcode.profiling",
544
+ description="Summarize a pcode profiling capture, or list recent captures",
545
+ )
546
+ parser.add_argument("directory", type=Path, nargs="?", help="Capture directory; omit to list")
547
+ arguments = parser.parse_args(argv)
548
+ if arguments.directory is None:
549
+ root = captures_root()
550
+ captures = sorted(path for path in root.glob("*") if (path / "summary.json").exists())
551
+ if not captures:
552
+ print(f"No captures under {root}", file=sys.stderr)
553
+ return 1
554
+ for path in captures:
555
+ print(path)
556
+ return 0
557
+ try:
558
+ print(report(arguments.directory))
559
+ except (OSError, ValueError) as error:
560
+ print(f"Cannot read capture ({type(error).__name__})", file=sys.stderr)
561
+ return 1
562
+ return 0
563
+
564
+
565
+ if __name__ == "__main__":
566
+ raise SystemExit(main())
pcode/project_trust.py ADDED
@@ -0,0 +1,94 @@
1
+ """Which repositories may run the code they ship: `.pcode/extensions` and `.pcode/worktree-setup`.
2
+
3
+ Both execute at launch with the user's permissions, so a freshly cloned
4
+ repository must not get to run them just because pcode was started inside it.
5
+ Trust is granted per repository, once, by answering the launch prompt; the
6
+ answer lands in the user-only `trusted_projects` setting, keyed by the primary
7
+ checkout so every worktree of a trusted repository is covered.
8
+ `project_extensions on` remains the blanket opt-in.
9
+ """
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from pcode.preferences import SETTINGS, load_preferences, update_preferences
15
+
16
+ EXTENSIONS_DIR = Path(".pcode") / "extensions"
17
+ SETUP_SCRIPT = Path(".pcode") / "worktree-setup"
18
+
19
+
20
+ def trust_key(workspace: Path) -> Path:
21
+ """The path trust is recorded under: the primary checkout of a git repo."""
22
+ from pcode.worktree import repo_scope
23
+
24
+ return repo_scope(workspace)
25
+
26
+
27
+ def project_code(workspace: Path) -> list[Path]:
28
+ """Launch-time code the repository ships, relative to its root, or empty."""
29
+ found = []
30
+ extensions = workspace / EXTENSIONS_DIR
31
+ if extensions.is_dir() and any(not p.name.startswith(("_", ".")) for p in extensions.iterdir()):
32
+ found.append(EXTENSIONS_DIR)
33
+ if (workspace / SETUP_SCRIPT).is_file():
34
+ found.append(SETUP_SCRIPT)
35
+ return found
36
+
37
+
38
+ def trusted_projects() -> set[Path]:
39
+ raw = load_preferences().get("trusted_projects", SETTINGS["trusted_projects"].default) or ""
40
+ return {Path(entry).expanduser() for entry in raw.split(os.pathsep) if entry.strip()}
41
+
42
+
43
+ def is_trusted(workspace: Path) -> bool:
44
+ if load_preferences().get("project_extensions", "off") == "on":
45
+ return True
46
+ return trust_key(workspace) in trusted_projects()
47
+
48
+
49
+ def trust(workspace: Path) -> Path:
50
+ """Record trust for the repository containing `workspace`; returns the recorded key."""
51
+ key = trust_key(workspace)
52
+ entries = trusted_projects() | {key}
53
+ update_preferences({"trusted_projects": os.pathsep.join(str(p) for p in sorted(entries))})
54
+ return key
55
+
56
+
57
+ def prompt_trust(workspace: Path, ask=input, stream=None) -> bool:
58
+ """Ask once, at launch, whether a repository shipping code may run it.
59
+
60
+ Returns whether it is trusted afterwards. No prompt when nothing is
61
+ shipped, when already trusted, or when `ask` is None (non-interactive):
62
+ then the code is skipped and a note says so.
63
+ """
64
+ import sys
65
+
66
+ stream = stream or sys.stderr
67
+ code = project_code(workspace)
68
+ if not code:
69
+ return False
70
+ if is_trusted(workspace):
71
+ return True
72
+ key = trust_key(workspace)
73
+ listed = ", ".join(str(p) for p in code)
74
+ if ask is None:
75
+ print(f"pcode: skipping untrusted project code in {key}: {listed}", file=stream)
76
+ return False
77
+ print(
78
+ f"pcode: {key} ships code that runs at launch with your permissions: {listed}",
79
+ file=stream,
80
+ )
81
+ try:
82
+ answer = ask("Trust this repository? [y/N] ")
83
+ except (EOFError, OSError, KeyboardInterrupt):
84
+ answer = "" # closed or captured stdin: same as declining
85
+ if answer.strip().lower() in ("y", "yes"):
86
+ trust(workspace)
87
+ print(
88
+ "pcode: trusted; revoke with `pcode config unset trusted_projects` "
89
+ "or edit that setting.",
90
+ file=stream,
91
+ )
92
+ return True
93
+ print("pcode: not trusted; that code is skipped this launch.", file=stream)
94
+ return False