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,695 @@
|
|
|
1
|
+
"""The listener: which mail it accepts, where each message goes, and the sessions it runs.
|
|
2
|
+
|
|
3
|
+
Acceptance (`reject_reason`) is decided from the mailbox's labels and the
|
|
4
|
+
parsed headers before the body is read by anything but the parser. Routing
|
|
5
|
+
follows RFC ancestry (`References`, `In-Reply-To`) through the ids pcode
|
|
6
|
+
recorded, never Gmail's thread or the subject. A task goes to a session (a
|
|
7
|
+
host started through `pcode.gateway` under the fixed remote profile); `/status`
|
|
8
|
+
and `/stop` never reach a model. Every reply is recorded in the outbox before
|
|
9
|
+
it is sent, so a failed send is retried with the same content and never by
|
|
10
|
+
running the agent again.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import random
|
|
19
|
+
import re
|
|
20
|
+
import secrets
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from pcode import gateway
|
|
28
|
+
from pcode.email_remote import outbound
|
|
29
|
+
from pcode.email_remote.mailbox import Mailbox, Meta
|
|
30
|
+
from pcode.email_remote.parsing import (
|
|
31
|
+
EMPTY,
|
|
32
|
+
MALFORMED,
|
|
33
|
+
MAX_MESSAGE_BYTES,
|
|
34
|
+
TOO_LONG,
|
|
35
|
+
Incoming,
|
|
36
|
+
parse,
|
|
37
|
+
read_body,
|
|
38
|
+
)
|
|
39
|
+
from pcode.email_remote.state import CONTROL, LAUNCHER, OutboxEntry, State
|
|
40
|
+
from pcode.error_report import error_message
|
|
41
|
+
from pcode.host_protocol import HostEntry
|
|
42
|
+
from pcode.remote import stop_entry
|
|
43
|
+
from pcode.remote_profile import RemoteProfile
|
|
44
|
+
|
|
45
|
+
POLL_SECONDS = 5.0
|
|
46
|
+
MAX_BACKOFF = 120.0
|
|
47
|
+
# A turn that ends this soon after its session started gets one email, not two.
|
|
48
|
+
COALESCE_SECONDS = 20.0
|
|
49
|
+
SEND_ATTEMPTS = 6
|
|
50
|
+
# Seconds before the first resend of a failed reply; doubled each time after.
|
|
51
|
+
SEND_BACKOFF = 15.0
|
|
52
|
+
READ_ATTEMPTS = 3
|
|
53
|
+
CONTROLS = ("/status", "/stop")
|
|
54
|
+
REDACTED = "[pcode address]"
|
|
55
|
+
|
|
56
|
+
OUTCOMES = {
|
|
57
|
+
gateway.COMPLETED: "Completed",
|
|
58
|
+
gateway.FAILED: "Failed",
|
|
59
|
+
gateway.CANCELLED: "Cancelled",
|
|
60
|
+
gateway.LIMIT: "Stopped at a limit",
|
|
61
|
+
}
|
|
62
|
+
PROBLEMS = {
|
|
63
|
+
EMPTY: "it had no new text above the quoted thread",
|
|
64
|
+
MALFORMED: "pcode could not read its text",
|
|
65
|
+
TOO_LONG: "its new text is over 64 KiB",
|
|
66
|
+
"too-large": "the whole message is over 2 MiB",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Limits:
|
|
72
|
+
concurrent_sessions: int = 2
|
|
73
|
+
max_sessions: int = 20
|
|
74
|
+
session_inputs: int = 20
|
|
75
|
+
max_inputs: int = 100
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_preferences(cls, preferences: dict) -> Limits:
|
|
79
|
+
return cls(
|
|
80
|
+
concurrent_sessions=int(preferences.get("email_concurrent_sessions") or 2),
|
|
81
|
+
max_sessions=int(preferences.get("email_max_sessions") or 20),
|
|
82
|
+
session_inputs=int(preferences.get("email_session_inputs") or 20),
|
|
83
|
+
max_inputs=int(preferences.get("email_max_inputs") or 100),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Session:
|
|
89
|
+
"""One email-started session: its host while one runs, and what reaches it again."""
|
|
90
|
+
|
|
91
|
+
key: str
|
|
92
|
+
subject: str
|
|
93
|
+
entry: HostEntry | None = None
|
|
94
|
+
session_id: str = ""
|
|
95
|
+
# The worktree's commit when it started: change summaries are against it.
|
|
96
|
+
base: str | None = None
|
|
97
|
+
# Inputs accepted and not finished; they run one after another.
|
|
98
|
+
pending: int = 0
|
|
99
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
100
|
+
tasks: set[asyncio.Task] = field(default_factory=set)
|
|
101
|
+
# The input whose turn is running (it holds `lock`).
|
|
102
|
+
current: asyncio.Task | None = None
|
|
103
|
+
# Counts /stop; an input accepted before the latest one does not run.
|
|
104
|
+
stops: int = 0
|
|
105
|
+
# Set once the running input's message is in the host's queue, where a
|
|
106
|
+
# cancel reaches it; None while no message is on its way.
|
|
107
|
+
queued: asyncio.Event | None = None
|
|
108
|
+
|
|
109
|
+
def finished(self, task: asyncio.Task) -> None:
|
|
110
|
+
self.tasks.discard(task)
|
|
111
|
+
self.pending -= 1
|
|
112
|
+
if self.current is task:
|
|
113
|
+
self.current = None
|
|
114
|
+
|
|
115
|
+
def record(self) -> dict:
|
|
116
|
+
entry = self.entry
|
|
117
|
+
return {
|
|
118
|
+
"subject": self.subject,
|
|
119
|
+
"host": entry.id if entry else "",
|
|
120
|
+
"session_id": self.session_id,
|
|
121
|
+
"worktree": entry.workspace if entry else "",
|
|
122
|
+
"base": self.base,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def new_token() -> str:
|
|
127
|
+
"""16 random bytes as lowercase Base32: the alias's secret part."""
|
|
128
|
+
return base64.b32encode(secrets.token_bytes(16)).decode().rstrip("=").lower()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def alias_for(owner: str, token: str) -> str:
|
|
132
|
+
local, _, domain = owner.partition("@")
|
|
133
|
+
return f"{local}+pcode-{token}@{domain}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def head_commit(workspace: Path) -> str | None:
|
|
137
|
+
return gateway.inert_git(workspace, "rev-parse", "HEAD") or None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Listener:
|
|
141
|
+
"""One `--email-listen` invocation: a fresh alias, its sessions, until it expires."""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
owner: str,
|
|
147
|
+
mailbox: Mailbox,
|
|
148
|
+
workspace: Path,
|
|
149
|
+
profile: RemoteProfile,
|
|
150
|
+
ttl: float,
|
|
151
|
+
state_path: Path,
|
|
152
|
+
limits: Limits = Limits(),
|
|
153
|
+
model: str | None = None,
|
|
154
|
+
log: Callable[[str], None] | None = None,
|
|
155
|
+
sessions=gateway,
|
|
156
|
+
clock: Callable[[], float] = time.time,
|
|
157
|
+
) -> None:
|
|
158
|
+
self.owner = owner.lower()
|
|
159
|
+
self.mailbox = mailbox
|
|
160
|
+
self.workspace = workspace
|
|
161
|
+
self.profile = profile
|
|
162
|
+
self.limits = limits
|
|
163
|
+
self.model = model
|
|
164
|
+
self.log = log or (lambda text: print(text, file=sys.stderr, flush=True))
|
|
165
|
+
self.api = sessions
|
|
166
|
+
self.clock = clock
|
|
167
|
+
# The raw token exists here and in delivered mail only; the state has its hash.
|
|
168
|
+
self._token = new_token()
|
|
169
|
+
self.alias = alias_for(self.owner, self._token)
|
|
170
|
+
self.started = clock()
|
|
171
|
+
self.expires = self.started + ttl
|
|
172
|
+
self.stopping = False
|
|
173
|
+
self.state = State(
|
|
174
|
+
path=state_path,
|
|
175
|
+
listener={
|
|
176
|
+
"token_hash": hashlib.sha256(self._token.encode()).hexdigest(),
|
|
177
|
+
"owner": self.owner,
|
|
178
|
+
"workspace": str(workspace),
|
|
179
|
+
"profile": profile.to_json(),
|
|
180
|
+
"started": self.started,
|
|
181
|
+
"expires": self.expires,
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
self.sessions: dict[str, Session] = {}
|
|
185
|
+
# Message-IDs pcode sent, and IMAP handles already looked at.
|
|
186
|
+
self.sent_ids: set[str] = set()
|
|
187
|
+
self.seen: set[str] = set()
|
|
188
|
+
# Handles whose metadata could not be read, and how often.
|
|
189
|
+
self.failures: dict[str, int] = {}
|
|
190
|
+
self.handled_ids: set[str] = set()
|
|
191
|
+
# SMTP runs in a thread: cancelling its caller cannot stop the send.
|
|
192
|
+
# Keep the operation alive until its outcome has been saved.
|
|
193
|
+
self.deliveries: dict[str, asyncio.Task[None]] = {}
|
|
194
|
+
|
|
195
|
+
# Lifetime
|
|
196
|
+
|
|
197
|
+
def active(self) -> bool:
|
|
198
|
+
return not self.stopping and self.clock() < self.expires
|
|
199
|
+
|
|
200
|
+
async def start(self) -> None:
|
|
201
|
+
self.state.save()
|
|
202
|
+
lines = [
|
|
203
|
+
f"pcode is listening for email in {self.workspace} until "
|
|
204
|
+
f"{time.strftime('%a %H:%M %Z', time.localtime(self.expires))}.",
|
|
205
|
+
"",
|
|
206
|
+
"Reply to this email with a task to start a session; reply to a session's "
|
|
207
|
+
"emails to continue it. To start another session alongside, write a new "
|
|
208
|
+
"email to the address this one replies to.",
|
|
209
|
+
"",
|
|
210
|
+
"Send just `/status` or `/stop` as the whole text to see or stop a session's "
|
|
211
|
+
"work (in a fresh email: every session).",
|
|
212
|
+
"",
|
|
213
|
+
"Sessions run unattended under this profile, which email cannot change:",
|
|
214
|
+
*(f"- {line}" for line in self.profile.describe()),
|
|
215
|
+
"",
|
|
216
|
+
"Each session is a background host: `pcode --hosts` lists them, and "
|
|
217
|
+
"`pcode --attach <id>` takes one over at a terminal.",
|
|
218
|
+
]
|
|
219
|
+
await self.send(
|
|
220
|
+
"\n".join(lines),
|
|
221
|
+
subject=f"pcode remote: {self.workspace.name}",
|
|
222
|
+
route=LAUNCHER,
|
|
223
|
+
auto="auto-generated",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def run(self, stop: asyncio.Event | None = None) -> None:
|
|
227
|
+
"""Poll until the listener expires or `stop` is set; then end every session."""
|
|
228
|
+
stop = stop or asyncio.Event()
|
|
229
|
+
delay = POLL_SECONDS
|
|
230
|
+
try:
|
|
231
|
+
while self.active() and not stop.is_set():
|
|
232
|
+
try:
|
|
233
|
+
await self.poll()
|
|
234
|
+
delay = POLL_SECONDS
|
|
235
|
+
except Exception as error: # noqa: BLE001 - the mailbox may come back.
|
|
236
|
+
delay = min(delay * 2, MAX_BACKOFF)
|
|
237
|
+
self.log(
|
|
238
|
+
f"email: poll failed ({error_message(error)}); retrying in {delay:.0f}s"
|
|
239
|
+
)
|
|
240
|
+
await self.retry_outbox()
|
|
241
|
+
remaining = self.expires - self.clock()
|
|
242
|
+
pause = min(delay * random.uniform(0.8, 1.2), max(remaining, 0))
|
|
243
|
+
try:
|
|
244
|
+
await asyncio.wait_for(stop.wait(), timeout=pause)
|
|
245
|
+
except TimeoutError:
|
|
246
|
+
pass
|
|
247
|
+
finally:
|
|
248
|
+
await self.shutdown()
|
|
249
|
+
|
|
250
|
+
async def shutdown(self) -> None:
|
|
251
|
+
"""Stop intake, cancel waiting inputs, stop every session; keep their worktrees."""
|
|
252
|
+
self.stopping = True
|
|
253
|
+
tasks = [task for session in self.sessions.values() for task in session.tasks]
|
|
254
|
+
for task in tasks:
|
|
255
|
+
task.cancel()
|
|
256
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
257
|
+
stopped = []
|
|
258
|
+
for session in self.sessions.values():
|
|
259
|
+
entry = session.entry
|
|
260
|
+
if entry is None or self.api.status(entry).state == "stopped":
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
await self.api.stop(entry)
|
|
264
|
+
await stop_entry(entry, keep_worktree=True)
|
|
265
|
+
stopped.append(f"{session.key} (worktree `{entry.workspace}`)")
|
|
266
|
+
except (OSError, ConnectionError) as error:
|
|
267
|
+
self.log(f"email: could not stop host {entry.id}: {error_message(error)}")
|
|
268
|
+
# Cancelled session tasks may have left SMTP running. Settle those
|
|
269
|
+
# operations before retrying anything or sending the final notification.
|
|
270
|
+
if self.deliveries:
|
|
271
|
+
await asyncio.gather(*(asyncio.shield(task) for task in self.deliveries.values()))
|
|
272
|
+
lines = ["pcode stopped listening; replies to its emails are no longer read."]
|
|
273
|
+
if stopped:
|
|
274
|
+
lines += [
|
|
275
|
+
"",
|
|
276
|
+
"Stopped sessions, their transcripts and worktrees kept:",
|
|
277
|
+
"",
|
|
278
|
+
*(f"- {line}" for line in stopped),
|
|
279
|
+
]
|
|
280
|
+
await self.send("\n".join(lines), subject="pcode remote: stopped", route=CONTROL)
|
|
281
|
+
# There is no retry loop after shutdown. Spend the remaining finite
|
|
282
|
+
# attempt budget now, including the final notification, without backoff.
|
|
283
|
+
# Each SMTP operation retains the mailbox's network timeout; abandoning
|
|
284
|
+
# its thread on an asyncio timeout would lose the actual send outcome.
|
|
285
|
+
for _ in range(SEND_ATTEMPTS):
|
|
286
|
+
if not any(entry.state == "pending" for entry in self.state.outbox):
|
|
287
|
+
break
|
|
288
|
+
await self.retry_outbox(force=True)
|
|
289
|
+
self.state.save()
|
|
290
|
+
self.log("email: listener stopped; sessions' transcripts and worktrees are kept.")
|
|
291
|
+
|
|
292
|
+
# Intake
|
|
293
|
+
|
|
294
|
+
async def poll(self) -> None:
|
|
295
|
+
handles = await asyncio.to_thread(self.mailbox.search, self.alias)
|
|
296
|
+
for handle in handles:
|
|
297
|
+
if handle in self.seen or not self.active():
|
|
298
|
+
continue
|
|
299
|
+
# One message that cannot be read must not hold up those after it.
|
|
300
|
+
try:
|
|
301
|
+
meta = await asyncio.to_thread(self.mailbox.meta, handle)
|
|
302
|
+
except Exception as error: # noqa: BLE001 - counted, then given up on.
|
|
303
|
+
failures = self.failures[handle] = self.failures.get(handle, 0) + 1
|
|
304
|
+
self.log(f"email: could not read message {handle} ({error_message(error)})")
|
|
305
|
+
if failures >= READ_ATTEMPTS:
|
|
306
|
+
self.seen.add(handle)
|
|
307
|
+
continue
|
|
308
|
+
self.seen.add(handle)
|
|
309
|
+
if meta.gmail_id in self.state.handled:
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
await self.take(handle, meta)
|
|
313
|
+
except Exception as error: # noqa: BLE001 - recorded; the loop carries on.
|
|
314
|
+
self.state.handled.setdefault(meta.gmail_id, "error")
|
|
315
|
+
self.state.save()
|
|
316
|
+
self.log(f"email: could not read message {handle} ({error_message(error)})")
|
|
317
|
+
|
|
318
|
+
async def take(self, handle: str, meta: Meta) -> None:
|
|
319
|
+
headers = await asyncio.to_thread(self.mailbox.headers, handle)
|
|
320
|
+
incoming = parse(headers)
|
|
321
|
+
reason = self.reject_reason(incoming, meta)
|
|
322
|
+
# Recorded before anything is dispatched, so nothing runs twice.
|
|
323
|
+
self.state.handled[meta.gmail_id] = reason or "accepted"
|
|
324
|
+
self.state.save()
|
|
325
|
+
if reason:
|
|
326
|
+
hint = f"; From must be exactly {self.owner}" if reason == "sender" else ""
|
|
327
|
+
self.log(f"email: ignored a message ({reason}{hint})")
|
|
328
|
+
return
|
|
329
|
+
self.handled_ids.add(incoming.message_id)
|
|
330
|
+
if meta.size > MAX_MESSAGE_BYTES:
|
|
331
|
+
incoming.problem = "too-large"
|
|
332
|
+
else:
|
|
333
|
+
try:
|
|
334
|
+
raw = await asyncio.to_thread(self.mailbox.fetch, handle)
|
|
335
|
+
if len(raw) > MAX_MESSAGE_BYTES:
|
|
336
|
+
incoming.problem = "too-large"
|
|
337
|
+
else:
|
|
338
|
+
read_body(raw, incoming)
|
|
339
|
+
except Exception as error: # noqa: BLE001 - the owner is asked to resend.
|
|
340
|
+
self.log(f"email: could not fetch a message ({error_message(error)})")
|
|
341
|
+
incoming.problem = MALFORMED
|
|
342
|
+
await self.dispatch(incoming)
|
|
343
|
+
|
|
344
|
+
def reject_reason(self, incoming: Incoming, meta: Meta) -> str:
|
|
345
|
+
"""Why this message is not the owner's own to this listener; "" to accept it."""
|
|
346
|
+
if not self.active():
|
|
347
|
+
return "listener-inactive"
|
|
348
|
+
if self.alias not in incoming.to:
|
|
349
|
+
return "not-to-alias"
|
|
350
|
+
if incoming.senders != [self.owner] or incoming.resent:
|
|
351
|
+
return "sender"
|
|
352
|
+
if incoming.sender_header and incoming.sender_header != [self.owner]:
|
|
353
|
+
return "sender"
|
|
354
|
+
if not meta.sent:
|
|
355
|
+
return "not-sent"
|
|
356
|
+
if incoming.marker or incoming.message_id in self.sent_ids:
|
|
357
|
+
return "own-mail"
|
|
358
|
+
if incoming.auto_submitted not in ("", "no"):
|
|
359
|
+
return "auto-submitted"
|
|
360
|
+
if incoming.report:
|
|
361
|
+
return "report"
|
|
362
|
+
if incoming.list_mail:
|
|
363
|
+
return "list-mail"
|
|
364
|
+
if not incoming.message_id:
|
|
365
|
+
return "no-message-id"
|
|
366
|
+
if incoming.message_id in self.handled_ids:
|
|
367
|
+
return "duplicate"
|
|
368
|
+
return ""
|
|
369
|
+
|
|
370
|
+
# Routing
|
|
371
|
+
|
|
372
|
+
def resolve(self, incoming: Incoming) -> tuple[str, Session | None, str]:
|
|
373
|
+
"""(kind, session, root): kind is "fresh", "session", "root", "unknown" or "conflict"."""
|
|
374
|
+
if not incoming.ancestry:
|
|
375
|
+
return "fresh", None, ""
|
|
376
|
+
targets = [self.state.routes[i] for i in incoming.ancestry if i in self.state.routes]
|
|
377
|
+
keys = {target for target in targets if target in self.sessions}
|
|
378
|
+
if len(keys) > 1:
|
|
379
|
+
return "conflict", None, ""
|
|
380
|
+
if keys:
|
|
381
|
+
return "session", self.sessions[keys.pop()], ""
|
|
382
|
+
roots = [i for i in incoming.ancestry if self.state.routes.get(i) in (LAUNCHER, CONTROL)]
|
|
383
|
+
if roots:
|
|
384
|
+
return "root", None, roots[-1]
|
|
385
|
+
return "unknown", None, ""
|
|
386
|
+
|
|
387
|
+
async def dispatch(self, incoming: Incoming) -> None:
|
|
388
|
+
kind, session, root = self.resolve(incoming)
|
|
389
|
+
reply = self.replier(incoming)
|
|
390
|
+
if kind == "conflict":
|
|
391
|
+
await reply(
|
|
392
|
+
"This email replies to messages from more than one pcode session, so "
|
|
393
|
+
"pcode cannot tell which to continue. Reply to one session's latest email, "
|
|
394
|
+
"or write a new email for a new session.",
|
|
395
|
+
CONTROL,
|
|
396
|
+
)
|
|
397
|
+
return
|
|
398
|
+
if kind == "unknown":
|
|
399
|
+
await reply(
|
|
400
|
+
"pcode does not recognise the thread this email replies to (it may be from "
|
|
401
|
+
"an earlier listener). Write a new email to start a session.",
|
|
402
|
+
CONTROL,
|
|
403
|
+
)
|
|
404
|
+
return
|
|
405
|
+
if incoming.problem:
|
|
406
|
+
what = PROBLEMS.get(incoming.problem, "pcode could not read it")
|
|
407
|
+
await reply(
|
|
408
|
+
f"Nothing was run: {what}. Please send the task again as plain new text.",
|
|
409
|
+
session.key if session else CONTROL,
|
|
410
|
+
)
|
|
411
|
+
return
|
|
412
|
+
command = incoming.text.strip()
|
|
413
|
+
if command == "/status":
|
|
414
|
+
await reply(self.status_text(session), session.key if session else CONTROL)
|
|
415
|
+
return
|
|
416
|
+
if command == "/stop":
|
|
417
|
+
await self.stop_session(session, reply)
|
|
418
|
+
return
|
|
419
|
+
if refusal := self.refusal(session):
|
|
420
|
+
route = session.key if session else CONTROL
|
|
421
|
+
await reply(f"Not run: {refusal}. Send it again later.", route)
|
|
422
|
+
return
|
|
423
|
+
if session is None:
|
|
424
|
+
session = Session(key=f"s{len(self.sessions) + 1}", subject=incoming.subject)
|
|
425
|
+
self.sessions[session.key] = session
|
|
426
|
+
if kind == "root":
|
|
427
|
+
# Replies to the same launcher (or status email) join this session.
|
|
428
|
+
self.state.routes[root] = session.key
|
|
429
|
+
self.state.routes[incoming.message_id] = session.key
|
|
430
|
+
self.state.save()
|
|
431
|
+
session.pending += 1
|
|
432
|
+
task = asyncio.create_task(self.run_input(session, incoming, reply, session.stops))
|
|
433
|
+
session.tasks.add(task)
|
|
434
|
+
# Not a `finally` in run_input: a task cancelled before it starts never runs one.
|
|
435
|
+
task.add_done_callback(session.finished)
|
|
436
|
+
|
|
437
|
+
# Sessions
|
|
438
|
+
|
|
439
|
+
def refusal(self, session: Session | None) -> str:
|
|
440
|
+
"""Why a new input for `session` (None: a new session) is over a limit; "" if not."""
|
|
441
|
+
limits = self.limits
|
|
442
|
+
pending = sum(each.pending for each in self.sessions.values())
|
|
443
|
+
busy = sum(1 for each in self.sessions.values() if each.pending)
|
|
444
|
+
if session is None and len(self.sessions) >= limits.max_sessions:
|
|
445
|
+
return (
|
|
446
|
+
f"this listener has already started {limits.max_sessions} sessions; "
|
|
447
|
+
"restart pcode --email-listen for more"
|
|
448
|
+
)
|
|
449
|
+
if session is not None and session.pending >= limits.session_inputs:
|
|
450
|
+
return f"this session already has {session.pending} emails waiting"
|
|
451
|
+
if pending >= limits.max_inputs:
|
|
452
|
+
return f"{pending} emails are already waiting across all sessions"
|
|
453
|
+
if (session is None or not session.pending) and busy >= limits.concurrent_sessions:
|
|
454
|
+
return f"{limits.concurrent_sessions} sessions are already working"
|
|
455
|
+
return ""
|
|
456
|
+
|
|
457
|
+
async def ensure_host(self, session: Session) -> bool:
|
|
458
|
+
"""A running host for `session`; True when it was started fresh rather than resumed."""
|
|
459
|
+
entry = session.entry
|
|
460
|
+
if entry is not None and self.api.status(entry).state != "stopped":
|
|
461
|
+
return False
|
|
462
|
+
resume = session.session_id or None
|
|
463
|
+
session.entry = await self.api.start_session(
|
|
464
|
+
self.workspace, profile=self.profile, model=self.model, resume=resume
|
|
465
|
+
)
|
|
466
|
+
if session.base is None:
|
|
467
|
+
session.base = await asyncio.to_thread(head_commit, Path(session.entry.workspace))
|
|
468
|
+
self.state.sessions[session.key] = session.record()
|
|
469
|
+
self.state.save()
|
|
470
|
+
self.log(f"email: session {session.key} on host {session.entry.id}")
|
|
471
|
+
return resume is None
|
|
472
|
+
|
|
473
|
+
def prompt(self, session: Session, incoming: Incoming, first: bool) -> str:
|
|
474
|
+
text = incoming.text
|
|
475
|
+
subject = incoming.subject
|
|
476
|
+
if first and subject and not subject.lower().startswith("re:") and subject not in text:
|
|
477
|
+
text = f"{subject}\n\n{text}"
|
|
478
|
+
# The model never sees the address that controls it, even with the
|
|
479
|
+
# token broken across quoted, wrapped or encoded lines.
|
|
480
|
+
spread = r"[\s>=]*".join(re.escape(char) for char in self._token)
|
|
481
|
+
return re.sub(spread, REDACTED, text, flags=re.IGNORECASE)
|
|
482
|
+
|
|
483
|
+
async def run_input(self, session: Session, incoming: Incoming, reply, stops: int) -> None:
|
|
484
|
+
try:
|
|
485
|
+
async with session.lock:
|
|
486
|
+
session.current = asyncio.current_task()
|
|
487
|
+
fresh = await self.ensure_host(session)
|
|
488
|
+
entry = session.entry
|
|
489
|
+
# No await from this check to the send: a /stop either sees
|
|
490
|
+
# `queued` and waits for it, or this sees its count.
|
|
491
|
+
if session.stops != stops:
|
|
492
|
+
await reply("Not run: the session was stopped first.", session.key)
|
|
493
|
+
return
|
|
494
|
+
queued = session.queued = asyncio.Event()
|
|
495
|
+
sending = asyncio.ensure_future(
|
|
496
|
+
self.api.send(
|
|
497
|
+
entry,
|
|
498
|
+
self.prompt(session, incoming, fresh),
|
|
499
|
+
base=session.base,
|
|
500
|
+
on_queued=queued.set,
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
acknowledged = False
|
|
504
|
+
if fresh:
|
|
505
|
+
done, _ = await asyncio.wait({sending}, timeout=COALESCE_SECONDS)
|
|
506
|
+
if not done:
|
|
507
|
+
acknowledged = True
|
|
508
|
+
await reply(self.started_text(session), session.key)
|
|
509
|
+
try:
|
|
510
|
+
result = await sending
|
|
511
|
+
finally:
|
|
512
|
+
sending.cancel()
|
|
513
|
+
session.queued = None
|
|
514
|
+
status = self.api.status(entry)
|
|
515
|
+
session.session_id = status.session_id or session.session_id
|
|
516
|
+
self.state.sessions[session.key] = session.record()
|
|
517
|
+
self.state.save()
|
|
518
|
+
body, trailer = self.result_text(
|
|
519
|
+
session, result, incoming, intro=fresh and not acknowledged
|
|
520
|
+
)
|
|
521
|
+
await reply(body, session.key, trailer=trailer)
|
|
522
|
+
except Exception as error: # noqa: BLE001 - told to the owner, not raised.
|
|
523
|
+
self.log(f"email: session {session.key} failed: {error_message(error)}")
|
|
524
|
+
await reply(f"pcode could not run this: {error_message(error)}", session.key)
|
|
525
|
+
|
|
526
|
+
async def stop_session(self, session: Session | None, reply) -> None:
|
|
527
|
+
if session is None:
|
|
528
|
+
await reply(
|
|
529
|
+
"/stop needs to know which session: reply with /stop to an email from "
|
|
530
|
+
"that session.",
|
|
531
|
+
CONTROL,
|
|
532
|
+
)
|
|
533
|
+
return
|
|
534
|
+
# Inputs still waiting their turn here are dropped, as the host's queue
|
|
535
|
+
# is; the running one ends when its turn is cancelled, and says so.
|
|
536
|
+
session.stops += 1
|
|
537
|
+
for task in session.tasks:
|
|
538
|
+
if task is not session.current:
|
|
539
|
+
task.cancel()
|
|
540
|
+
if session.queued is not None:
|
|
541
|
+
# Its message may still be on its way: cancel once the host holds it.
|
|
542
|
+
try:
|
|
543
|
+
await asyncio.wait_for(session.queued.wait(), timeout=30)
|
|
544
|
+
except TimeoutError:
|
|
545
|
+
pass
|
|
546
|
+
if session.entry is not None and self.api.status(session.entry).state != "stopped":
|
|
547
|
+
await self.api.stop(session.entry)
|
|
548
|
+
await reply(f"Stopped session {session.key}'s work; the session stays open.", session.key)
|
|
549
|
+
|
|
550
|
+
# What the replies say
|
|
551
|
+
|
|
552
|
+
def started_text(self, session: Session) -> str:
|
|
553
|
+
entry = session.entry
|
|
554
|
+
return (
|
|
555
|
+
f"Started session {session.key} (host `{entry.id}`) in `{entry.workspace}`. "
|
|
556
|
+
f"Working on it; the result follows by email.\n\n"
|
|
557
|
+
f"Take it over at a terminal: `pcode --attach {entry.id}`"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def result_text(
|
|
561
|
+
self, session: Session, result, incoming: Incoming, *, intro: bool
|
|
562
|
+
) -> tuple[str, str]:
|
|
563
|
+
"""The model's reply (Markdown) and the session detail under it."""
|
|
564
|
+
entry = session.entry
|
|
565
|
+
body = result.reply or "(The turn ended without a reply.)"
|
|
566
|
+
if intro:
|
|
567
|
+
body = f"Started session {session.key}.\n\n{body}"
|
|
568
|
+
lines = [f"Status: {OUTCOMES.get(result.outcome, result.outcome)}"]
|
|
569
|
+
lines += [f"Note: {note}" for note in result.notes]
|
|
570
|
+
if incoming.attachments:
|
|
571
|
+
lines.append(f"Attachments were ignored: {', '.join(incoming.attachments)}")
|
|
572
|
+
session_id = session.session_id or "(unsaved)"
|
|
573
|
+
lines.append(f"Session {session.key}: {session_id}, host {entry.id}")
|
|
574
|
+
if result.changes is not None:
|
|
575
|
+
lines += ["", result.changes.describe()]
|
|
576
|
+
lines += ["", f"Take it over at a terminal: pcode --attach {entry.id}"]
|
|
577
|
+
return body, "\n".join(lines)
|
|
578
|
+
|
|
579
|
+
def status_text(self, session: Session | None) -> str:
|
|
580
|
+
sessions = [session] if session is not None else list(self.sessions.values())
|
|
581
|
+
if not sessions:
|
|
582
|
+
return "No sessions yet. Reply with a task to start one."
|
|
583
|
+
lines = []
|
|
584
|
+
for each in sessions:
|
|
585
|
+
if each.entry is None:
|
|
586
|
+
lines.append(f"- **{each.key}**: starting ({each.pending} waiting)")
|
|
587
|
+
continue
|
|
588
|
+
status = self.api.status(each.entry)
|
|
589
|
+
line = f"- **{each.key}**: {status.state}, {status.turns} turns"
|
|
590
|
+
if status.outcome:
|
|
591
|
+
line += f", last {status.outcome}"
|
|
592
|
+
if each.pending:
|
|
593
|
+
line += f", {each.pending} waiting"
|
|
594
|
+
lines.append(f"{line} — {status.title or each.subject}")
|
|
595
|
+
lines.append(f" - host `{each.entry.id}`, worktree `{status.workspace}`")
|
|
596
|
+
remaining = max(0, self.expires - self.clock()) / 60
|
|
597
|
+
lines.append(f"\nListening for {remaining:.0f} more minutes.")
|
|
598
|
+
return "\n".join(lines)
|
|
599
|
+
|
|
600
|
+
# Sending
|
|
601
|
+
|
|
602
|
+
def replier(self, incoming: Incoming):
|
|
603
|
+
async def reply(body: str, route: str, *, trailer: str = "") -> None:
|
|
604
|
+
await self.send(
|
|
605
|
+
body,
|
|
606
|
+
trailer=trailer,
|
|
607
|
+
subject=outbound.reply_subject(incoming.subject),
|
|
608
|
+
route=route,
|
|
609
|
+
in_reply_to=incoming.message_id,
|
|
610
|
+
references=incoming.ancestry,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
return reply
|
|
614
|
+
|
|
615
|
+
async def send(
|
|
616
|
+
self,
|
|
617
|
+
body: str,
|
|
618
|
+
*,
|
|
619
|
+
subject: str,
|
|
620
|
+
route: str,
|
|
621
|
+
trailer: str = "",
|
|
622
|
+
in_reply_to: str = "",
|
|
623
|
+
references: list[str] | None = None,
|
|
624
|
+
auto: str = "auto-replied",
|
|
625
|
+
) -> None:
|
|
626
|
+
message_id = outbound.new_message_id(self.owner)
|
|
627
|
+
self.sent_ids.add(message_id)
|
|
628
|
+
self.state.routes[message_id] = route
|
|
629
|
+
entry = OutboxEntry(
|
|
630
|
+
message_id,
|
|
631
|
+
route,
|
|
632
|
+
in_reply_to,
|
|
633
|
+
subject,
|
|
634
|
+
body,
|
|
635
|
+
list(references or []),
|
|
636
|
+
auto,
|
|
637
|
+
trailer=trailer,
|
|
638
|
+
)
|
|
639
|
+
# Saved with its content before sending: a retry never reruns the agent.
|
|
640
|
+
self.state.outbox.append(entry)
|
|
641
|
+
self.state.save()
|
|
642
|
+
await self.deliver(entry)
|
|
643
|
+
|
|
644
|
+
async def deliver(self, entry: OutboxEntry) -> None:
|
|
645
|
+
message_id = entry.planned_rfc_id
|
|
646
|
+
task = self.deliveries.get(message_id)
|
|
647
|
+
if task is None:
|
|
648
|
+
if entry.state in {"sent", "failed"}:
|
|
649
|
+
return
|
|
650
|
+
task = asyncio.create_task(self._deliver(entry))
|
|
651
|
+
self.deliveries[message_id] = task
|
|
652
|
+
task.add_done_callback(lambda _: self.deliveries.pop(message_id, None))
|
|
653
|
+
await asyncio.shield(task)
|
|
654
|
+
|
|
655
|
+
async def _deliver(self, entry: OutboxEntry) -> None:
|
|
656
|
+
if entry.attempts >= SEND_ATTEMPTS:
|
|
657
|
+
entry.state = "failed"
|
|
658
|
+
self.state.save()
|
|
659
|
+
return
|
|
660
|
+
message = outbound.compose(
|
|
661
|
+
owner=self.owner,
|
|
662
|
+
alias=self.alias,
|
|
663
|
+
subject=entry.subject,
|
|
664
|
+
body=entry.body,
|
|
665
|
+
trailer=entry.trailer,
|
|
666
|
+
in_reply_to=entry.in_reply_to,
|
|
667
|
+
references=entry.references,
|
|
668
|
+
auto=entry.auto,
|
|
669
|
+
message_id=entry.planned_rfc_id,
|
|
670
|
+
)
|
|
671
|
+
entry.attempts += 1
|
|
672
|
+
# In flight: a retry pass must not send it a second time meanwhile.
|
|
673
|
+
entry.state = "sending"
|
|
674
|
+
try:
|
|
675
|
+
await asyncio.to_thread(self.mailbox.send, message)
|
|
676
|
+
except Exception as error: # noqa: BLE001 - retried, and shown here.
|
|
677
|
+
if entry.attempts >= SEND_ATTEMPTS:
|
|
678
|
+
entry.state = "failed"
|
|
679
|
+
self.log(
|
|
680
|
+
f"email: gave up sending a reply after {entry.attempts} attempts: "
|
|
681
|
+
f"{error_message(error)}"
|
|
682
|
+
)
|
|
683
|
+
else:
|
|
684
|
+
entry.state = "pending"
|
|
685
|
+
entry.retry_at = self.clock() + SEND_BACKOFF * 2 ** (entry.attempts - 1)
|
|
686
|
+
self.log(f"email: sending a reply failed ({error_message(error)}); will retry")
|
|
687
|
+
else:
|
|
688
|
+
entry.state = "sent"
|
|
689
|
+
self.state.save()
|
|
690
|
+
|
|
691
|
+
async def retry_outbox(self, *, force: bool = False) -> None:
|
|
692
|
+
"""Resend failed replies whose backoff has passed (all of them with `force`)."""
|
|
693
|
+
for entry in self.state.outbox:
|
|
694
|
+
if entry.state == "pending" and (force or entry.retry_at <= self.clock()):
|
|
695
|
+
await self.deliver(entry)
|