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,175 @@
1
+ """Markdown replies as email HTML that Gmail (web and app) shows as written.
2
+
3
+ The model writes Markdown; the plain-text part keeps it as is and the HTML part
4
+ is rendered here. Gmail drops or rewrites `<style>` blocks unpredictably, so
5
+ every style is an attribute added to the tag at render time, from the fixed
6
+ table below, never from the model's text.
7
+
8
+ Model output is untrusted: raw HTML is escaped (`html: False`), links keep
9
+ markdown-it's scheme check (no `javascript:` or `data:`), and images become
10
+ plain links, since an `<img>` loads on open and a model-chosen URL would be a
11
+ way to send data out the moment the email is read.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ import re
18
+
19
+ from markdown_it import MarkdownIt
20
+ from markdown_it.common.utils import escapeHtml
21
+ from pygments import highlight as pygmentize
22
+ from pygments.formatters import HtmlFormatter
23
+ from pygments.lexers import get_lexer_by_name
24
+ from pygments.util import ClassNotFound
25
+
26
+ MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
27
+ SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif"
28
+ CODE_BOX = (
29
+ f"font-family:{MONO};font-size:13px;line-height:1.45;background:#f6f8fa;"
30
+ "border:1px solid #d0d7de;border-radius:6px;padding:10px 12px;margin:0 0 12px;"
31
+ "white-space:pre-wrap;word-wrap:break-word"
32
+ )
33
+ STYLES = {
34
+ "p": "margin:0 0 12px",
35
+ "h1": "font-size:20px;margin:16px 0 8px",
36
+ "h2": "font-size:18px;margin:16px 0 8px",
37
+ "h3": "font-size:16px;margin:14px 0 6px",
38
+ "h4": "font-size:14px;margin:12px 0 6px",
39
+ "h5": "font-size:14px;margin:12px 0 6px",
40
+ "h6": "font-size:14px;margin:12px 0 6px;color:#57606a",
41
+ "ul": "margin:0 0 12px;padding-left:24px",
42
+ "ol": "margin:0 0 12px;padding-left:24px",
43
+ "li": "margin:2px 0",
44
+ "blockquote": "margin:0 0 12px;padding:0 12px;border-left:4px solid #d0d7de;color:#57606a",
45
+ "table": "border-collapse:collapse;margin:0 0 12px",
46
+ "th": "border:1px solid #d0d7de;padding:4px 10px;background:#f6f8fa;text-align:left",
47
+ "td": "border:1px solid #d0d7de;padding:4px 10px",
48
+ "code": (
49
+ f"font-family:{MONO};font-size:85%;background:#eff1f3;border-radius:4px;padding:1px 4px"
50
+ ),
51
+ "a": "color:#0969da",
52
+ "hr": "border:0;border-top:1px solid #d0d7de;margin:16px 0",
53
+ }
54
+ # Every tag markdown-it emits, with whatever attributes it gave it. With raw
55
+ # HTML off, a `<` in the output is always markdown-it's own, never the model's.
56
+ _TAG = re.compile(r"<(%s)((?:\s[^>]*?)?)\s*(/?)>" % "|".join(STYLES))
57
+ _STYLE_ATTR = re.compile(r'\sstyle="([^"]*)"')
58
+
59
+
60
+ def _style_tag(match: re.Match) -> str:
61
+ name, attrs, close = match.groups()
62
+ style = STYLES[name]
63
+ existing = _STYLE_ATTR.search(attrs)
64
+ if existing: # Table alignment: it replaces the default one.
65
+ style = f"{re.sub(r';?text-align:[a-z]+', '', style)};{existing.group(1)}"
66
+ attrs = _STYLE_ATTR.sub("", attrs)
67
+ return f'<{name}{attrs} style="{style}"{" /" if close else ""}>'
68
+
69
+
70
+ # Pygments' inline styles make code about 8x larger, and Gmail clips a message
71
+ # whose HTML passes ~102 KB, hiding the session detail and footer at its end.
72
+ # So long blocks stay plain, and so does everything if the total is still large.
73
+ HIGHLIGHT_LIMIT = 8_000
74
+ HTML_BUDGET = 90_000
75
+
76
+
77
+ def _code_block(code: str, language: str, env: dict) -> str:
78
+ lexer = None
79
+ if language and env.get("highlight", True) and len(code) <= HIGHLIGHT_LIMIT:
80
+ try:
81
+ lexer = get_lexer_by_name(language)
82
+ except ClassNotFound:
83
+ pass
84
+ if lexer is None:
85
+ inner = escapeHtml(code)
86
+ else:
87
+ formatter = HtmlFormatter(noclasses=True, nowrap=True, style="default")
88
+ inner = pygmentize(code, lexer, formatter)
89
+ # Built here, after styling, so the `<code>` inside keeps no inline-code chip.
90
+ return f'<pre style="{CODE_BOX}">{inner.rstrip()}</pre>\n'
91
+
92
+
93
+ def _markdown() -> MarkdownIt:
94
+ md = MarkdownIt("commonmark", {"html": False, "linkify": False, "typographer": False})
95
+ md.enable(["table", "strikethrough"])
96
+
97
+ def fence(renderer, tokens, idx, options, env):
98
+ token = tokens[idx]
99
+ language = token.info.strip().split(maxsplit=1)[0] if token.info.strip() else ""
100
+ return _code_block(token.content, language, env)
101
+
102
+ def code_block(renderer, tokens, idx, options, env):
103
+ return _code_block(tokens[idx].content, "", env)
104
+
105
+ # Only web links are live. A relative path (`[app.py](src/app.py)`, common
106
+ # from a coding agent) or a mailto: cannot work from an email: plain text.
107
+ def link_open(renderer, tokens, idx, options, env):
108
+ href = str(tokens[idx].attrGet("href") or "")
109
+ live = href.lower().startswith(("https://", "http://"))
110
+ env.setdefault("links", []).append(live)
111
+ return renderer.renderToken(tokens, idx, options, env) if live else ""
112
+
113
+ def link_close(renderer, tokens, idx, options, env):
114
+ live = env["links"].pop()
115
+ return renderer.renderToken(tokens, idx, options, env) if live else ""
116
+
117
+ def image(renderer, tokens, idx, options, env):
118
+ token = tokens[idx]
119
+ src = str(token.attrGet("src") or "")
120
+ alt = token.content or "image"
121
+ # markdown-it's own check still allows data:image/...; only web links here.
122
+ if not src.lower().startswith(("https://", "http://")):
123
+ return escapeHtml(alt)
124
+ return f'<a href="{escapeHtml(src)}">[image: {escapeHtml(alt)}]</a>'
125
+
126
+ md.add_render_rule("fence", fence)
127
+ md.add_render_rule("code_block", code_block)
128
+ md.add_render_rule("image", image)
129
+ md.add_render_rule("link_open", link_open)
130
+ md.add_render_rule("link_close", link_close)
131
+ return md
132
+
133
+
134
+ _MD = _markdown()
135
+ # Code blocks are rendered whole by _code_block; mark them so styling skips them.
136
+ _PRE = re.compile(r'<pre style="[^"]*">.*?</pre>', re.S)
137
+
138
+
139
+ def markdown_html(text: str, *, highlight: bool = True) -> str:
140
+ """Render Markdown to styled HTML fragments (no `<html>` wrapper)."""
141
+ rendered = _MD.render(text, {"highlight": highlight})
142
+ blocks: list[str] = []
143
+
144
+ def stash(match: re.Match) -> str:
145
+ blocks.append(match.group(0))
146
+ return f"\x00{len(blocks) - 1}\x00"
147
+
148
+ rendered = _PRE.sub(stash, rendered)
149
+ rendered = _TAG.sub(_style_tag, rendered)
150
+ return re.sub(r"\x00(\d+)\x00", lambda m: blocks[int(m.group(1))], rendered)
151
+
152
+
153
+ def document(body: str, trailer: str, link: str) -> str:
154
+ """The whole HTML part: rendered reply, grey trailer, and the footer actions."""
155
+ rendered = markdown_html(body)
156
+ if len(rendered) > HTML_BUDGET:
157
+ rendered = markdown_html(body, highlight=False)
158
+ parts = [rendered]
159
+ if trailer.strip():
160
+ parts.append(
161
+ f'<pre style="font-family:{MONO};font-size:12px;line-height:1.4;color:#57606a;'
162
+ "white-space:pre-wrap;word-wrap:break-word;margin:16px 0 0;padding-top:10px;"
163
+ f'border-top:1px solid #d0d7de">{html.escape(trailer.strip())}</pre>'
164
+ )
165
+ parts.append(
166
+ f'<p style="margin:16px 0 0;font-size:13px;color:#57606a">Reply to this email to '
167
+ f'continue. &nbsp;<a href="{html.escape(link)}" style="display:inline-block;'
168
+ "padding:5px 12px;border:1px solid #d0d7de;border-radius:6px;background:#f6f8fa;"
169
+ 'color:#24292f;text-decoration:none;font-weight:600">Start new task</a></p>'
170
+ )
171
+ return (
172
+ '<html><body style="margin:0;padding:0">'
173
+ f'<div style="max-width:720px;font-family:{SANS};font-size:14px;line-height:1.5;'
174
+ f'color:#1f2328">{"".join(parts)}</div></body></html>'
175
+ )
@@ -0,0 +1,79 @@
1
+ """What one listener has handled, routed and sent: one JSON file, rewritten atomically.
2
+
3
+ The file belongs to one invocation. A new `--email-listen` starts it afresh:
4
+ the old alias is dead, so nothing addressed to it could be accepted, and an
5
+ old queue is never drained or resumed. It holds the token's hash, never the
6
+ token, and lives in a `0700` directory the remote hosts' sandbox cannot read.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import tempfile
14
+ from dataclasses import asdict, dataclass, field
15
+ from pathlib import Path
16
+
17
+ # Route targets that are not a session: a message whose replies start one.
18
+ LAUNCHER, CONTROL = "launcher", "control"
19
+
20
+
21
+ def state_dir() -> Path:
22
+ state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
23
+ return state / "pcode" / "email-remote"
24
+
25
+
26
+ @dataclass
27
+ class OutboxEntry:
28
+ """A reply as composed, so a retry resends it, same Message-ID, without a new turn.
29
+
30
+ Its parts rather than the message: the message carries the alias, which is
31
+ never written to disk, so a retry composes it again around the same text.
32
+ """
33
+
34
+ planned_rfc_id: str
35
+ session: str
36
+ in_reply_to: str
37
+ subject: str
38
+ body: str
39
+ references: list[str] = field(default_factory=list)
40
+ auto: str = "auto-replied"
41
+ # Session detail shown under the body (not Markdown).
42
+ trailer: str = ""
43
+ attempts: int = 0
44
+ # "pending", "sending", "sent", or "failed".
45
+ state: str = "pending"
46
+ # When a failed send may be tried again (the listener's clock).
47
+ retry_at: float = 0.0
48
+
49
+
50
+ @dataclass
51
+ class State:
52
+ path: Path
53
+ listener: dict = field(default_factory=dict)
54
+ # Gmail message id -> disposition ("accepted" or a rejection code).
55
+ handled: dict[str, str] = field(default_factory=dict)
56
+ # RFC Message-ID -> session key, LAUNCHER, or CONTROL.
57
+ routes: dict[str, str] = field(default_factory=dict)
58
+ # Session key -> what reaches it again (host id, session id, worktree, base).
59
+ sessions: dict[str, dict] = field(default_factory=dict)
60
+ outbox: list[OutboxEntry] = field(default_factory=list)
61
+
62
+ def save(self) -> None:
63
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
64
+ os.chmod(self.path.parent, 0o700)
65
+ data = {key: value for key, value in asdict(self).items() if key != "path"}
66
+ fd, temporary = tempfile.mkstemp(dir=self.path.parent, prefix=".state-")
67
+ try:
68
+ with os.fdopen(fd, "w") as file:
69
+ json.dump(data, file, indent=1, sort_keys=True)
70
+ os.replace(temporary, self.path)
71
+ except BaseException:
72
+ Path(temporary).unlink(missing_ok=True)
73
+ raise
74
+
75
+ @classmethod
76
+ def load(cls, path: Path) -> State:
77
+ data = json.loads(path.read_text())
78
+ outbox = [OutboxEntry(**entry) for entry in data.pop("outbox", [])]
79
+ return cls(path=path, outbox=outbox, **data)
pcode/error_report.py ADDED
@@ -0,0 +1,38 @@
1
+ """Describing a failure must not fail.
2
+
3
+ `pcode.live.error_message` sanitizes an exception for the transcript, but it
4
+ lives in the package's heaviest module, so callers import it lazily -- inside
5
+ the `except` block that needs it. That import can itself raise. A session
6
+ whose source tree is deleted underneath it (a worktree merged and removed from
7
+ elsewhere) answers `No module named 'pcode.live'`, and because the failure
8
+ happens in the handler it escapes, kills the task that was reporting, and
9
+ loses the original error. Import from here instead of from `pcode.live`: this
10
+ module has no imports of its own, so it is resident before anything goes
11
+ wrong.
12
+ """
13
+
14
+
15
+ def error_message(error: Exception, *, unexpected: str | None = None) -> str:
16
+ """`pcode.live.error_message`, degrading to a description we can always build."""
17
+ try:
18
+ from pcode.live import error_message as describe
19
+
20
+ return describe(error, unexpected=unexpected)
21
+ except Exception as failure: # noqa: BLE001 - the caller is already handling an error.
22
+ return _undescribed(error, failure, unexpected)
23
+
24
+
25
+ def _undescribed(error: Exception, failure: Exception, unexpected: str | None) -> str:
26
+ """Name both failures. Never `str(error)`: sanitizing it is what just broke."""
27
+ opening = unexpected or "Run failed"
28
+ message = f"{opening} ({type(error).__name__}), and describing it failed "
29
+ message += f"({type(failure).__name__})."
30
+ if isinstance(failure, ImportError) and (failure.name or "").split(".")[0] == "pcode":
31
+ message += (
32
+ " pcode cannot load its own source any more: the directory it runs from was"
33
+ " deleted, usually a session worktree removed from elsewhere. Restart pcode"
34
+ " from a directory that still exists."
35
+ )
36
+ else:
37
+ message += " See the saved session diagnostics."
38
+ return message