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
pcode/shell.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Sanitized UI projections of the shell tool's output events.
|
|
2
|
+
|
|
3
|
+
Execution, polling and process ownership live in `pcode.jobs`; this module only
|
|
4
|
+
accumulates the bounded output events for the transient preview, and decides
|
|
5
|
+
how much of a job result is safe to show once upstream clipping has already
|
|
6
|
+
removed the context redaction depends on.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from pydantic_ai_harness.shell import (
|
|
12
|
+
CommandFinishedEvent,
|
|
13
|
+
CommandOutputEvent,
|
|
14
|
+
CommandStartedEvent,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from pcode.runtime import CommandOutput
|
|
18
|
+
from pcode.tool_display import command_text
|
|
19
|
+
|
|
20
|
+
REDUCED_SHELL_OUTPUT = "[Shell output reduced; full output remains in the command log.]"
|
|
21
|
+
|
|
22
|
+
# A quoted secret may span chunks and lines. Redact through EOF until its
|
|
23
|
+
# closing quote arrives, not just after a complete quoted value is available.
|
|
24
|
+
_OPEN_SECRET = re.compile(
|
|
25
|
+
r"(?i)((?:password|passwd|secret|token|api[_-]?key|authorization)[\"']?\s*[:=]\s*)"
|
|
26
|
+
r"([\"'])(.*?)(?:(?<!\\)\2|\Z)",
|
|
27
|
+
re.DOTALL,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def preview_text(text, *, final=False):
|
|
32
|
+
"""Sanitize before clipping; unfinished lines can contain split credentials."""
|
|
33
|
+
if not final:
|
|
34
|
+
text = text[: text.rfind("\n") + 1]
|
|
35
|
+
text = _OPEN_SECRET.sub(r"\1\2[redacted]\2", text)
|
|
36
|
+
text = re.sub(r"(?i)(\btoken\s*[:=]\s*)[^\s\"',;}]+", r"\1[redacted]", text)
|
|
37
|
+
return command_text(text).rstrip()[-131072:]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Every job result ends with a `[jN · outcome · elapsed]` marker and, while the
|
|
41
|
+
# command runs, the lines telling the model how to get back to it. That block
|
|
42
|
+
# is control information, never command output, so it survives both reduction
|
|
43
|
+
# and redaction while the body above it may not.
|
|
44
|
+
_JOB_MARKER = re.compile(r"^\[j\d+ · (?:running|stopped|exit -?\d+) · [^]]*\]", re.MULTILINE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def split_envelope(content):
|
|
48
|
+
"""Split a job result into its command output and its control envelope.
|
|
49
|
+
|
|
50
|
+
Returns `None` when there is no marker, which means the text is not a job
|
|
51
|
+
result and must be left exactly as it is.
|
|
52
|
+
"""
|
|
53
|
+
marker = None
|
|
54
|
+
for marker in _JOB_MARKER.finditer(content):
|
|
55
|
+
pass # The last marker belongs to this call.
|
|
56
|
+
if marker is None:
|
|
57
|
+
return None
|
|
58
|
+
return content[: marker.start()], content[marker.start() :]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def result_projection(content, finished=None):
|
|
62
|
+
"""Omit already-truncated tails that have lost their redaction context.
|
|
63
|
+
|
|
64
|
+
A result carries the last 16 KB of the log, which can start inside a quoted
|
|
65
|
+
credential or private-key block. Do not read the raw log to reconstruct it.
|
|
66
|
+
Keep only the control envelope in the UI and inspection journal. Model tool
|
|
67
|
+
results and the log files themselves are unaffected. The tool-output limiter
|
|
68
|
+
marks reduced bodies explicitly, since their length no longer proves whether
|
|
69
|
+
clipping removed the opening credential marker.
|
|
70
|
+
"""
|
|
71
|
+
if not isinstance(content, str):
|
|
72
|
+
return content
|
|
73
|
+
split = split_envelope(content)
|
|
74
|
+
if split is None:
|
|
75
|
+
return content
|
|
76
|
+
output, envelope = split
|
|
77
|
+
if (
|
|
78
|
+
content.startswith(REDUCED_SHELL_OUTPUT)
|
|
79
|
+
or (finished is not None and finished.truncated)
|
|
80
|
+
or len(output.encode("utf-8")) >= 16000
|
|
81
|
+
):
|
|
82
|
+
return (
|
|
83
|
+
"[Output tail omitted: clipping removed redaction context. "
|
|
84
|
+
"Raw output remains in the command log.]\n" + envelope
|
|
85
|
+
)
|
|
86
|
+
return content
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ShellPreview:
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self.commands = {}
|
|
92
|
+
self.output = {}
|
|
93
|
+
|
|
94
|
+
def update(self, event):
|
|
95
|
+
call_id = event.tool_call_id or ""
|
|
96
|
+
if isinstance(event, CommandStartedEvent):
|
|
97
|
+
self.commands[call_id] = command_text(event.command)
|
|
98
|
+
self.output[call_id] = ""
|
|
99
|
+
return None
|
|
100
|
+
if call_id not in self.commands:
|
|
101
|
+
return None
|
|
102
|
+
if isinstance(event, CommandOutputEvent):
|
|
103
|
+
self.output[call_id] += event.text
|
|
104
|
+
text = preview_text(self.output[call_id])
|
|
105
|
+
elif isinstance(event, CommandFinishedEvent):
|
|
106
|
+
text = preview_text(self.output.pop(call_id), final=True)
|
|
107
|
+
if event.truncated:
|
|
108
|
+
text += "\n[Live preview capped; further output is in the command log.]"
|
|
109
|
+
command = self.commands.pop(call_id)
|
|
110
|
+
return CommandOutput(call_id, command, text)
|
|
111
|
+
else:
|
|
112
|
+
return None
|
|
113
|
+
return CommandOutput(call_id, self.commands[call_id], text) if text else None
|
pcode/shell_mode.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Shell mode: `!command` typed at the prompt runs locally and reaches the model as a tool call.
|
|
2
|
+
|
|
3
|
+
The user runs the command; nothing here asks a model for anything. The result
|
|
4
|
+
is queued as a synthetic `shell` tool exchange (a tool call the model "made"
|
|
5
|
+
plus its result) so the next request shows the model what ran and what it
|
|
6
|
+
printed, in the shape it already knows how to read. Before queuing, the
|
|
7
|
+
result passes through the same `ToolOutputLimits` bands that reduce real tool
|
|
8
|
+
results, so a long test log spills to a `read_tool_result` handle instead of
|
|
9
|
+
landing whole in the context window.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
from uuid import uuid4
|
|
22
|
+
|
|
23
|
+
# Imported lazily below: `app.py` imports this module, and the terminal must
|
|
24
|
+
# open without loading the agent stack (see tests/test_startup.py).
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from pydantic_ai import ToolReturn
|
|
27
|
+
from pydantic_ai.messages import ModelMessage
|
|
28
|
+
|
|
29
|
+
SHELL_PREFIX = "!"
|
|
30
|
+
TOOL_NAME = "shell"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def shell_command(text: str) -> str | None:
|
|
34
|
+
"""The command behind a `!` prompt, or None when the text is an ordinary message.
|
|
35
|
+
|
|
36
|
+
`!` alone and `!!` are not commands: the first has nothing to run and the
|
|
37
|
+
second is a history-expansion habit better refused than run.
|
|
38
|
+
"""
|
|
39
|
+
text = text.strip()
|
|
40
|
+
if not text.startswith(SHELL_PREFIX):
|
|
41
|
+
return None
|
|
42
|
+
command = text[len(SHELL_PREFIX) :].strip()
|
|
43
|
+
if not command or command.startswith(SHELL_PREFIX):
|
|
44
|
+
return None
|
|
45
|
+
return command
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ShellRun:
|
|
50
|
+
command: str
|
|
51
|
+
output: str
|
|
52
|
+
exit_code: int | None
|
|
53
|
+
elapsed_seconds: float
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def failed(self) -> bool:
|
|
57
|
+
return self.exit_code != 0
|
|
58
|
+
|
|
59
|
+
def tool_result(self) -> str:
|
|
60
|
+
"""Match Harness's `run_command` rendering, which the model already reads."""
|
|
61
|
+
output = self.output
|
|
62
|
+
if self.exit_code is None:
|
|
63
|
+
return f"{output}\n[Command interrupted before it finished]"
|
|
64
|
+
if self.exit_code != 0:
|
|
65
|
+
return f"{output}\n[exit code: {self.exit_code}]"
|
|
66
|
+
return output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def execute(
|
|
70
|
+
command: str,
|
|
71
|
+
*,
|
|
72
|
+
cwd: Path,
|
|
73
|
+
env: dict[str, str] | None = None,
|
|
74
|
+
on_output: Callable[[str], None] | None = None,
|
|
75
|
+
) -> ShellRun:
|
|
76
|
+
"""Run `command` to completion, streaming combined stdout/stderr to `on_output`.
|
|
77
|
+
|
|
78
|
+
No timeout: the user typed this and can cancel it. Cancellation kills the
|
|
79
|
+
whole process group, so a `make test` cannot leave its children running.
|
|
80
|
+
"""
|
|
81
|
+
loop = asyncio.get_running_loop()
|
|
82
|
+
started = loop.time()
|
|
83
|
+
process = await asyncio.create_subprocess_shell(
|
|
84
|
+
command,
|
|
85
|
+
cwd=cwd,
|
|
86
|
+
env=env,
|
|
87
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
88
|
+
stdout=asyncio.subprocess.PIPE,
|
|
89
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
90
|
+
start_new_session=True,
|
|
91
|
+
)
|
|
92
|
+
chunks: list[str] = []
|
|
93
|
+
assert process.stdout is not None
|
|
94
|
+
try:
|
|
95
|
+
while data := await process.stdout.read(4096):
|
|
96
|
+
text = data.decode("utf-8", errors="replace")
|
|
97
|
+
chunks.append(text)
|
|
98
|
+
if on_output is not None:
|
|
99
|
+
on_output(text)
|
|
100
|
+
exit_code: int | None = await process.wait()
|
|
101
|
+
except BaseException:
|
|
102
|
+
if process.returncode is None:
|
|
103
|
+
try:
|
|
104
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
105
|
+
except ProcessLookupError:
|
|
106
|
+
pass
|
|
107
|
+
await process.wait()
|
|
108
|
+
raise
|
|
109
|
+
return ShellRun(command, "".join(chunks), exit_code, loop.time() - started)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def shell_exchange(
|
|
113
|
+
command: str, content: str | ToolReturn, *, call_id: str | None = None, first: bool = False
|
|
114
|
+
) -> list[ModelMessage]:
|
|
115
|
+
"""Messages that show the model a `shell` call it did not make, with its result.
|
|
116
|
+
|
|
117
|
+
`first` prepends the typed line as a user turn: providers reject a history
|
|
118
|
+
that opens with an assistant message, and an empty history has no user
|
|
119
|
+
turn for the response to follow. Otherwise the pair simply extends the
|
|
120
|
+
conversation; Pydantic AI merges the trailing request with the user's
|
|
121
|
+
next prompt, tool results first, exactly as after a real tool call.
|
|
122
|
+
"""
|
|
123
|
+
from pydantic_ai import ToolReturn
|
|
124
|
+
from pydantic_ai.messages import (
|
|
125
|
+
ModelRequest,
|
|
126
|
+
ModelResponse,
|
|
127
|
+
ToolCallPart,
|
|
128
|
+
ToolReturnPart,
|
|
129
|
+
UserPromptPart,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
call_id = call_id or f"shell_mode_{uuid4().hex[:12]}"
|
|
133
|
+
if isinstance(content, ToolReturn):
|
|
134
|
+
result_part = ToolReturnPart(
|
|
135
|
+
tool_name=TOOL_NAME,
|
|
136
|
+
content=content.return_value,
|
|
137
|
+
tool_call_id=call_id,
|
|
138
|
+
metadata=content.metadata,
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
result_part = ToolReturnPart(tool_name=TOOL_NAME, content=content, tool_call_id=call_id)
|
|
142
|
+
messages: list[ModelMessage] = [
|
|
143
|
+
ModelResponse(parts=[ToolCallPart(TOOL_NAME, {"command": command}, tool_call_id=call_id)]),
|
|
144
|
+
ModelRequest(parts=[result_part]),
|
|
145
|
+
]
|
|
146
|
+
if first:
|
|
147
|
+
messages.insert(0, ModelRequest(parts=[UserPromptPart(f"{SHELL_PREFIX}{command}")]))
|
|
148
|
+
return messages
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def reduce_result(limits, *, call_id: str, command: str, result: str):
|
|
152
|
+
"""Apply the agent's tool-output bands to a result produced outside a run.
|
|
153
|
+
|
|
154
|
+
Spill and truncate only read the run id and retry count from their
|
|
155
|
+
context, so a bare `RunContext` is enough; the run id namespaces the spill
|
|
156
|
+
handle on disk. The model slot stays empty on purpose: an `anthropic:`
|
|
157
|
+
string with no credential cannot be inferred, and nothing here calls it.
|
|
158
|
+
"""
|
|
159
|
+
from pydantic_ai import RunContext
|
|
160
|
+
from pydantic_ai.messages import ToolCallPart
|
|
161
|
+
from pydantic_ai.tools import ToolDefinition
|
|
162
|
+
from pydantic_ai.usage import RunUsage
|
|
163
|
+
|
|
164
|
+
ctx = RunContext(deps=None, model=None, usage=RunUsage(), run_id=f"shell-mode-{call_id}")
|
|
165
|
+
call = ToolCallPart(TOOL_NAME, {"command": command}, tool_call_id=call_id)
|
|
166
|
+
return await limits.after_tool_execute(
|
|
167
|
+
ctx,
|
|
168
|
+
call=call,
|
|
169
|
+
tool_def=ToolDefinition(name=TOOL_NAME),
|
|
170
|
+
args={"command": command},
|
|
171
|
+
result=result,
|
|
172
|
+
)
|