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,251 @@
1
+ """Live CLI processes for one event loop, matched to requests by history."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ import weakref
7
+ from contextlib import suppress
8
+ from dataclasses import dataclass
9
+
10
+ from pcode.claude_sdk.errors import ClaudeProcessError, ClaudeStartError
11
+ from pcode.claude_sdk.messages import _blocks, _result_ids, _tool_use_ids, replay
12
+ from pcode.claude_sdk.resume import ForkPoint, ResumeIndex
13
+ from pcode.claude_sdk.session import ClaudeSession, SessionConfig, _Diverged
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Each process holds about 110-135 MB of its own beyond the ~200 MB binary the
18
+ # processes share (measured with vmmap), and forking a transcript is warm anyway,
19
+ # so keeping one only saves the ~0.8 s start. Finished ones are capped and expire
20
+ # soon; one is enough for the next turn to continue on. A parked one belongs to a
21
+ # run still executing its tools (often a parent waiting on delegations), so it is
22
+ # never capped, only expired, and later. Under memory pressure none are kept.
23
+ # The first two are defaults for `claude_idle_processes` / `claude_idle_minutes`.
24
+ MAX_IDLE_SESSIONS = 1
25
+ IDLE_SECONDS = 10 * 60
26
+ PARKED_SECONDS = 30 * 60
27
+ # How often idle processes are checked against memory pressure, and what counts:
28
+ # less than this share of physical memory available.
29
+ PRESSURE_CHECK_SECONDS = 60.0
30
+ LOW_MEMORY_FRACTION = 0.10
31
+
32
+
33
+ @dataclass
34
+ class Checkout:
35
+ session: ClaudeSession
36
+ route: str # "live", "fork" or "fresh", for logs and tests
37
+
38
+
39
+ class SessionPool:
40
+ """Live processes for one event loop, matched to requests by history."""
41
+
42
+ def __init__(self, index: ResumeIndex, factory=None) -> None:
43
+ self.index = index
44
+ self.factory = factory
45
+ self.sessions: list[ClaudeSession] = []
46
+ self._lock = asyncio.Lock()
47
+ self._closing: set[asyncio.Task] = set()
48
+ self._expiry: asyncio.TimerHandle | None = None
49
+
50
+ async def checkout(
51
+ self, config: SessionConfig, messages: list[dict], chain: list[str]
52
+ ) -> Checkout:
53
+ """A session that has been sent `messages` and is producing the next response."""
54
+ for attempt in (1, 2):
55
+ try:
56
+ return await self._checkout(config, messages, chain)
57
+ except _Diverged as error:
58
+ # A live process that had gone its own way is retired: fork now.
59
+ if error.live and attempt == 1:
60
+ continue
61
+ # Internal only: the runtime retries this name as transient.
62
+ raise ClaudeProcessError(config.model, error.message) from error
63
+ raise AssertionError("unreachable")
64
+
65
+ async def _checkout(self, config, messages, chain) -> Checkout:
66
+ async with self._lock:
67
+ for session in sorted(self.sessions, key=lambda s: s.last_used, reverse=True):
68
+ if session.accepts(config, messages, chain):
69
+ session.busy = True
70
+ route, point, start = "live", None, len(session.chain)
71
+ break
72
+ else:
73
+ session = None
74
+ point, start = self._fork_point(config, messages, chain)
75
+ route = "fork" if point else "fresh"
76
+ if session is None:
77
+ session = await self._open(config, point)
78
+ if session is None: # the transcript to fork is gone
79
+ route, point, start = "fresh", None, 0
80
+ session = await self._open(config, None)
81
+ delta = messages[start:]
82
+ try:
83
+ if route == "live":
84
+ await session.send(delta[0])
85
+ else:
86
+ open_ids = _tool_use_ids(messages[start - 1]) if start else ()
87
+ single = len(delta) == 1 and delta[0]["role"] == "user"
88
+ if single and _result_ids(delta[0]) == set(open_ids):
89
+ await session.start(_blocks(delta[0]))
90
+ else:
91
+ await session.start(replay(delta, open_ids))
92
+ except _Diverged as error:
93
+ error.live = route == "live"
94
+ self.release(session, ok=False)
95
+ raise
96
+ except BaseException:
97
+ self.release(session, ok=False)
98
+ raise
99
+ session.chain = chain[: len(messages)]
100
+ logger.debug("claude request via %s session (%d new messages)", route, len(delta))
101
+ return Checkout(session, route)
102
+
103
+ def _fork_point(self, config, messages, chain) -> tuple[ForkPoint | None, int]:
104
+ for position in range(len(messages) - 2, -1, -1):
105
+ if messages[position]["role"] != "assistant":
106
+ continue
107
+ point = self.index.get(chain[position])
108
+ if point is not None and point.cwd == config.cwd:
109
+ return point, position + 1
110
+ return None, 0
111
+
112
+ async def _open(self, config: SessionConfig, point: ForkPoint | None) -> ClaudeSession | None:
113
+ session = ClaudeSession(config, point)
114
+ session.busy = True
115
+ session.retire = lambda: self._drop(session)
116
+ self.sessions.append(session)
117
+ try:
118
+ await session.connect(self.factory)
119
+ except ClaudeStartError:
120
+ self._drop(session)
121
+ if point is not None:
122
+ logger.debug("could not resume Claude session %s", point.session_id)
123
+ self.index.forget(point.session_id)
124
+ return None
125
+ raise
126
+ except BaseException:
127
+ self._drop(session)
128
+ raise
129
+ return session
130
+
131
+ def record(self, session: ClaudeSession, assistant_hash: str) -> None:
132
+ """Note the response just produced, so the history can continue or fork from it."""
133
+ session.chain.append(assistant_hash)
134
+ if session.cli_session_id and session.response_uuid:
135
+ point = ForkPoint(session.cli_session_id, session.response_uuid, session.config.cwd)
136
+ self.index.add(assistant_hash, point)
137
+
138
+ def release(self, session: ClaudeSession, *, ok: bool) -> None:
139
+ session.busy = False
140
+ session.last_used = time.monotonic()
141
+ if not ok:
142
+ session.dead = True
143
+ low = memory_low()
144
+ for stale in [s for s in self.sessions if not s.busy and (s.dead or low)]:
145
+ self._drop(stale)
146
+ finished = sorted(
147
+ (s for s in self.sessions if not s.busy and not s.open_tool_ids),
148
+ key=lambda s: s.last_used,
149
+ )
150
+ for stale in finished[: max(0, len(finished) - _idle_limit())]:
151
+ self._drop(stale)
152
+ self._schedule_expiry()
153
+
154
+ @staticmethod
155
+ def _deadline(session: ClaudeSession) -> float:
156
+ return session.last_used + (PARKED_SECONDS if session.open_tool_ids else _idle_seconds())
157
+
158
+ def _schedule_expiry(self) -> None:
159
+ if self._expiry is not None:
160
+ self._expiry.cancel()
161
+ self._expiry = None
162
+ idle = [s for s in self.sessions if not s.busy]
163
+ if idle:
164
+ delay = max(0.0, min(map(self._deadline, idle)) - time.monotonic())
165
+ delay = min(delay, PRESSURE_CHECK_SECONDS)
166
+ self._expiry = asyncio.get_running_loop().call_later(delay, self._expire)
167
+
168
+ def _expire(self) -> None:
169
+ self._expiry = None
170
+ now = time.monotonic()
171
+ low = memory_low()
172
+ for session in [
173
+ s for s in self.sessions if not s.busy and (low or self._deadline(s) <= now)
174
+ ]:
175
+ self._drop(session)
176
+ self._schedule_expiry()
177
+
178
+ def _drop(self, session: ClaudeSession) -> None:
179
+ with suppress(ValueError):
180
+ self.sessions.remove(session)
181
+ task = asyncio.get_running_loop().create_task(session.close())
182
+ self._closing.add(task)
183
+ task.add_done_callback(self._closing.discard)
184
+
185
+ async def aclose(self) -> None:
186
+ if self._expiry is not None:
187
+ self._expiry.cancel()
188
+ self._expiry = None
189
+ for session in list(self.sessions):
190
+ self._drop(session)
191
+ if self._closing:
192
+ await asyncio.gather(*self._closing, return_exceptions=True)
193
+
194
+
195
+ def _preference(key: str) -> int | None:
196
+ """A saved whole-number preference, read on use so `/config set` applies at once."""
197
+ from pcode.preferences import load_preferences
198
+
199
+ value = load_preferences().get(key, "")
200
+ return int(value) if value.isdecimal() else None
201
+
202
+
203
+ def memory_low() -> bool:
204
+ """Whether the machine is short of memory, so no idle process is worth keeping.
205
+
206
+ Dropping one costs the next request a warm fork, even mid-round: the tool
207
+ results then arrive structured at the fork point.
208
+ """
209
+ try:
210
+ import psutil
211
+
212
+ memory = psutil.virtual_memory()
213
+ except Exception:
214
+ return False
215
+ return memory.available < memory.total * LOW_MEMORY_FRACTION
216
+
217
+
218
+ def _idle_limit() -> int:
219
+ value = _preference("claude_idle_processes")
220
+ return MAX_IDLE_SESSIONS if value is None else value
221
+
222
+
223
+ def _idle_seconds() -> float:
224
+ return (_preference("claude_idle_minutes") or 0) * 60 or IDLE_SECONDS
225
+
226
+
227
+ _pools: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, SessionPool]" = (
228
+ weakref.WeakKeyDictionary()
229
+ )
230
+ _index: ResumeIndex | None = None
231
+
232
+
233
+ def pool() -> SessionPool:
234
+ """This event loop's pool; processes cannot be shared across loops."""
235
+ global _index
236
+ loop = asyncio.get_running_loop()
237
+ current = _pools.get(loop)
238
+ if current is None:
239
+ _index = _index or ResumeIndex()
240
+ current = _pools[loop] = SessionPool(_index)
241
+ return current
242
+
243
+
244
+ async def shutdown() -> None:
245
+ """Stop this event loop's CLI processes, interrupting any mid-turn first.
246
+
247
+ Called as pcode exits; the SDK's own atexit hook only sends SIGTERM.
248
+ """
249
+ current = _pools.pop(asyncio.get_running_loop(), None)
250
+ if current is not None:
251
+ await current.aclose()
@@ -0,0 +1,34 @@
1
+ """`ClaudeWorkspace`: the directory a `claude:` request's CLI runs in."""
2
+
3
+ from dataclasses import replace
4
+ from pathlib import Path
5
+
6
+ from pydantic_ai import RunContext
7
+ from pydantic_ai.capabilities import AbstractCapability
8
+ from pydantic_ai.models import ModelRequestContext
9
+
10
+ # Model setting carrying the workspace the CLI runs in (see `ClaudeWorkspace`).
11
+ CWD_SETTING = "pcode_claude_cwd"
12
+
13
+
14
+ class ClaudeWorkspace(AbstractCapability):
15
+ """Run `claude:` requests' CLI in the agent's workspace.
16
+
17
+ The CLI tells the model its working directory, and keeps transcripts per
18
+ directory; pcode's process directory is not the workspace in worktree mode.
19
+ A `fallback` one yields to any other: sub-agents get the parent's as a
20
+ fallback, and an isolated worker's own checkout must win whatever the order.
21
+ """
22
+
23
+ def __init__(self, workspace: Path, *, fallback: bool = False) -> None:
24
+ self.workspace = Path(workspace)
25
+ self.fallback = fallback
26
+
27
+ async def before_model_request(
28
+ self, ctx: RunContext, request_context: ModelRequestContext
29
+ ) -> ModelRequestContext:
30
+ settings = request_context.model_settings or {}
31
+ if request_context.model.system != "claude" or (self.fallback and CWD_SETTING in settings):
32
+ return request_context
33
+ settings = {**settings, CWD_SETTING: str(self.workspace)}
34
+ return replace(request_context, model_settings=settings)
pcode/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ """Lightweight entry point: silence terminal echo before importing the frontend."""
2
+
3
+ import sys
4
+ from contextlib import ExitStack
5
+
6
+ _startup: ExitStack | None = None
7
+
8
+
9
+ def restore_stdin() -> None:
10
+ """Hand terminal settings back before the editor or a launch question takes over."""
11
+ if _startup is not None:
12
+ _startup.close()
13
+
14
+
15
+ def ask(prompt: str) -> str:
16
+ restore_stdin()
17
+ return input(prompt)
18
+
19
+
20
+ def _quiet_stdin(stack: ExitStack) -> None:
21
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
22
+ return
23
+ try:
24
+ import termios
25
+ except ImportError:
26
+ return
27
+ try:
28
+ fd = sys.stdin.fileno()
29
+ original = termios.tcgetattr(fd)
30
+ quiet = termios.tcgetattr(fd)
31
+ # The kernel queues typeahead; no reader thread or input flushing is
32
+ # needed. Canonical mode goes too: canonical without echo is how
33
+ # terminals such as Ghostty recognize a password prompt. Signals stay.
34
+ quiet[3] &= ~(termios.ECHO | termios.ECHONL | termios.ICANON)
35
+ quiet[6][termios.VMIN] = 1
36
+ quiet[6][termios.VTIME] = 0
37
+ termios.tcsetattr(fd, termios.TCSANOW, quiet)
38
+ except (OSError, ValueError, termios.error):
39
+ return
40
+
41
+ def restore():
42
+ try:
43
+ termios.tcsetattr(fd, termios.TCSANOW, original)
44
+ except (OSError, termios.error):
45
+ pass
46
+
47
+ stack.callback(restore)
48
+
49
+
50
+ def main() -> None:
51
+ global _startup
52
+ from pcode.completion import COMPLETE_COMMAND
53
+
54
+ if sys.argv[1:2] == [COMPLETE_COMMAND]:
55
+ # Shell completion calls back on every Tab: skip the frontend import.
56
+ from pcode.completion import print_candidates
57
+
58
+ sys.exit(print_candidates(sys.argv[2:]))
59
+ with ExitStack() as stack:
60
+ _startup = stack
61
+ _quiet_stdin(stack)
62
+ try:
63
+ from pcode.app import main as run
64
+
65
+ run()
66
+ finally:
67
+ _startup = None
68
+
69
+
70
+ if __name__ == "__main__":
71
+ from pcode.cli import main
72
+
73
+ main()
pcode/clipboard.py ADDED
@@ -0,0 +1,76 @@
1
+ """Best-effort system clipboard for popups; never raises at the call site.
2
+
3
+ A local helper (`pbcopy`, `wl-copy`, `xclip`) is tried first because it needs no
4
+ cooperation from the terminal emulator. OSC 52 is the fallback, and goes first
5
+ over ssh, where the helper would target the clipboard of the wrong machine.
6
+ tmux only forwards OSC 52 with `set -g set-clipboard on`, so neither path alone
7
+ is enough.
8
+ """
9
+
10
+ import base64
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+
15
+ _HELPERS = (
16
+ ("pbcopy",),
17
+ ("wl-copy",),
18
+ ("xclip", "-selection", "clipboard"),
19
+ ("xsel", "--clipboard", "--input"),
20
+ ("clip.exe",),
21
+ )
22
+
23
+ # Terminals drop oversized OSC 52 payloads, and a clipboard is not a transfer
24
+ # channel: truncate loudly rather than copy something silently incomplete.
25
+ LIMIT = 64 * 1024
26
+
27
+
28
+ def osc52(text: str) -> str:
29
+ payload = base64.b64encode(text.encode("utf-8", "replace")).decode("ascii")
30
+ return f"\x1b]52;c;{payload}\x07"
31
+
32
+
33
+ def _helper(text: str, output=None) -> bool:
34
+ for helper in _HELPERS:
35
+ if shutil.which(helper[0]) is None:
36
+ continue
37
+ try:
38
+ # The caller owns the alternate screen: a chatty helper must not
39
+ # write over it, so its streams are discarded rather than inherited.
40
+ subprocess.run(
41
+ helper,
42
+ input=text.encode("utf-8", "replace"),
43
+ stdout=subprocess.DEVNULL,
44
+ stderr=subprocess.DEVNULL,
45
+ check=True,
46
+ timeout=5,
47
+ )
48
+ return True
49
+ except (OSError, subprocess.SubprocessError):
50
+ continue
51
+ return False
52
+
53
+
54
+ def _terminal(text: str, output) -> bool:
55
+ if output is None:
56
+ return False
57
+ try:
58
+ output.write_raw(osc52(text))
59
+ output.flush()
60
+ return True
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ def copy(text: str, output=None) -> tuple[bool, bool]:
66
+ """Copy `text`, returning whether it worked and whether it was truncated.
67
+
68
+ `output` is a prompt_toolkit output to write OSC 52 to; without one, only a
69
+ local helper can copy.
70
+ """
71
+ truncated = len(text) > LIMIT
72
+ if truncated:
73
+ text = text[:LIMIT] + "\n[truncated by pcode at 64 KiB]"
74
+ remote = bool(os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"))
75
+ order = (_terminal, _helper) if remote else (_helper, _terminal)
76
+ return any(attempt(text, output) for attempt in order), truncated
pcode/code_mode.py ADDED
@@ -0,0 +1,43 @@
1
+ """Opt-in sandboxed batching for the read-only tools, keeping mutations native.
2
+
3
+ Code mode collapses many dependent tool calls into one `run_code` snippet, which
4
+ pays off for investigation (fan out greps and reads, filter in Python). Tools
5
+ whose terminal display is the point -- edits with their diffs, plan updates, the
6
+ persistent shell, delegation -- stay native, so the transcript keeps showing what
7
+ changed rather than an opaque snippet that changed it.
8
+ """
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from pcode.preferences import SETTINGS, load_preferences
13
+
14
+ if TYPE_CHECKING:
15
+ from pydantic_ai_harness.code_mode import CodeMode
16
+
17
+ # Read-only lookups worth batching. Shell tools are already kept native by
18
+ # CodeMode itself; naming the sandboxed set explicitly also keeps MCP and future
19
+ # tools native until they are reviewed for it.
20
+ SANDBOXED_TOOLS = (
21
+ "read_file",
22
+ "list_files",
23
+ "grep",
24
+ "read_tool_result",
25
+ "web_search",
26
+ "get_page",
27
+ )
28
+
29
+
30
+ def code_mode_enabled() -> bool:
31
+ preferences = load_preferences()
32
+ return preferences.get("code_mode", SETTINGS["code_mode"].default) == "on"
33
+
34
+
35
+ def create_code_mode() -> "CodeMode | None":
36
+ """Return the capability when enabled, or None to leave tool calling alone."""
37
+ if not code_mode_enabled():
38
+ return None
39
+ # Imported here so the display modules can share SANDBOXED_TOOLS without
40
+ # loading the Monty sandbox.
41
+ from pydantic_ai_harness.code_mode import CodeMode
42
+
43
+ return CodeMode(tools=list(SANDBOXED_TOOLS))
pcode/codex_login.py ADDED
@@ -0,0 +1,189 @@
1
+ """pcode's own OpenAI Codex sign-in: `/login openai-codex` and `/logout openai-codex`.
2
+
3
+ The flow itself is Pydantic AI's `OpenAICodexOAuthFlow` (PKCE authorization
4
+ code against the public Codex client, callback pinned by OpenAI's registration
5
+ to `http://localhost:1455/auth/callback`). pcode owns the browser and the
6
+ credential file, an owner-only JSON file in `config_dir()`, and hands that
7
+ file to the provider as an `OpenAICodexCredentialSource`, so refreshed tokens
8
+ are written back and a login survives restarts.
9
+
10
+ The Codex CLI's `$CODEX_HOME/auth.json` remains a read-only fallback when pcode
11
+ has no login of its own (see `credential_source`). pcode never writes that
12
+ file: the CLI requires an `id_token` there that this flow does not keep, so a
13
+ pcode-written `auth.json` would be unreadable by the CLI.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import os
21
+ import tempfile
22
+ import webbrowser
23
+ from pathlib import Path
24
+
25
+ from pydantic_ai.exceptions import UserError
26
+ from pydantic_ai.providers.openai_codex import (
27
+ OpenAICodexCredentials,
28
+ OpenAICodexCredentialSource,
29
+ OpenAICodexOAuthFlow,
30
+ )
31
+
32
+ from pcode.auth import LoginError
33
+ from pcode.preferences import config_dir
34
+
35
+ LOGIN_TIMEOUT_SECONDS = 300.0
36
+ _MAX_FILE_BYTES = 1024 * 1024
37
+
38
+
39
+ def credentials_path() -> Path:
40
+ override = os.environ.get("PCODE_CODEX_CREDENTIALS_FILE", "").strip()
41
+ if override:
42
+ return Path(override).expanduser()
43
+ return config_dir() / "codex-credentials.json"
44
+
45
+
46
+ def have_credentials(path: Path | None = None) -> bool:
47
+ """Existence check only; the picker must never read a stored token."""
48
+ try:
49
+ return (path if path is not None else credentials_path()).is_file()
50
+ except OSError:
51
+ return False
52
+
53
+
54
+ def _token(value: object) -> str | None:
55
+ if not isinstance(value, str) or not value or any(char.isspace() for char in value):
56
+ return None
57
+ return value
58
+
59
+
60
+ def read_credentials(path: Path) -> OpenAICodexCredentials:
61
+ """Load pcode's own stored Codex credential, never the CLI's auth file."""
62
+ try:
63
+ with path.open("r", encoding="utf-8-sig") as source:
64
+ contents = source.read(_MAX_FILE_BYTES + 1)
65
+ if len(contents) > _MAX_FILE_BYTES:
66
+ raise ValueError
67
+ data = json.loads(contents)
68
+ entry = data.get("openai-codex") if isinstance(data, dict) else None
69
+ if not isinstance(entry, dict) or entry.get("type") != "oauth":
70
+ raise ValueError
71
+ access = _token(entry.get("access"))
72
+ refresh = _token(entry.get("refresh"))
73
+ account = _token(entry.get("account_id"))
74
+ if access is None or refresh is None or account is None:
75
+ raise ValueError
76
+ return OpenAICodexCredentials(
77
+ access_token=access, refresh_token=refresh, account_id=account
78
+ )
79
+ except Exception:
80
+ raise LoginError(
81
+ "No usable pcode OpenAI Codex login. Run /login openai-codex to sign in "
82
+ "with your ChatGPT account. Remove pcode's login with /logout openai-codex "
83
+ "to fall back to the CLI's credential."
84
+ ) from None
85
+
86
+
87
+ def write_credentials(path: Path, credentials: OpenAICodexCredentials) -> None:
88
+ """Replace the credential file atomically, owner-readable only."""
89
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
90
+ payload = {
91
+ "openai-codex": {
92
+ "type": "oauth",
93
+ "access": credentials.access_token,
94
+ "refresh": credentials.refresh_token,
95
+ "account_id": credentials.account_id,
96
+ }
97
+ }
98
+ name = None
99
+ try:
100
+ with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
101
+ name = file.name
102
+ # Restrict before any token bytes reach the filesystem.
103
+ os.chmod(file.fileno(), 0o600)
104
+ json.dump(payload, file, indent=2)
105
+ file.write("\n")
106
+ os.replace(name, path)
107
+ name = None
108
+ finally:
109
+ if name is not None:
110
+ Path(name).unlink(missing_ok=True)
111
+
112
+
113
+ def delete_credentials(path: Path) -> bool:
114
+ try:
115
+ path.unlink()
116
+ return True
117
+ except FileNotFoundError:
118
+ return False
119
+ except OSError:
120
+ raise LoginError(f"Could not remove the stored OpenAI Codex login: {path}") from None
121
+
122
+
123
+ class CredentialStore(OpenAICodexCredentialSource):
124
+ """pcode's credential file as the provider's storage, so refreshes persist."""
125
+
126
+ def __init__(self, path: Path | None = None) -> None:
127
+ self.path = path if path is not None else credentials_path()
128
+
129
+ async def load(self) -> OpenAICodexCredentials:
130
+ return await asyncio.to_thread(read_credentials, self.path)
131
+
132
+ async def save(self, credentials: OpenAICodexCredentials) -> None:
133
+ await asyncio.to_thread(write_credentials, self.path, credentials)
134
+
135
+
136
+ def credential_source(path: Path | None = None) -> CredentialStore | None:
137
+ """pcode's own store when a login exists, else `None` for the CLI's `auth.json`."""
138
+ path = path if path is not None else credentials_path()
139
+ return CredentialStore(path) if have_credentials(path) else None
140
+
141
+
142
+ def _open_browser(url: str) -> None:
143
+ try:
144
+ webbrowser.open(url)
145
+ except Exception:
146
+ # A headless machine is expected; the URL was already displayed.
147
+ pass
148
+
149
+
150
+ async def login(
151
+ *,
152
+ path: Path | None = None,
153
+ notify=None,
154
+ open_browser: bool = True,
155
+ timeout: float = LOGIN_TIMEOUT_SECONDS,
156
+ flow: OpenAICodexOAuthFlow | None = None,
157
+ ) -> OpenAICodexCredentials:
158
+ """Run the browser sign-in and store the resulting credential."""
159
+ path = path if path is not None else credentials_path()
160
+ flow = flow if flow is not None else OpenAICodexOAuthFlow()
161
+ url = flow.authorization_url()
162
+ if notify is not None:
163
+ notify(url)
164
+ if open_browser:
165
+ # Opening a browser can block on some desktops; never stall the terminal.
166
+ await asyncio.to_thread(_open_browser, url)
167
+ try:
168
+ async with asyncio.timeout(timeout):
169
+ credentials = await flow.exchange_code_from_callback()
170
+ except TimeoutError:
171
+ raise LoginError(
172
+ "OpenAI Codex sign-in timed out. Run /login openai-codex to try again."
173
+ ) from None
174
+ except OSError:
175
+ # The callback port is pinned by OpenAI's client registration.
176
+ raise LoginError(
177
+ "Port 1455 is already in use, so the sign-in callback cannot start. "
178
+ "Close the other login (for example a running `codex login`) and try again."
179
+ ) from None
180
+ except UserError:
181
+ raise LoginError(
182
+ "OpenAI Codex authorization failed. Try /login openai-codex again."
183
+ ) from None
184
+ except Exception:
185
+ raise LoginError(
186
+ "OpenAI Codex sign-in failed. No credential details were logged."
187
+ ) from None
188
+ await asyncio.to_thread(write_credentials, path, credentials)
189
+ return credentials