atgent 0.3.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.
- agent_mailbox/__init__.py +3 -0
- agent_mailbox/__main__.py +3 -0
- agent_mailbox/claude_approvals.py +268 -0
- agent_mailbox/cli.py +319 -0
- agent_mailbox/codex_approvals.py +238 -0
- agent_mailbox/core.py +853 -0
- agent_mailbox/oauth.py +256 -0
- agent_mailbox/render.py +187 -0
- agent_mailbox/runtime.py +534 -0
- agent_mailbox/service.py +144 -0
- agent_mailbox/ui.py +320 -0
- atgent-0.3.0.dist-info/METADATA +185 -0
- atgent-0.3.0.dist-info/RECORD +17 -0
- atgent-0.3.0.dist-info/WHEEL +5 -0
- atgent-0.3.0.dist-info/entry_points.txt +3 -0
- atgent-0.3.0.dist-info/licenses/LICENSE +21 -0
- atgent-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Claude's native SDK permission channel, backed by one-use email decisions.
|
|
2
|
+
|
|
3
|
+
The wire format mirrors anthropics/claude-agent-sdk-python's internal Query.
|
|
4
|
+
Only a native can_use_tool request can be approved; CLI deny rules and hooks
|
|
5
|
+
still run first. No plugin, MCP server, persistent settings, or replay is used.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import queue
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def claude_email_command(config, job):
|
|
17
|
+
command = list(config["agents"]["claude"]["command"])
|
|
18
|
+
forbidden = (
|
|
19
|
+
"--dangerously-skip-permissions",
|
|
20
|
+
"--allow-dangerously-skip-permissions",
|
|
21
|
+
)
|
|
22
|
+
if any(arg.split("=", 1)[0] in forbidden for arg in command):
|
|
23
|
+
raise ValueError("Email approvals cannot use permission bypass flags")
|
|
24
|
+
command += [
|
|
25
|
+
"-p",
|
|
26
|
+
"--output-format",
|
|
27
|
+
"stream-json",
|
|
28
|
+
"--verbose",
|
|
29
|
+
"--input-format",
|
|
30
|
+
"stream-json",
|
|
31
|
+
"--permission-mode",
|
|
32
|
+
"default",
|
|
33
|
+
"--permission-prompts",
|
|
34
|
+
"host",
|
|
35
|
+
"--permission-prompt-tool",
|
|
36
|
+
"stdio",
|
|
37
|
+
]
|
|
38
|
+
from .core import model_args
|
|
39
|
+
|
|
40
|
+
command += model_args(config, job, "claude")
|
|
41
|
+
if job.get("session"):
|
|
42
|
+
command.append("--resume=" + job["session"])
|
|
43
|
+
return command
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def permission_response(request_id, approved, original_input):
|
|
47
|
+
decision = (
|
|
48
|
+
{"behavior": "allow", "updatedInput": original_input}
|
|
49
|
+
if approved
|
|
50
|
+
else {"behavior": "deny", "message": "Email approval denied or unavailable."}
|
|
51
|
+
)
|
|
52
|
+
return {
|
|
53
|
+
"type": "control_response",
|
|
54
|
+
"response": {
|
|
55
|
+
"subtype": "success",
|
|
56
|
+
"request_id": request_id,
|
|
57
|
+
"response": decision,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_claude(worker, job, prompt, env, deadline):
|
|
63
|
+
"""Run a single turn while keeping stdin open for native permission replies."""
|
|
64
|
+
from .runtime import consume_event, process_start
|
|
65
|
+
|
|
66
|
+
proc = None
|
|
67
|
+
reader = None
|
|
68
|
+
events = queue.Queue()
|
|
69
|
+
pending = {}
|
|
70
|
+
seen = set()
|
|
71
|
+
text = ""
|
|
72
|
+
error = None
|
|
73
|
+
status = "completed"
|
|
74
|
+
initialized = False
|
|
75
|
+
got_result = False
|
|
76
|
+
init_id = "mailbox_initialize"
|
|
77
|
+
init_deadline = min(deadline, time.monotonic() + 120)
|
|
78
|
+
|
|
79
|
+
def read_events():
|
|
80
|
+
try:
|
|
81
|
+
for line in proc.stdout:
|
|
82
|
+
try:
|
|
83
|
+
event = json.loads(line)
|
|
84
|
+
except ValueError:
|
|
85
|
+
continue
|
|
86
|
+
if isinstance(event, dict):
|
|
87
|
+
events.put(event)
|
|
88
|
+
finally:
|
|
89
|
+
events.put(None)
|
|
90
|
+
|
|
91
|
+
def send(value):
|
|
92
|
+
proc.stdin.write(json.dumps(value, ensure_ascii=False) + "\n")
|
|
93
|
+
proc.stdin.flush()
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
with tempfile.TemporaryFile() as err:
|
|
97
|
+
proc = subprocess.Popen(
|
|
98
|
+
claude_email_command(worker.config, job),
|
|
99
|
+
cwd=worker.config["projects"][job["project"]],
|
|
100
|
+
env=env,
|
|
101
|
+
stdin=subprocess.PIPE,
|
|
102
|
+
stdout=subprocess.PIPE,
|
|
103
|
+
stderr=err,
|
|
104
|
+
text=True,
|
|
105
|
+
start_new_session=True,
|
|
106
|
+
)
|
|
107
|
+
worker.store.process(job["id"], proc.pid, process_start(proc.pid))
|
|
108
|
+
reader = threading.Thread(target=read_events, daemon=True)
|
|
109
|
+
reader.start()
|
|
110
|
+
send(
|
|
111
|
+
{
|
|
112
|
+
"type": "control_request",
|
|
113
|
+
"request_id": init_id,
|
|
114
|
+
"request": {"subtype": "initialize", "hooks": None},
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
stream_closed = False
|
|
118
|
+
while not stream_closed:
|
|
119
|
+
if worker.shutdown.is_set() or worker.store.cancelled(
|
|
120
|
+
job["thread"], job["id"]
|
|
121
|
+
):
|
|
122
|
+
status = "interrupted" if worker.shutdown.is_set() else "cancelled"
|
|
123
|
+
break
|
|
124
|
+
now = time.monotonic()
|
|
125
|
+
if now >= deadline or (not initialized and now >= init_deadline):
|
|
126
|
+
status = "timed_out"
|
|
127
|
+
error = (
|
|
128
|
+
"Claude permission channel initialization timed out."
|
|
129
|
+
if not initialized
|
|
130
|
+
else None
|
|
131
|
+
)
|
|
132
|
+
break
|
|
133
|
+
# Consume the durable grant immediately before allowing exactly one
|
|
134
|
+
# matching call. An approved email is not a persistent permission rule.
|
|
135
|
+
for request_id, (token, original_input) in list(pending.items()):
|
|
136
|
+
decision = worker.store.approval_decision(token)
|
|
137
|
+
if decision == "pending":
|
|
138
|
+
continue
|
|
139
|
+
approved = decision == "approved" and worker.store.consume_approval(
|
|
140
|
+
token
|
|
141
|
+
)
|
|
142
|
+
send(permission_response(request_id, approved, original_input))
|
|
143
|
+
del pending[request_id]
|
|
144
|
+
try:
|
|
145
|
+
event = events.get(timeout=0.1)
|
|
146
|
+
except queue.Empty:
|
|
147
|
+
if proc.poll() is not None and not reader.is_alive():
|
|
148
|
+
stream_closed = True
|
|
149
|
+
continue
|
|
150
|
+
if event is None:
|
|
151
|
+
stream_closed = True
|
|
152
|
+
continue
|
|
153
|
+
if event.get("type") == "control_response":
|
|
154
|
+
response = event.get("response", {})
|
|
155
|
+
if response.get("request_id") == init_id:
|
|
156
|
+
if initialized or response.get("subtype") != "success":
|
|
157
|
+
raise ValueError("Invalid Claude initialization response")
|
|
158
|
+
initialized = True
|
|
159
|
+
send(
|
|
160
|
+
{
|
|
161
|
+
"type": "user",
|
|
162
|
+
"session_id": "",
|
|
163
|
+
"parent_tool_use_id": None,
|
|
164
|
+
"message": {"role": "user", "content": prompt},
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
if event.get("type") == "control_cancel_request":
|
|
169
|
+
# CLI abandoned the call. Never send a late allow to it.
|
|
170
|
+
abandoned = pending.pop(event.get("request_id"), None)
|
|
171
|
+
if abandoned:
|
|
172
|
+
worker.store.revoke_approval(abandoned[0])
|
|
173
|
+
continue
|
|
174
|
+
if event.get("type") == "control_request":
|
|
175
|
+
request_id, request = (
|
|
176
|
+
event.get("request_id"),
|
|
177
|
+
event.get("request", {}),
|
|
178
|
+
)
|
|
179
|
+
if (
|
|
180
|
+
not initialized
|
|
181
|
+
or not isinstance(request_id, str)
|
|
182
|
+
or request_id in seen
|
|
183
|
+
):
|
|
184
|
+
raise ValueError(
|
|
185
|
+
"Invalid or duplicate Claude permission request"
|
|
186
|
+
)
|
|
187
|
+
seen.add(request_id)
|
|
188
|
+
if (
|
|
189
|
+
request.get("subtype") != "can_use_tool"
|
|
190
|
+
or not isinstance(request.get("tool_name"), str)
|
|
191
|
+
or not isinstance(request.get("input"), dict)
|
|
192
|
+
):
|
|
193
|
+
send(permission_response(request_id, False, {}))
|
|
194
|
+
continue
|
|
195
|
+
timeout = min(
|
|
196
|
+
worker.config.get("approval_timeout_seconds", 900),
|
|
197
|
+
max(1, int(deadline - time.monotonic())),
|
|
198
|
+
)
|
|
199
|
+
payload = {
|
|
200
|
+
"agent": "claude",
|
|
201
|
+
"request_id": request_id,
|
|
202
|
+
"tool_name": request["tool_name"],
|
|
203
|
+
"input": request["input"],
|
|
204
|
+
"tool_use_id": request.get("tool_use_id"),
|
|
205
|
+
"decision_reason": request.get("decision_reason"),
|
|
206
|
+
"cwd": worker.config["projects"][job["project"]],
|
|
207
|
+
"session": job.get("session"),
|
|
208
|
+
"project": job["project"],
|
|
209
|
+
"native_request": request,
|
|
210
|
+
}
|
|
211
|
+
try:
|
|
212
|
+
token = worker.store.request_approval(job, payload, timeout)
|
|
213
|
+
except ValueError:
|
|
214
|
+
send(permission_response(request_id, False, {}))
|
|
215
|
+
continue
|
|
216
|
+
pending[request_id] = (token, request["input"])
|
|
217
|
+
continue
|
|
218
|
+
session, result, event_error = consume_event(event, "claude")
|
|
219
|
+
if session:
|
|
220
|
+
if job.get("session") and session != job["session"]:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
"Claude resumed a different session; refusing to continue"
|
|
223
|
+
)
|
|
224
|
+
worker.store.session(job["thread"], session)
|
|
225
|
+
if result is not None:
|
|
226
|
+
text = result
|
|
227
|
+
if event_error:
|
|
228
|
+
error = event_error
|
|
229
|
+
if event.get("type") == "result":
|
|
230
|
+
got_result = True
|
|
231
|
+
# One runner job is one turn; stdin otherwise intentionally stays
|
|
232
|
+
# open for the permission host. Closing here ends the CLI cleanly.
|
|
233
|
+
proc.stdin.close()
|
|
234
|
+
if status != "completed":
|
|
235
|
+
worker.terminate(proc)
|
|
236
|
+
else:
|
|
237
|
+
try:
|
|
238
|
+
proc.wait(timeout=10)
|
|
239
|
+
except subprocess.TimeoutExpired:
|
|
240
|
+
worker.terminate(proc)
|
|
241
|
+
status = "failed"
|
|
242
|
+
error = "Claude permission channel did not close after its result."
|
|
243
|
+
if status == "completed" and (
|
|
244
|
+
proc.returncode or error or not got_result or not text
|
|
245
|
+
):
|
|
246
|
+
status = (
|
|
247
|
+
"needs_review"
|
|
248
|
+
if (error or "").startswith("Permission review needed:")
|
|
249
|
+
else "failed"
|
|
250
|
+
)
|
|
251
|
+
return (
|
|
252
|
+
status,
|
|
253
|
+
error
|
|
254
|
+
or text
|
|
255
|
+
or f"Claude exit {proc.returncode}; review locally; no automatic replay.",
|
|
256
|
+
)
|
|
257
|
+
finally:
|
|
258
|
+
try:
|
|
259
|
+
worker.store.revoke_approvals(job["id"])
|
|
260
|
+
finally:
|
|
261
|
+
# Database errors must never leave the child agent running.
|
|
262
|
+
if proc:
|
|
263
|
+
worker.terminate(proc)
|
|
264
|
+
if reader:
|
|
265
|
+
reader.join(timeout=5)
|
|
266
|
+
for stream in (proc.stdin, proc.stdout):
|
|
267
|
+
if stream and not stream.closed:
|
|
268
|
+
stream.close()
|
agent_mailbox/cli.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import fcntl
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .core import MODEL, Store, load_config, pickup_command, resume_spec
|
|
13
|
+
from .runtime import Gmail, Worker, recover_processes
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG = Path.home() / ".config" / "agent-mailbox" / "config.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
os.umask(0o077)
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="atgent", description="Email an idea. Your local coding agent gets to work."
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
|
24
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
25
|
+
init = sub.add_parser(
|
|
26
|
+
"init", help="Create a Gmail configuration (no secrets stored)"
|
|
27
|
+
)
|
|
28
|
+
init.add_argument("--email", help="Skip the guided setup and write a minimal config")
|
|
29
|
+
init.add_argument("--project", type=Path, default=Path.cwd())
|
|
30
|
+
sub.add_parser("run", help="Run intake and worker in this process")
|
|
31
|
+
sub.add_parser(
|
|
32
|
+
"start", help="Start an ordinary detached process; no login/reboot persistence"
|
|
33
|
+
)
|
|
34
|
+
sub.add_parser("shutdown", help="Stop this runner and its active task")
|
|
35
|
+
sub.add_parser(
|
|
36
|
+
"doctor", help="Check mailbox access, agent installation and local queue"
|
|
37
|
+
)
|
|
38
|
+
sub.add_parser("status", help="Show tasks and notification delivery state")
|
|
39
|
+
sub.add_parser(
|
|
40
|
+
"rebaseline", help="Explicitly skip historical mail after a Sent folder reset"
|
|
41
|
+
)
|
|
42
|
+
for action in (
|
|
43
|
+
"install-service",
|
|
44
|
+
"uninstall-service",
|
|
45
|
+
"service-status",
|
|
46
|
+
"oauth-login",
|
|
47
|
+
):
|
|
48
|
+
sub.add_parser(action)
|
|
49
|
+
pickup = sub.add_parser(
|
|
50
|
+
"pickup", help="Print command to resume a local agent session"
|
|
51
|
+
)
|
|
52
|
+
pickup.add_argument("task")
|
|
53
|
+
sub.add_parser("list", help="Pick an email task's session and resume it here")
|
|
54
|
+
resume = sub.add_parser(
|
|
55
|
+
"open", help="Resume a task's agent session in this terminal (picker if no task)"
|
|
56
|
+
)
|
|
57
|
+
resume.add_argument("task", nargs="?")
|
|
58
|
+
model = sub.add_parser("model", help="Set an agent's default model, e.g. model claude opus")
|
|
59
|
+
model.add_argument("agent", choices=("codex", "claude"))
|
|
60
|
+
model.add_argument("model", help="Model name, or 'default' to use the agent's own default")
|
|
61
|
+
args = parser.parse_args()
|
|
62
|
+
args.config = args.config.expanduser().resolve()
|
|
63
|
+
state = args.config.parent / "state"
|
|
64
|
+
if args.action == "init":
|
|
65
|
+
if args.config.exists():
|
|
66
|
+
parser.error("Config already exists; edit it directly")
|
|
67
|
+
if not args.email:
|
|
68
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
69
|
+
parser.error("init without --email needs an interactive terminal")
|
|
70
|
+
from .ui import setup, yes
|
|
71
|
+
|
|
72
|
+
setup(args.config)
|
|
73
|
+
if yes("Check mailbox access and agents now with doctor?"):
|
|
74
|
+
subprocess.run(
|
|
75
|
+
[sys.executable, "-m", "agent_mailbox", "--config", str(args.config), "doctor"]
|
|
76
|
+
)
|
|
77
|
+
return
|
|
78
|
+
args.config.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
79
|
+
local, domain = args.email.split("@", 1)
|
|
80
|
+
config = {
|
|
81
|
+
"transport": "gmail-sent",
|
|
82
|
+
"email": args.email,
|
|
83
|
+
"recipient": f"{local}+agents@{domain}",
|
|
84
|
+
"password_env": "AGENT_MAILBOX_PASSWORD",
|
|
85
|
+
"default_project": "main",
|
|
86
|
+
"projects": {"main": str(args.project.expanduser().resolve())},
|
|
87
|
+
"agents": {
|
|
88
|
+
"codex": {"command": ["codex"]},
|
|
89
|
+
"claude": {"command": ["claude"]},
|
|
90
|
+
},
|
|
91
|
+
"poll_seconds": 15,
|
|
92
|
+
"task_timeout_seconds": 3600,
|
|
93
|
+
"codex_sandbox": "workspace-write",
|
|
94
|
+
}
|
|
95
|
+
args.config.write_text(json.dumps(config, indent=2) + "\n")
|
|
96
|
+
print(
|
|
97
|
+
f"Created {args.config}. Configure your password resolver, then run atgent doctor."
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
if args.action == "model":
|
|
101
|
+
raw = json.loads(args.config.read_text())
|
|
102
|
+
if args.agent not in raw.get("agents", {}):
|
|
103
|
+
parser.error(f"{args.agent} is not configured; run atgent init")
|
|
104
|
+
if args.model == "default":
|
|
105
|
+
raw["agents"][args.agent].pop("model", None)
|
|
106
|
+
elif not MODEL.fullmatch(args.model):
|
|
107
|
+
parser.error("Model names contain only letters, numbers, . _ or -")
|
|
108
|
+
else:
|
|
109
|
+
raw["agents"][args.agent]["model"] = args.model
|
|
110
|
+
args.config.write_text(json.dumps(raw, indent=2) + "\n")
|
|
111
|
+
print(f"{args.agent} default model: {raw['agents'][args.agent].get('model', 'agent default')}")
|
|
112
|
+
return
|
|
113
|
+
config = load_config(args.config)
|
|
114
|
+
state.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
115
|
+
if args.action in ("install-service", "uninstall-service", "service-status"):
|
|
116
|
+
from .service import manage
|
|
117
|
+
|
|
118
|
+
manage(args.action, args.config)
|
|
119
|
+
return
|
|
120
|
+
if args.action == "oauth-login":
|
|
121
|
+
from .oauth import login
|
|
122
|
+
|
|
123
|
+
login(config, state)
|
|
124
|
+
print("Gmail OAuth credential stored")
|
|
125
|
+
return
|
|
126
|
+
logging.basicConfig(
|
|
127
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
|
|
128
|
+
)
|
|
129
|
+
pidfile = state / "runner.pid"
|
|
130
|
+
lockfile = state / "runner.lock"
|
|
131
|
+
if args.action == "start":
|
|
132
|
+
# The child acquires the lock before touching the queue. Check first for friendly output.
|
|
133
|
+
with lockfile.open("a") as lock:
|
|
134
|
+
try:
|
|
135
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
136
|
+
except BlockingIOError:
|
|
137
|
+
parser.error("Runner is already active")
|
|
138
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
139
|
+
with (state / "runner.log").open("ab") as log:
|
|
140
|
+
proc = subprocess.Popen(
|
|
141
|
+
[
|
|
142
|
+
sys.executable,
|
|
143
|
+
"-m",
|
|
144
|
+
"agent_mailbox",
|
|
145
|
+
"--config",
|
|
146
|
+
str(args.config),
|
|
147
|
+
"run",
|
|
148
|
+
],
|
|
149
|
+
stdin=subprocess.DEVNULL,
|
|
150
|
+
stdout=log,
|
|
151
|
+
stderr=log,
|
|
152
|
+
start_new_session=True,
|
|
153
|
+
)
|
|
154
|
+
print(f"Started process {proc.pid}. Log: {state / 'runner.log'}")
|
|
155
|
+
return
|
|
156
|
+
store = Store(state / "queue.sqlite3")
|
|
157
|
+
if args.action == "shutdown":
|
|
158
|
+
# Signal only while our held advisory lock proves a runner owns this PID file.
|
|
159
|
+
with lockfile.open("a") as lock:
|
|
160
|
+
try:
|
|
161
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
162
|
+
except BlockingIOError:
|
|
163
|
+
os.kill(int(pidfile.read_text()), signal.SIGTERM)
|
|
164
|
+
print("Shutdown requested")
|
|
165
|
+
return
|
|
166
|
+
print("Runner is not active")
|
|
167
|
+
return
|
|
168
|
+
if args.action == "status":
|
|
169
|
+
print(
|
|
170
|
+
json.dumps(
|
|
171
|
+
{
|
|
172
|
+
"jobs": store.rows(
|
|
173
|
+
"SELECT id,thread,status,created FROM jobs ORDER BY id DESC LIMIT 30"
|
|
174
|
+
),
|
|
175
|
+
"approvals": store.rows(
|
|
176
|
+
"SELECT job,thread,status,expires FROM approvals WHERE status='pending' ORDER BY expires"
|
|
177
|
+
),
|
|
178
|
+
"notifications": store.rows(
|
|
179
|
+
"SELECT status,COUNT(*) AS count FROM outbox GROUP BY status"
|
|
180
|
+
),
|
|
181
|
+
},
|
|
182
|
+
indent=2,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
if args.action == "pickup":
|
|
187
|
+
rows = store.rows("SELECT * FROM threads WHERE id=?", (args.task,))
|
|
188
|
+
if not rows or not rows[0]["session"]:
|
|
189
|
+
parser.error("No resumable session for this task")
|
|
190
|
+
if store.rows(
|
|
191
|
+
"SELECT 1 FROM jobs WHERE thread=? AND status IN ('queued','running','stopping')",
|
|
192
|
+
(args.task,),
|
|
193
|
+
):
|
|
194
|
+
parser.error("Stop the email task before picking it up locally")
|
|
195
|
+
command = pickup_command(config, rows[0])
|
|
196
|
+
if not command:
|
|
197
|
+
parser.error("This task's project or agent is no longer configured")
|
|
198
|
+
print(command)
|
|
199
|
+
return
|
|
200
|
+
if args.action in ("list", "open"):
|
|
201
|
+
from .ui import pick, plain_lines, rows_for, yes
|
|
202
|
+
|
|
203
|
+
threads = store.rows(
|
|
204
|
+
"SELECT t.*, (SELECT status FROM jobs WHERE thread=t.id ORDER BY id DESC LIMIT 1) AS last,"
|
|
205
|
+
" (SELECT result FROM jobs WHERE thread=t.id AND result IS NOT NULL ORDER BY id DESC LIMIT 1) AS result,"
|
|
206
|
+
" (SELECT MAX(created) FROM jobs WHERE thread=t.id) AS updated"
|
|
207
|
+
" FROM threads t WHERE t.session IS NOT NULL ORDER BY updated DESC LIMIT 60"
|
|
208
|
+
)
|
|
209
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
210
|
+
task = args.task if args.action == "open" else None
|
|
211
|
+
if not task:
|
|
212
|
+
if not threads:
|
|
213
|
+
print("No resumable email tasks yet")
|
|
214
|
+
return
|
|
215
|
+
rows = rows_for(config, threads)
|
|
216
|
+
if not interactive:
|
|
217
|
+
print("\n".join(plain_lines(rows)))
|
|
218
|
+
return
|
|
219
|
+
chosen = pick(rows)
|
|
220
|
+
if chosen is None:
|
|
221
|
+
return
|
|
222
|
+
task = chosen["id"]
|
|
223
|
+
found = [t for t in threads if t["id"] == task] or store.rows(
|
|
224
|
+
"SELECT * FROM threads WHERE id=?", (task,)
|
|
225
|
+
)
|
|
226
|
+
if not found or not found[0]["session"]:
|
|
227
|
+
parser.error("No resumable session for this task")
|
|
228
|
+
if store.active(task):
|
|
229
|
+
if not interactive or not yes(
|
|
230
|
+
f"Task {task} is still running from email. Stop the email task and open it here?",
|
|
231
|
+
default=False,
|
|
232
|
+
):
|
|
233
|
+
parser.error("Still running from email. Reply stop in its thread, or say yes here")
|
|
234
|
+
print("Stopping the email task...")
|
|
235
|
+
if not store.stop_and_wait(task):
|
|
236
|
+
parser.error("The runner has not released the session yet; try again in a minute")
|
|
237
|
+
spec = resume_spec(config, found[0], terminal=True)
|
|
238
|
+
if not spec:
|
|
239
|
+
parser.error("This task's project or agent is no longer configured")
|
|
240
|
+
cwd, argv, env = spec
|
|
241
|
+
os.chdir(cwd)
|
|
242
|
+
os.execvpe(argv[0], argv, {**os.environ, **env})
|
|
243
|
+
transport = Gmail(config, store)
|
|
244
|
+
if args.action == "doctor":
|
|
245
|
+
mail = transport.connect()
|
|
246
|
+
mail.logout()
|
|
247
|
+
print("Gmail authenticated; trusted Sent folder available")
|
|
248
|
+
import smtplib
|
|
249
|
+
import ssl
|
|
250
|
+
|
|
251
|
+
with smtplib.SMTP_SSL(
|
|
252
|
+
"smtp.gmail.com", 465, timeout=30, context=ssl.create_default_context()
|
|
253
|
+
) as smtp:
|
|
254
|
+
transport.smtp_auth(smtp)
|
|
255
|
+
print("SMTP authenticated; no email sent")
|
|
256
|
+
for agent, spec in config["agents"].items():
|
|
257
|
+
result = subprocess.run(
|
|
258
|
+
spec["command"] + ["--version"],
|
|
259
|
+
capture_output=True,
|
|
260
|
+
text=True,
|
|
261
|
+
timeout=20,
|
|
262
|
+
env={**os.environ, **spec.get("env", {})},
|
|
263
|
+
)
|
|
264
|
+
print(
|
|
265
|
+
f"{agent}: {'installed' if result.returncode == 0 else 'unavailable'}"
|
|
266
|
+
)
|
|
267
|
+
print(f"Send to {config['recipient']}; subject: Codex: your task")
|
|
268
|
+
return
|
|
269
|
+
if args.action == "rebaseline":
|
|
270
|
+
# Never reset the watermark under a running intake process.
|
|
271
|
+
with lockfile.open("a") as lock:
|
|
272
|
+
try:
|
|
273
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
274
|
+
except BlockingIOError:
|
|
275
|
+
parser.error("Shut down the runner before rebaselining")
|
|
276
|
+
with store.transaction() as db:
|
|
277
|
+
db.execute("DELETE FROM meta WHERE key IN ('uidvalidity','last_uid')")
|
|
278
|
+
transport.poll()
|
|
279
|
+
print("Historical mail skipped; new-message intake armed")
|
|
280
|
+
return
|
|
281
|
+
with lockfile.open("a") as lock:
|
|
282
|
+
try:
|
|
283
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
284
|
+
except BlockingIOError:
|
|
285
|
+
parser.error("Runner is already active")
|
|
286
|
+
pidfile.write_text(str(os.getpid()))
|
|
287
|
+
shutdown = threading.Event()
|
|
288
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
289
|
+
signal.signal(sig, lambda *_: shutdown.set())
|
|
290
|
+
recover_processes(store)
|
|
291
|
+
store.recover()
|
|
292
|
+
# Arm intake before workers can execute anything.
|
|
293
|
+
transport.poll()
|
|
294
|
+
workers = [
|
|
295
|
+
threading.Thread(target=Worker(config, store, shutdown).run, daemon=True)
|
|
296
|
+
for _ in range(config.get("max_workers", 1))
|
|
297
|
+
]
|
|
298
|
+
for worker in workers:
|
|
299
|
+
worker.start()
|
|
300
|
+
try:
|
|
301
|
+
while not shutdown.is_set():
|
|
302
|
+
try:
|
|
303
|
+
transport.poll()
|
|
304
|
+
transport.flush()
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
# Errors may include auth material. Log only the class.
|
|
307
|
+
logging.error(
|
|
308
|
+
"Mail cycle failed (%s); will reconnect", type(exc).__name__
|
|
309
|
+
)
|
|
310
|
+
shutdown.wait(config.get("poll_seconds", 15))
|
|
311
|
+
finally:
|
|
312
|
+
shutdown.set()
|
|
313
|
+
for worker in workers:
|
|
314
|
+
worker.join(timeout=20)
|
|
315
|
+
pidfile.unlink(missing_ok=True)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
main()
|