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.
@@ -0,0 +1,3 @@
1
+ """atgent: email to local coding agents."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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()