pcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/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
+ )