pcode 0.1.0__py3-none-any.whl

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