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/edits.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Bounded, sanitized evidence of completed file mutations, not workspace diffs."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from pcode.runtime import EditCompleted
|
|
8
|
+
from pcode.tool_display import command_text, plain
|
|
9
|
+
|
|
10
|
+
MAX_SOURCE = 256 * 1024
|
|
11
|
+
MAX_LINES = 4000
|
|
12
|
+
MAX_PATCH = 64 * 1024
|
|
13
|
+
MAX_PATCH_LINES = 400
|
|
14
|
+
_SENSITIVE = re.compile(
|
|
15
|
+
r"(?i)(secret|token|password|credential|api.?key|private.?key|\.env|kubeconfig|"
|
|
16
|
+
r"(?:^|/)\.kube/|(?:^|/)\.aws/|\.tfvars|\.pem$|\.key$|(?:^|/)id_(?:rsa|ed25519)(?:$|\.))"
|
|
17
|
+
)
|
|
18
|
+
_OPEN_SECRET = re.compile(
|
|
19
|
+
r"(?i)((?:password|passwd|secret|token|api[_-]?key|authorization)[\"']?\s*[:=]\s*)"
|
|
20
|
+
r"([\"'])(.*?)(?:(?<!\\)\2|\Z)",
|
|
21
|
+
re.DOTALL,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sensitive_path(path: str) -> bool:
|
|
26
|
+
return bool(_SENSITIVE.search(path))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def edit_text(text: str) -> str:
|
|
30
|
+
# Redact whole strings before adding diff prefixes or clipping. A credential
|
|
31
|
+
# can span lines, and a streamed quoted value may not have its closing quote.
|
|
32
|
+
text = _OPEN_SECRET.sub(r"\1\2[redacted]\2", text)
|
|
33
|
+
text = re.sub(
|
|
34
|
+
r"(?i)((?:password|passwd|secret|token|api[_-]?key|authorization)[\"']?\s*[:=]\s*)"
|
|
35
|
+
r"[^\s\"',;}]+",
|
|
36
|
+
r"\1[redacted]",
|
|
37
|
+
text,
|
|
38
|
+
)
|
|
39
|
+
text = re.sub(r"-----BEGIN [^-]*PRIVATE KEY-----.*", "[private key redacted]", text, flags=re.S)
|
|
40
|
+
return command_text(text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_HUNK = re.compile(r"@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def patch_text(patch: str, *, dedent: bool) -> str:
|
|
47
|
+
"""A stored patch as it is displayed: redacted, and dedented if asked."""
|
|
48
|
+
text = edit_text(patch)
|
|
49
|
+
return dedent_patch(text) if dedent else text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def dedent_patch(patch: str) -> str:
|
|
53
|
+
"""Strip the leading whitespace every line of a hunk shares.
|
|
54
|
+
|
|
55
|
+
An edit deep in a nested block otherwise starts its whole diff several
|
|
56
|
+
levels in. Each hunk is dedented on its own, by the whitespace common to
|
|
57
|
+
all its context, added and removed lines; whitespace-only lines do not
|
|
58
|
+
count, and lose only the shared part, so a whitespace change still shows.
|
|
59
|
+
Hunks are walked by their header's line counts, so a removed `--- x` line
|
|
60
|
+
is never mistaken for a file header, and everything outside a hunk is left
|
|
61
|
+
alone; a hunk that does not parse as its header promises is left as it is.
|
|
62
|
+
`edit_text` has already expanded tabs.
|
|
63
|
+
"""
|
|
64
|
+
lines = patch.split("\n")
|
|
65
|
+
index = 0
|
|
66
|
+
while index < len(lines):
|
|
67
|
+
match = _HUNK.match(lines[index])
|
|
68
|
+
index += 1
|
|
69
|
+
if not match:
|
|
70
|
+
continue
|
|
71
|
+
old, new = (int(count) if count is not None else 1 for count in match.groups())
|
|
72
|
+
body = []
|
|
73
|
+
malformed = False
|
|
74
|
+
while index < len(lines) and (old > 0 or new > 0):
|
|
75
|
+
marker = lines[index][:1]
|
|
76
|
+
if marker not in (" ", "+", "-", "\\", ""):
|
|
77
|
+
malformed = True
|
|
78
|
+
break
|
|
79
|
+
if marker != "\\":
|
|
80
|
+
body.append(index)
|
|
81
|
+
old -= marker != "+"
|
|
82
|
+
new -= marker != "-"
|
|
83
|
+
index += 1
|
|
84
|
+
# A trailing "\ No newline" note belongs to the hunk just read.
|
|
85
|
+
if index < len(lines) and lines[index].startswith("\\"):
|
|
86
|
+
index += 1
|
|
87
|
+
if not malformed:
|
|
88
|
+
_dedent(lines, body)
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _dedent(lines: list[str], body: list[int]) -> None:
|
|
93
|
+
contents = [lines[i][1:] for i in body]
|
|
94
|
+
indents = [c[: len(c) - len(c.lstrip())] for c in contents if c.strip()]
|
|
95
|
+
if not indents:
|
|
96
|
+
return
|
|
97
|
+
shared = os.path.commonprefix(indents)
|
|
98
|
+
if not shared:
|
|
99
|
+
return
|
|
100
|
+
for i, content in zip(body, contents, strict=True):
|
|
101
|
+
marker = lines[i][:1]
|
|
102
|
+
kept = content.removeprefix(shared) if content.strip() else content[len(shared) :]
|
|
103
|
+
lines[i] = marker + kept
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def source_lines(text: str) -> list[str]:
|
|
107
|
+
"""Lines as git and diff count them, each keeping its newline.
|
|
108
|
+
|
|
109
|
+
`str.splitlines` also breaks at form feeds, U+2028 and other separators
|
|
110
|
+
that are ordinary characters inside a source line, adding lines the file
|
|
111
|
+
does not have and throwing a hunk's line counts off.
|
|
112
|
+
"""
|
|
113
|
+
parts = text.split("\n")
|
|
114
|
+
return [part + "\n" for part in parts[:-1]] + ([parts[-1]] if parts[-1] else [])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def change_from_record(record: dict) -> EditCompleted:
|
|
118
|
+
"""Rebuild a saved change, ignoring journal fields the dataclass does not own."""
|
|
119
|
+
return EditCompleted(
|
|
120
|
+
**{key: value for key, value in record.items() if key in EditCompleted.__dataclass_fields__}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def completed_change(path, before, after, *, existed=True, call_id="", omitted=""):
|
|
125
|
+
"""Build display evidence while raw snapshots are still local to execution."""
|
|
126
|
+
operation = "edited" if existed else "created"
|
|
127
|
+
if sensitive_path(path):
|
|
128
|
+
return EditCompleted(call_id, "[sensitive path]", operation, omitted="Sensitive file")
|
|
129
|
+
path = plain(edit_text(path), limit=None)
|
|
130
|
+
if omitted or before is None:
|
|
131
|
+
return EditCompleted(
|
|
132
|
+
call_id, path, operation, omitted=omitted or "Before snapshot unavailable"
|
|
133
|
+
)
|
|
134
|
+
if max(len(before), len(after)) > MAX_SOURCE:
|
|
135
|
+
return EditCompleted(call_id, path, operation, omitted="File exceeds preview size limit")
|
|
136
|
+
if "\x00" in before or "\x00" in after:
|
|
137
|
+
return EditCompleted(call_id, path, operation, omitted="Binary content")
|
|
138
|
+
if max(len(source_lines(before)), len(source_lines(after))) > MAX_LINES:
|
|
139
|
+
return EditCompleted(call_id, path, operation, omitted="File exceeds preview line limit")
|
|
140
|
+
if before == after:
|
|
141
|
+
return EditCompleted(call_id, path, "unchanged" if existed else "created")
|
|
142
|
+
# Count actual changed source lines, even where redaction hides a change.
|
|
143
|
+
raw = list(difflib.unified_diff(source_lines(before), source_lines(after), n=3))
|
|
144
|
+
added = sum(line.startswith("+") for line in raw[2:])
|
|
145
|
+
removed = sum(line.startswith("-") for line in raw[2:])
|
|
146
|
+
lines = []
|
|
147
|
+
for line in difflib.unified_diff(
|
|
148
|
+
source_lines(edit_text(before)),
|
|
149
|
+
source_lines(edit_text(after)),
|
|
150
|
+
fromfile=f"a/{path}" if existed else "/dev/null",
|
|
151
|
+
tofile=f"b/{path}",
|
|
152
|
+
n=3,
|
|
153
|
+
):
|
|
154
|
+
lines.append(line.rstrip("\n"))
|
|
155
|
+
if not line.endswith("\n"):
|
|
156
|
+
lines.append(r"")
|
|
157
|
+
patch = "\n".join(lines[:MAX_PATCH_LINES])
|
|
158
|
+
truncated = len(lines) > MAX_PATCH_LINES or len(patch) > MAX_PATCH
|
|
159
|
+
patch = patch[:MAX_PATCH]
|
|
160
|
+
if not patch:
|
|
161
|
+
omitted = "Changes hidden by redaction or newline normalization"
|
|
162
|
+
return EditCompleted(call_id, path, operation, patch, added, removed, truncated, omitted)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""`pcode --email-listen`: a Gmail account as a remote control for session hosts.
|
|
2
|
+
|
|
3
|
+
A new email to the listener's alias starts a session (an ordinary host, under
|
|
4
|
+
`pcode.remote_profile`'s fixed profile, in its own worktree); a reply continues
|
|
5
|
+
it. Only mail the owner sent from their own account is accepted: Gmail labels
|
|
6
|
+
it `SENT`, which nothing delivered from outside can carry. See
|
|
7
|
+
`dev/email-remote.md` for the design and the checks behind it.
|
|
8
|
+
|
|
9
|
+
- `parsing`: one raw message to headers and the new text in its body
|
|
10
|
+
- `mailbox`: Gmail over IMAP and SMTP, and the keychain credential
|
|
11
|
+
- `state`: what one listener has handled, routed and sent, on disk
|
|
12
|
+
- `outbound`: the messages pcode sends
|
|
13
|
+
- `listener`: acceptance, routing, the sessions, and the loop
|
|
14
|
+
"""
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""`pcode --email-setup` and `pcode --email-listen`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pcode.email_remote.listener import Limits, Listener, head_commit
|
|
14
|
+
from pcode.email_remote.mailbox import GmailMailbox, SetupError, read_password, store_password
|
|
15
|
+
from pcode.email_remote.state import state_dir
|
|
16
|
+
from pcode.preferences import load_preferences, update_preferences
|
|
17
|
+
from pcode.remote_profile import RemoteProfile
|
|
18
|
+
|
|
19
|
+
_DURATION = re.compile(r"^(\d+(?:\.\d+)?)([smhd]?)$")
|
|
20
|
+
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "": 60}
|
|
21
|
+
_ADDRESS = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
22
|
+
APP_PASSWORDS = "https://myaccount.google.com/apppasswords"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_duration(text: str) -> float:
|
|
26
|
+
"""`8h`, `30m`, `1d`, `90s`; a bare number is minutes."""
|
|
27
|
+
match = _DURATION.match(text.strip().lower())
|
|
28
|
+
if match is None or float(match.group(1)) <= 0:
|
|
29
|
+
raise ValueError(f"Not a duration: {text!r} (use e.g. 30m, 8h, 1d).")
|
|
30
|
+
return float(match.group(1)) * _UNITS[match.group(2)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def profile_from_preferences(preferences: dict, base: str | None = None) -> RemoteProfile:
|
|
34
|
+
"""The profile email-started hosts run under: chosen here, never by email."""
|
|
35
|
+
return RemoteProfile(
|
|
36
|
+
# The listener's state (routes, the outbox, replies); the keychain is denied anyway.
|
|
37
|
+
deny_read=(str(state_dir()),),
|
|
38
|
+
mcp=preferences.get("email_mcp", "off") == "on",
|
|
39
|
+
turn_minutes=float(preferences.get("email_turn_minutes") or 30),
|
|
40
|
+
max_requests=int(preferences.get("email_turn_requests") or 100),
|
|
41
|
+
max_tool_calls=int(preferences.get("email_turn_tool_calls") or 100),
|
|
42
|
+
base=base,
|
|
43
|
+
origin="email",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def setup(owner: str | None, ask=input) -> int:
|
|
48
|
+
"""Store the app password in the keychain, check it, and confirm the profile once."""
|
|
49
|
+
owner = (owner or ask("Gmail address: ")).strip().lower()
|
|
50
|
+
if not _ADDRESS.match(owner):
|
|
51
|
+
raise SetupError(f"{owner!r} is not an email address.")
|
|
52
|
+
print(
|
|
53
|
+
"pcode reads and sends this account's mail over IMAP with a Gmail app password "
|
|
54
|
+
f"(2-Step Verification required): create one at {APP_PASSWORDS}.\n"
|
|
55
|
+
"Paste it at the keychain prompt below; it goes straight into the macOS "
|
|
56
|
+
"keychain, and only the listener reads it.",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
store_password(owner)
|
|
60
|
+
mailbox = GmailMailbox(owner, read_password(owner))
|
|
61
|
+
try:
|
|
62
|
+
mailbox.verify()
|
|
63
|
+
finally:
|
|
64
|
+
mailbox.close()
|
|
65
|
+
print("Signed in to Gmail over IMAP.\n", file=sys.stderr)
|
|
66
|
+
print("Email-started sessions run unattended, under this profile:", file=sys.stderr)
|
|
67
|
+
for line in profile_from_preferences(load_preferences()).describe():
|
|
68
|
+
print(f" {line}", file=sys.stderr)
|
|
69
|
+
answer = ask("Let email from this account start sessions under it? [y/N] ")
|
|
70
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
71
|
+
print("Not enabled.", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
update_preferences({"email_owner": owner})
|
|
74
|
+
print(f"Enabled for {owner}. Start listening with: pcode --email-listen", file=sys.stderr)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def listen(workspace: Path, *, ttl: str, model: str | None) -> int:
|
|
79
|
+
from pcode.worktree import main_checkout
|
|
80
|
+
|
|
81
|
+
preferences = load_preferences()
|
|
82
|
+
owner = preferences.get("email_owner")
|
|
83
|
+
if not owner:
|
|
84
|
+
raise SetupError("No email account is set up; run pcode --email-setup.")
|
|
85
|
+
model = model or preferences.get("model")
|
|
86
|
+
if not model:
|
|
87
|
+
raise SetupError("Email sessions need a model: pass -m or set a default with /model.")
|
|
88
|
+
workspace = workspace.resolve()
|
|
89
|
+
if main_checkout(workspace) is None:
|
|
90
|
+
raise SetupError(f"{workspace} is not in a git repository; email sessions need worktrees.")
|
|
91
|
+
seconds = parse_duration(ttl)
|
|
92
|
+
mailbox = GmailMailbox(owner, read_password(owner))
|
|
93
|
+
mailbox.verify()
|
|
94
|
+
profile = profile_from_preferences(preferences, base=head_commit(workspace))
|
|
95
|
+
listener = Listener(
|
|
96
|
+
owner=owner,
|
|
97
|
+
mailbox=mailbox,
|
|
98
|
+
workspace=workspace,
|
|
99
|
+
profile=profile,
|
|
100
|
+
ttl=seconds,
|
|
101
|
+
# One file per run: two listeners (two repositories) never share one.
|
|
102
|
+
state_path=state_dir() / f"{time.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}.json",
|
|
103
|
+
limits=Limits.from_preferences(preferences),
|
|
104
|
+
model=model,
|
|
105
|
+
)
|
|
106
|
+
expiry = time.strftime("%Y-%m-%d %H:%M %Z", time.localtime(listener.expires))
|
|
107
|
+
print(
|
|
108
|
+
"\n".join(
|
|
109
|
+
[
|
|
110
|
+
f"Listening for email from {owner}",
|
|
111
|
+
f"Workspace: {workspace} (model {model})",
|
|
112
|
+
"Profile:",
|
|
113
|
+
*(f" {line}" for line in profile.describe()),
|
|
114
|
+
f"Until: {expiry}. Ctrl+C stops listening and every email session.",
|
|
115
|
+
]
|
|
116
|
+
),
|
|
117
|
+
file=sys.stderr,
|
|
118
|
+
flush=True,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def main() -> None:
|
|
122
|
+
stop = asyncio.Event()
|
|
123
|
+
loop = asyncio.get_running_loop()
|
|
124
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
125
|
+
loop.add_signal_handler(signum, stop.set)
|
|
126
|
+
try:
|
|
127
|
+
await listener.start()
|
|
128
|
+
await listener.run(stop)
|
|
129
|
+
finally:
|
|
130
|
+
mailbox.close()
|
|
131
|
+
|
|
132
|
+
asyncio.run(main())
|
|
133
|
+
return 0
|