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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|