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/inspection.py ADDED
@@ -0,0 +1,289 @@
1
+ """Read-only inspection projection; never used to replay or execute tools.
2
+
3
+ Saved payloads stay in the existing private UI journal and are loaded on selection.
4
+ Unsaved payloads have a global memory budget; call metadata is retained on eviction.
5
+ File changes are held in memory either way: each patch is already capped when made.
6
+ """
7
+
8
+ import json
9
+ import math
10
+ import re
11
+ from collections import deque
12
+ from dataclasses import asdict, dataclass, field
13
+ from pathlib import Path
14
+
15
+ from pcode.edits import change_from_record
16
+ from pcode.runtime import EditCompleted, ToolStarted, ToolSummary
17
+ from pcode.tool_display import command_text
18
+
19
+ PAYLOAD_LIMIT = 128 * 1024 # characters per arguments/result field
20
+ MEMORY_LIMIT = 8 * 1024 * 1024 # UTF-8 bytes of unsaved payload text
21
+ MISSING = "Details were not captured for this call (older session or preview)."
22
+
23
+
24
+ def _redact_fields(value: object) -> object:
25
+ if isinstance(value, dict):
26
+ return {
27
+ key: "[redacted]"
28
+ if re.search(
29
+ r"(?i)password|passwd|secret|token|authorization|api.?key|private.?key", str(key)
30
+ )
31
+ else _redact_fields(item)
32
+ for key, item in value.items()
33
+ }
34
+ if isinstance(value, (list, tuple)):
35
+ return [_redact_fields(item) for item in value]
36
+ return value
37
+
38
+
39
+ def capture(value: object) -> str:
40
+ """Render a bounded, redacted text projection, not arbitrary object reprs."""
41
+ text = (
42
+ value
43
+ if isinstance(value, str)
44
+ else json.dumps(
45
+ _redact_fields(value),
46
+ ensure_ascii=False,
47
+ indent=2,
48
+ default=lambda obj: f"<{type(obj).__name__} omitted>",
49
+ )
50
+ )
51
+ text = command_text(text)
52
+ if len(text) > PAYLOAD_LIMIT:
53
+ text = text[:PAYLOAD_LIMIT] + "\n[Inspector truncated this payload at 128 Ki characters.]"
54
+ return text
55
+
56
+
57
+ @dataclass
58
+ class Payload:
59
+ text: str | None = None
60
+ path: Path | None = None
61
+ offset: int = 0
62
+ key: str = ""
63
+
64
+ def read(self) -> str:
65
+ if self.path is None:
66
+ return self.text if self.text is not None else MISSING
67
+ try:
68
+ with self.path.open("rb") as file:
69
+ file.seek(self.offset)
70
+ record = json.loads(file.readline())
71
+ if not isinstance(record, dict):
72
+ return "Saved details are unavailable: damaged journal record."
73
+ value = record.get(self.key)
74
+ if value is None and self.key == "result" and record.get("error"):
75
+ return capture(record["error"]) + "\n\n" + MISSING
76
+ return capture(value) if value is not None else MISSING
77
+ except (OSError, ValueError):
78
+ return "Saved details are unavailable or the journal record is damaged."
79
+
80
+
81
+ @dataclass
82
+ class InspectedCall:
83
+ call_id: str
84
+ name: str
85
+ run_id: str
86
+ started_at: str = "unavailable"
87
+ state: str = "running"
88
+ detail: str = ""
89
+ summary: str = ""
90
+ elapsed: float | None = None
91
+ outcome: str = ""
92
+ process_id: str = ""
93
+ execution: str = ""
94
+ arguments: Payload = field(default_factory=Payload)
95
+ result: Payload = field(default_factory=Payload)
96
+ # The file changes this call made: already sanitized and size-capped.
97
+ changes: list[EditCompleted] = field(default_factory=list)
98
+
99
+ def title(self) -> str:
100
+ # Only "background" is worth a row tag: waiting is the default, and the
101
+ # detail pane names the mode either way.
102
+ tag = " (background)" if self.execution == "background" else ""
103
+ return f"{self.state:11} {self.name}{tag} · {self.detail}"
104
+
105
+ def metadata(self, calls: list["InspectedCall"]) -> list[tuple[str, str]]:
106
+ """Label/value rows describing the call, shared by text and Rich renderings."""
107
+ timing = f"{self.elapsed:.2f}s" if self.elapsed is not None else "unavailable"
108
+ rows = [
109
+ ("Call", self.call_id),
110
+ ("Run", self.run_id),
111
+ ("Started", self.started_at),
112
+ ("Duration", timing),
113
+ *([("Execution", self.execution)] if self.execution else []),
114
+ ("Framework outcome", self.outcome or "unavailable"),
115
+ ("Summary", self.summary),
116
+ ]
117
+ if self.process_id:
118
+ related = [
119
+ c.call_id for c in calls if c is not self and c.process_id == self.process_id
120
+ ]
121
+ rows.append(("Background process", self.process_id))
122
+ rows.append(("Related calls", ", ".join(related) or "none"))
123
+ return rows
124
+
125
+ def details(self, calls: list["InspectedCall"]) -> str:
126
+ metadata = f"{self.name} · {self.state}\n" + "".join(
127
+ f"{label}: {value}\n" for label, value in self.metadata(calls)
128
+ )
129
+ return command_text(
130
+ metadata
131
+ + "\nArguments\n─────────\n"
132
+ + self.arguments.read()
133
+ + "\n\nReturned result / error\n───────────────────────\n"
134
+ + self.result.read()
135
+ + "\n\nOnly captured tool output is shown; tool-side truncation cannot be recovered."
136
+ )
137
+
138
+
139
+ class ToolArchive:
140
+ def __init__(self) -> None:
141
+ self.calls: list[InspectedCall] = []
142
+ self._running: dict[tuple[str, str], InspectedCall] = {}
143
+ self._payloads: deque[tuple[Payload, int]] = deque()
144
+ self._bytes = 0
145
+ self.run_id = "unavailable"
146
+ self._path: Path | None = None
147
+ self._file_id: tuple[int, int] | None = None
148
+ self._offset = 0
149
+
150
+ def settle(self, state: str) -> None:
151
+ for call in self._running.values():
152
+ call.state = state
153
+ self._running.clear()
154
+
155
+ def _payload(self, record: dict, key: str, path: Path | None, offset: int) -> Payload:
156
+ if path is not None:
157
+ return Payload(path=path, offset=offset, key=key)
158
+ value = record.get(key)
159
+ if value is None and key == "result" and record.get("error"):
160
+ value = capture(record["error"]) + "\n\n" + MISSING
161
+ payload = Payload(text=capture(value) if value is not None else None)
162
+ size = len((payload.text or "").encode("utf-8"))
163
+ self._payloads.append((payload, size))
164
+ self._bytes += size
165
+ while self._bytes > MEMORY_LIMIT:
166
+ old, count = self._payloads.popleft()
167
+ old.text = "[Payload evicted from the unsaved session's 8 MiB inspection budget.]"
168
+ self._bytes -= count
169
+ return payload
170
+
171
+ def record(self, record: dict, *, path: Path | None = None, offset: int = 0) -> None:
172
+ kind = record.get("kind")
173
+ if not isinstance(kind, str):
174
+ return
175
+ # Ignore malformed metadata without losing healthy surrounding records.
176
+ for key in (
177
+ "name",
178
+ "call_id",
179
+ "run_id",
180
+ "detail",
181
+ "command",
182
+ "purpose",
183
+ "process_id",
184
+ "execution",
185
+ "started_at",
186
+ "time",
187
+ "outcome",
188
+ ):
189
+ if key in record and not isinstance(record[key], str):
190
+ return
191
+ if kind == "turn_started":
192
+ self.settle("unknown")
193
+ self.run_id = record.get("run_id", str(offset))
194
+ return
195
+ if kind in {"turn_completed", "turn_failed", "turn_cancelled"}:
196
+ self.settle("interrupted" if kind == "turn_cancelled" else "unknown")
197
+ return
198
+ if kind == "EditCompleted":
199
+ self._change(record)
200
+ return
201
+ if kind not in {"ToolStarted", "ToolSummary"}:
202
+ return
203
+ if not record.get("name"):
204
+ return
205
+ call_id = record.get("call_id") or f"unavailable-{len(self.calls) + 1}"
206
+ run_id = record.get("run_id") or self.run_id
207
+ key = (run_id, call_id)
208
+ call = self._running.get(key)
209
+ if call is None:
210
+ call = InspectedCall(call_id, capture(record["name"]), run_id)
211
+ self.calls.append(call)
212
+ # Purpose leads the row, command follows it: this list is the inventory
213
+ # of what ran, so it must keep the command a stated intention can't prove.
214
+ invocation = record.get("command") or record.get("detail", "")
215
+ purpose = record.get("purpose") or ""
216
+ if purpose and record.get("command"):
217
+ invocation = f"{purpose} · {invocation}"
218
+ call.detail = command_text(invocation)[:300]
219
+ call.summary = command_text(record.get("detail", ""))[:1000]
220
+ call.process_id = command_text(record.get("process_id") or call.process_id)
221
+ call.execution = record.get("execution") or call.execution
222
+ if kind == "ToolStarted":
223
+ call.started_at = record.get("started_at") or record.get("time", "unavailable")
224
+ call.arguments = self._payload(record, "arguments", path, offset)
225
+ self._running[key] = call
226
+ else:
227
+ call.result = self._payload(record, "result", path, offset)
228
+ elapsed = record.get("elapsed_seconds")
229
+ call.elapsed = (
230
+ elapsed
231
+ if (type(elapsed) in (int, float) and math.isfinite(elapsed) and elapsed >= 0)
232
+ else None
233
+ )
234
+ call.state = "failed" if record.get("failed") else "succeeded"
235
+ call.outcome = record.get("outcome", "")
236
+ self._running.pop(key, None)
237
+
238
+ def _change(self, record: dict) -> None:
239
+ """Attach a change to the call in this turn that made it."""
240
+ call_id = record.get("call_id")
241
+ if not (
242
+ call_id
243
+ and isinstance(record.get("path"), str)
244
+ and isinstance(record.get("operation"), str)
245
+ and all(isinstance(record.get(key, ""), str) for key in ("patch", "omitted"))
246
+ and all(type(record.get(key, 0)) is int for key in ("added", "removed"))
247
+ and type(record.get("truncated", False)) is bool
248
+ ):
249
+ return
250
+ # Retries and resumed sessions can reuse a call id, so the turn must match too.
251
+ run_id = record.get("run_id") or self.run_id
252
+ call = next(
253
+ (c for c in reversed(self.calls) if (c.run_id, c.call_id) == (run_id, call_id)), None
254
+ )
255
+ if call is not None:
256
+ call.changes.append(change_from_record(record))
257
+
258
+ def event(self, event: ToolStarted | ToolSummary | EditCompleted) -> None:
259
+ self.record({"kind": type(event).__name__, **asdict(event)})
260
+
261
+ def update(self, path: Path) -> None:
262
+ """Incrementally index complete appends; payloads remain file-offset references."""
263
+ stat = path.stat()
264
+ identity = (stat.st_dev, stat.st_ino)
265
+ if path != self._path or identity != self._file_id or stat.st_size < self._offset:
266
+ self.__init__()
267
+ self._path, self._file_id = path, identity
268
+ with path.open("rb") as file:
269
+ file.seek(self._offset)
270
+ while True:
271
+ offset = file.tell()
272
+ line = file.readline()
273
+ if not line or not line.endswith(b"\n"):
274
+ break # Retry an incomplete append on the next open.
275
+ self._offset = file.tell()
276
+ try:
277
+ record = json.loads(line)
278
+ except ValueError:
279
+ continue
280
+ if isinstance(record, dict):
281
+ self.record(record, path=path, offset=offset)
282
+ for call in self._running.values():
283
+ call.state = "unknown"
284
+
285
+ @classmethod
286
+ def load(cls, path: Path) -> "ToolArchive":
287
+ archive = cls()
288
+ archive.update(path)
289
+ return archive