delimit-cli 3.14.43 → 3.14.44

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.
@@ -1,684 +0,0 @@
1
- """
2
- Inbox polling daemon for Delimit's email governance system.
3
-
4
- Polls pro@delimit.ai via IMAP every 5 minutes, auto-classifies emails,
5
- forwards owner-action items, and handles draft approval via email replies.
6
-
7
- Consensus 116: Standalone daemon, fresh IMAP connections.
8
- Auto-posting DISABLED — all approved drafts are emailed for manual posting.
9
-
10
- Can run as:
11
- - Standalone script: python inbox_daemon.py
12
- - MCP tool: delimit_inbox_daemon(action="start"|"stop"|"status")
13
- """
14
-
15
- import json
16
- import logging
17
- import os
18
- import re
19
- import threading
20
- import time
21
- from datetime import datetime, timezone
22
- from pathlib import Path
23
- from typing import Any, Dict, List, Optional
24
-
25
- logger = logging.getLogger("delimit.ai.inbox_daemon")
26
-
27
- # ── Configuration ────────────────────────────────────────────────────
28
- POLL_INTERVAL = int(os.environ.get("DELIMIT_INBOX_POLL_INTERVAL", "300")) # seconds
29
- CANCEL_WINDOW = 600 # 10 minutes in seconds
30
- MAX_CONSECUTIVE_FAILURES = 3
31
-
32
- ALERTS_DIR = Path.home() / ".delimit" / "alerts"
33
- ALERT_FILE = ALERTS_DIR / "inbox_daemon.json"
34
- ROUTING_LOG = Path.home() / ".delimit" / "inbox_routing.jsonl"
35
-
36
- # Approval keywords (case-insensitive, must be standalone words)
37
- APPROVAL_KEYWORDS = [
38
- "approved",
39
- "approve",
40
- "yes",
41
- "go ahead",
42
- "lgtm",
43
- "looks good",
44
- "ship it",
45
- "post it",
46
- ]
47
-
48
- CANCEL_KEYWORDS = [
49
- "cancel",
50
- "stop",
51
- "abort",
52
- "don't post",
53
- "do not post",
54
- "hold",
55
- ]
56
-
57
- # Regex to extract 12-char hex draft ID from subject lines
58
- DRAFT_ID_PATTERN = re.compile(r"\b([0-9a-f]{12})\b", re.IGNORECASE)
59
-
60
-
61
- # ── Daemon State ─────────────────────────────────────────────────────
62
-
63
- class InboxDaemonState:
64
- """Thread-safe state for the inbox polling daemon."""
65
-
66
- def __init__(self):
67
- self.running = False
68
- self.last_poll: Optional[str] = None
69
- self.items_processed: int = 0
70
- self.items_forwarded: int = 0
71
- self.approvals_detected: int = 0
72
- self.consecutive_failures: int = 0
73
- self.total_polls: int = 0
74
- self.stopped_reason: Optional[str] = None
75
- self._lock = threading.Lock()
76
- self._thread: Optional[threading.Thread] = None
77
- self._stop_event = threading.Event()
78
- # Pending approvals: draft_id -> {approved_at, message_id, draft_record}
79
- self._pending_approvals: Dict[str, Dict[str, Any]] = {}
80
-
81
- def to_dict(self) -> Dict[str, Any]:
82
- with self._lock:
83
- return {
84
- "running": self.running,
85
- "last_poll": self.last_poll,
86
- "items_processed": self.items_processed,
87
- "items_forwarded": self.items_forwarded,
88
- "approvals_detected": self.approvals_detected,
89
- "consecutive_failures": self.consecutive_failures,
90
- "total_polls": self.total_polls,
91
- "stopped_reason": self.stopped_reason,
92
- "pending_approvals": list(self._pending_approvals.keys()),
93
- "poll_interval_seconds": POLL_INTERVAL,
94
- }
95
-
96
- def record_success(self, processed: int, forwarded: int):
97
- with self._lock:
98
- self.consecutive_failures = 0
99
- self.items_processed += processed
100
- self.items_forwarded += forwarded
101
- self.total_polls += 1
102
- self.last_poll = datetime.now(timezone.utc).isoformat()
103
-
104
- def record_failure(self) -> int:
105
- with self._lock:
106
- self.consecutive_failures += 1
107
- self.total_polls += 1
108
- self.last_poll = datetime.now(timezone.utc).isoformat()
109
- return self.consecutive_failures
110
-
111
- def add_pending_approval(self, draft_id: str, info: Dict[str, Any]):
112
- with self._lock:
113
- self._pending_approvals[draft_id] = info
114
- self.approvals_detected += 1
115
-
116
- def remove_pending_approval(self, draft_id: str) -> Optional[Dict[str, Any]]:
117
- with self._lock:
118
- return self._pending_approvals.pop(draft_id, None)
119
-
120
- def get_pending_approvals(self) -> Dict[str, Dict[str, Any]]:
121
- with self._lock:
122
- return dict(self._pending_approvals)
123
-
124
-
125
- # Singleton state
126
- _daemon_state = InboxDaemonState()
127
-
128
-
129
- # ── Logging ──────────────────────────────────────────────────────────
130
-
131
- def get_pending_directives() -> List[Dict]:
132
- """Get founder directives that haven't been completed yet."""
133
- directives = []
134
- if not ROUTING_LOG.exists():
135
- return directives
136
- completed = set()
137
- for line in ROUTING_LOG.read_text().splitlines():
138
- try:
139
- entry = json.loads(line)
140
- if entry.get("event") == "founder_directive_completed":
141
- completed.add(entry.get("directive_subject", ""))
142
- elif entry.get("event") == "founder_directive_received":
143
- directives.append(entry)
144
- except (json.JSONDecodeError, KeyError):
145
- continue
146
- return [d for d in directives if d.get("subject", "") not in completed]
147
-
148
-
149
- def complete_directive(subject: str, result: str) -> None:
150
- """Mark a founder directive as completed and send confirmation email."""
151
- _log_routing({
152
- "event": "founder_directive_completed",
153
- "directive_subject": subject,
154
- "result": result,
155
- })
156
- send_email(
157
- to=FORWARD_TO,
158
- subject=f"[COMPLETED] {subject}",
159
- body=(
160
- f"Your directive has been completed.\n\n"
161
- f"Subject: {subject}\n"
162
- f"Result: {result}\n"
163
- ),
164
- from_account="pro@delimit.ai",
165
- event_type="founder_directive_completed",
166
- )
167
-
168
-
169
- def _log_routing(entry: Dict[str, Any]) -> None:
170
- """Append a routing decision to the audit trail."""
171
- try:
172
- ROUTING_LOG.parent.mkdir(parents=True, exist_ok=True)
173
- entry["daemon"] = True
174
- entry["timestamp"] = datetime.now(timezone.utc).isoformat()
175
- with open(ROUTING_LOG, "a", encoding="utf-8") as f:
176
- f.write(json.dumps(entry) + "\n")
177
- except OSError as e:
178
- logger.warning("Failed to write routing log: %s", e)
179
-
180
-
181
- def _write_alert(reason: str, failure_count: int) -> None:
182
- """Write an alert file for SessionStart to pick up."""
183
- try:
184
- ALERTS_DIR.mkdir(parents=True, exist_ok=True)
185
- alert = {
186
- "alert": "inbox_daemon_stopped",
187
- "reason": reason,
188
- "failure_count": failure_count,
189
- "timestamp": datetime.now(timezone.utc).isoformat(),
190
- }
191
- with open(ALERT_FILE, "w", encoding="utf-8") as f:
192
- json.dump(alert, f, indent=2)
193
- logger.error("Inbox daemon alert written: %s", reason)
194
- except OSError as e:
195
- logger.error("Failed to write alert file: %s", e)
196
-
197
-
198
- # ── Approval Detection ───────────────────────────────────────────────
199
-
200
- def detect_approval_keywords(text: str) -> bool:
201
- """Check if text contains approval keywords."""
202
- if not text:
203
- return False
204
- text_lower = text.lower().strip()
205
- for keyword in APPROVAL_KEYWORDS:
206
- if keyword in text_lower:
207
- return True
208
- return False
209
-
210
-
211
- def detect_cancel_keywords(text: str) -> bool:
212
- """Check if text contains cancellation keywords."""
213
- if not text:
214
- return False
215
- text_lower = text.lower().strip()
216
- for keyword in CANCEL_KEYWORDS:
217
- if keyword in text_lower:
218
- return True
219
- return False
220
-
221
-
222
- def _get_all_drafts() -> list:
223
- """Load all drafts via ai.social module. Separate function for clean mocking."""
224
- import ai.social
225
- return ai.social._load_all_drafts()
226
-
227
-
228
- def _approve_draft(draft_id: str) -> dict:
229
- """Approve and post a draft via ai.social module. Separate function for clean mocking."""
230
- import ai.social
231
- return ai.social.approve_draft(draft_id)
232
-
233
-
234
- def match_draft_by_headers(
235
- in_reply_to: str,
236
- references: str,
237
- subject: str,
238
- ) -> Optional[str]:
239
- """Match an inbound email to a draft via headers or subject line.
240
-
241
- Primary: In-Reply-To / References header matching against stored Message-IDs.
242
- Fallback: Extract 12-char hex draft ID from subject line.
243
-
244
- Returns draft_id if matched, None otherwise.
245
- """
246
- all_drafts = _get_all_drafts()
247
-
248
- # Primary: match In-Reply-To or References against stored notification_message_id
249
- if in_reply_to or references:
250
- header_ids = set()
251
- if in_reply_to:
252
- header_ids.add(in_reply_to.strip().strip("<>"))
253
- if references:
254
- for ref in references.split():
255
- header_ids.add(ref.strip().strip("<>"))
256
-
257
- for draft in all_drafts:
258
- stored_mid = draft.get("notification_message_id", "")
259
- if stored_mid and stored_mid.strip("<>") in header_ids:
260
- if draft.get("status") in ("pending", "approved-pending"):
261
- return draft.get("draft_id")
262
-
263
- # Fallback: extract draft ID from subject
264
- # Strip Re:/Fwd: prefixes
265
- clean_subject = re.sub(r"^(Re|Fwd|Fw)\s*:\s*", "", subject, flags=re.IGNORECASE).strip()
266
- match = DRAFT_ID_PATTERN.search(clean_subject)
267
- if match:
268
- candidate_id = match.group(1).lower()
269
- for draft in all_drafts:
270
- if draft.get("draft_id") == candidate_id:
271
- if draft.get("status") in ("pending", "approved-pending"):
272
- return candidate_id
273
-
274
- return None
275
-
276
-
277
- def mark_draft_status(draft_id: str, status: str) -> bool:
278
- """Update a draft's status without posting it.
279
-
280
- Used for approved-pending, cancelled states.
281
- """
282
- import ai.social
283
-
284
- all_drafts = ai.social._load_all_drafts()
285
- for draft in all_drafts:
286
- if draft.get("draft_id") == draft_id:
287
- draft["status"] = status
288
- draft[f"{status.replace('-', '_')}_at"] = datetime.now(timezone.utc).isoformat()
289
- ai.social._rewrite_drafts(all_drafts)
290
- return True
291
- return False
292
-
293
-
294
- # ── Email Body Extraction ────────────────────────────────────────────
295
-
296
- def _extract_body(msg) -> str:
297
- """Extract plain text body from an email message."""
298
- import email as _email
299
-
300
- if msg.is_multipart():
301
- for part in msg.walk():
302
- if part.get_content_type() == "text/plain":
303
- payload = part.get_payload(decode=True)
304
- if payload:
305
- return payload.decode("utf-8", errors="replace")
306
- else:
307
- payload = msg.get_payload(decode=True)
308
- if payload:
309
- return payload.decode("utf-8", errors="replace")
310
- return ""
311
-
312
-
313
- # ── Core Polling Logic ───────────────────────────────────────────────
314
-
315
- def poll_once() -> Dict[str, Any]:
316
- """Execute a single poll cycle.
317
-
318
- 1. Connect to IMAP, fetch unseen messages
319
- 2. Classify each message
320
- 3. Check for approval/cancel replies
321
- 4. Forward owner-action emails
322
- 5. Process pending approval windows
323
-
324
- Returns summary of actions taken.
325
- """
326
- import email as _email
327
- import email.header
328
- import email.utils
329
- import imaplib
330
-
331
- from ai.notify import (
332
- IMAP_HOST, IMAP_PORT, IMAP_USER, FORWARD_TO,
333
- classify_email, _extract_sender_email, _decode_header,
334
- _forward_email, send_email, _record_inbox_routing,
335
- )
336
-
337
- smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
338
- if not smtp_pass:
339
- return {"error": "DELIMIT_SMTP_PASS not set"}
340
-
341
- # Fresh IMAP connection each poll (consensus: no connection pooling)
342
- try:
343
- imap = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
344
- imap.login(IMAP_USER, smtp_pass)
345
- except Exception as e:
346
- failures = _daemon_state.record_failure()
347
- _log_routing({"event": "imap_failure", "error": str(e), "consecutive": failures})
348
- if failures >= MAX_CONSECUTIVE_FAILURES:
349
- reason = f"3 consecutive IMAP failures. Last: {e}"
350
- _daemon_state.stopped_reason = reason
351
- _daemon_state.running = False
352
- _daemon_state._stop_event.set()
353
- _write_alert(reason, failures)
354
- return {"error": f"IMAP connection failed: {e}", "consecutive_failures": failures}
355
-
356
- try:
357
- imap.select("INBOX")
358
- _, unseen_data = imap.search(None, "UNSEEN")
359
- unseen_ids = unseen_data[0].split() if unseen_data[0] else []
360
-
361
- processed = 0
362
- forwarded = 0
363
- approvals = []
364
- cancels = []
365
-
366
- for msg_id in unseen_ids[-20:]: # Cap at 20 per cycle
367
- _, data = imap.fetch(msg_id, "(BODY.PEEK[])")
368
- if not data or not data[0]:
369
- continue
370
-
371
- raw_email = data[0][1]
372
- msg = _email.message_from_bytes(raw_email)
373
-
374
- from_header = _decode_header(msg.get("From", ""))
375
- subject = _decode_header(msg.get("Subject", ""))
376
- sender_addr = _extract_sender_email(from_header)
377
- in_reply_to = msg.get("In-Reply-To", "")
378
- references = msg.get("References", "")
379
- body_text = _extract_body(msg)
380
-
381
- classification = classify_email(sender_addr, subject, from_header)
382
-
383
- # Check for approval/cancel replies
384
- draft_id = match_draft_by_headers(in_reply_to, references, subject)
385
-
386
- action_taken = "classified"
387
-
388
- if draft_id and detect_cancel_keywords(body_text):
389
- # Cancel a pending approval
390
- pending = _daemon_state.remove_pending_approval(draft_id)
391
- if pending:
392
- mark_draft_status(draft_id, "cancelled")
393
- send_email(
394
- to=FORWARD_TO,
395
- subject=f"Draft {draft_id} CANCELLED",
396
- body=f"Draft {draft_id} was cancelled via email reply. It will NOT be posted.",
397
- from_account="pro@delimit.ai",
398
- event_type="draft_cancelled",
399
- )
400
- action_taken = "cancel_received"
401
- cancels.append(draft_id)
402
- else:
403
- action_taken = "cancel_no_pending"
404
- # Mark as seen
405
- imap.store(msg_id, "+FLAGS", "\\Seen")
406
-
407
- elif draft_id and detect_approval_keywords(body_text):
408
- # Both draft match AND approval keyword required (consensus)
409
- # Auto-posting is DISABLED — mark approved and confirm via email
410
- mark_draft_status(draft_id, "approved")
411
- send_email(
412
- to=FORWARD_TO,
413
- subject=f"Draft {draft_id} approved - ready for manual posting",
414
- body=(
415
- f"Draft {draft_id} has been approved via email reply.\n\n"
416
- f"Auto-posting is disabled. Please post manually.\n\n"
417
- f"The draft text was included in the original notification email."
418
- ),
419
- from_account="pro@delimit.ai",
420
- event_type="draft_approval_confirmed",
421
- )
422
- action_taken = "approval_detected"
423
- approvals.append(draft_id)
424
- # Mark as seen
425
- imap.store(msg_id, "+FLAGS", "\\Seen")
426
-
427
- elif classification == "owner-action":
428
- # Emails FROM the founder are decisions/action items — acknowledge and track
429
- if sender_addr.lower() == FORWARD_TO.lower():
430
- # Create a ledger item to track the founder's directive
431
- _directive_summary = subject[:80] if subject else body_text[:80]
432
- _log_routing({
433
- "event": "founder_directive_received",
434
- "subject": subject,
435
- "body_preview": body_text[:200],
436
- "timestamp": datetime.now(timezone.utc).isoformat(),
437
- })
438
- # Acknowledge receipt
439
- send_email(
440
- to=FORWARD_TO,
441
- subject=f"[RECEIVED] {subject}",
442
- body=(
443
- f"Your directive has been received and logged.\n\n"
444
- f"Subject: {subject}\n"
445
- f"Status: PENDING — will be processed in the next loop iteration.\n\n"
446
- f"You'll receive a [COMPLETED] email when this is done."
447
- ),
448
- from_account="pro@delimit.ai",
449
- event_type="founder_directive_ack",
450
- )
451
- imap.store(msg_id, "+FLAGS", "\\Seen")
452
- action_taken = "founder_directive_acked"
453
- else:
454
- success = _forward_email(msg, smtp_pass)
455
- if success:
456
- imap.store(msg_id, "+FLAGS", "\\Seen")
457
- forwarded += 1
458
- action_taken = "forwarded" if success else "forward_failed"
459
-
460
- else:
461
- # Non-owner, mark as seen
462
- imap.store(msg_id, "+FLAGS", "\\Seen")
463
- action_taken = "non_owner_archived"
464
-
465
- processed += 1
466
- _log_routing({
467
- "event": "email_processed",
468
- "from": from_header,
469
- "sender": sender_addr,
470
- "subject": subject,
471
- "classification": classification,
472
- "draft_match": draft_id,
473
- "action": action_taken,
474
- })
475
-
476
- imap.logout()
477
-
478
- # Auto-posting disabled — no cancel window processing
479
- posted_drafts = []
480
-
481
- _daemon_state.record_success(processed, forwarded)
482
-
483
- return {
484
- "processed": processed,
485
- "forwarded": forwarded,
486
- "approvals_detected": approvals,
487
- "cancels_received": cancels,
488
- "drafts_posted": posted_drafts,
489
- "unseen_count": len(unseen_ids),
490
- }
491
-
492
- except Exception as e:
493
- try:
494
- imap.logout()
495
- except Exception:
496
- pass
497
- failures = _daemon_state.record_failure()
498
- _log_routing({"event": "poll_error", "error": str(e), "consecutive": failures})
499
- return {"error": f"Poll failed: {e}", "consecutive_failures": failures}
500
-
501
-
502
- def _process_pending_approvals() -> List[str]:
503
- """Check pending approvals and post any past the cancel window."""
504
-
505
- posted = []
506
- now = time.time()
507
- pending = _daemon_state.get_pending_approvals()
508
-
509
- for draft_id, info in pending.items():
510
- approved_ts = info.get("approved_at_ts", 0)
511
- if now - approved_ts >= CANCEL_WINDOW:
512
- # Cancel window expired, post it
513
- _daemon_state.remove_pending_approval(draft_id)
514
- # Update status to approved so approve_draft can post it
515
- mark_draft_status(draft_id, "pending") # Reset to pending for approve_draft
516
- result = _approve_draft(draft_id)
517
- if "error" not in result:
518
- posted.append(draft_id)
519
- _log_routing({
520
- "event": "draft_auto_posted",
521
- "draft_id": draft_id,
522
- "post_result": result,
523
- })
524
- else:
525
- _log_routing({
526
- "event": "draft_post_failed",
527
- "draft_id": draft_id,
528
- "error": result.get("error"),
529
- })
530
-
531
- return posted
532
-
533
-
534
- # ── Daemon Loop ──────────────────────────────────────────────────────
535
-
536
- def _daemon_loop() -> None:
537
- """Main polling loop. Runs until stop event is set."""
538
- logger.info("Inbox daemon started. Polling every %d seconds.", POLL_INTERVAL)
539
- _log_routing({"event": "daemon_started", "poll_interval": POLL_INTERVAL})
540
-
541
- while not _daemon_state._stop_event.is_set():
542
- try:
543
- result = poll_once()
544
- if "error" in result:
545
- logger.warning("Poll error: %s", result["error"])
546
- else:
547
- logger.info(
548
- "Poll complete: %d processed, %d forwarded",
549
- result.get("processed", 0),
550
- result.get("forwarded", 0),
551
- )
552
- except Exception as e:
553
- logger.error("Unexpected error in daemon loop: %s", e)
554
- failures = _daemon_state.record_failure()
555
- if failures >= MAX_CONSECUTIVE_FAILURES:
556
- reason = f"3 consecutive failures in loop. Last: {e}"
557
- _daemon_state.stopped_reason = reason
558
- _write_alert(reason, failures)
559
- break
560
-
561
- # Wait for stop event or poll interval
562
- _daemon_state._stop_event.wait(timeout=POLL_INTERVAL)
563
-
564
- _daemon_state.running = False
565
- _log_routing({"event": "daemon_stopped", "reason": _daemon_state.stopped_reason or "manual_stop"})
566
- logger.info("Inbox daemon stopped.")
567
-
568
-
569
- def start_daemon() -> Dict[str, Any]:
570
- """Start the inbox polling daemon in a background thread."""
571
- if _daemon_state.running:
572
- return {"status": "already_running", **_daemon_state.to_dict()}
573
-
574
- _daemon_state.running = True
575
- _daemon_state.stopped_reason = None
576
- _daemon_state.consecutive_failures = 0
577
- _daemon_state._stop_event.clear()
578
-
579
- thread = threading.Thread(target=_daemon_loop, name="inbox-daemon", daemon=True)
580
- _daemon_state._thread = thread
581
- thread.start()
582
-
583
- return {"status": "started", **_daemon_state.to_dict()}
584
-
585
-
586
- def stop_daemon() -> Dict[str, Any]:
587
- """Stop the inbox polling daemon."""
588
- if not _daemon_state.running:
589
- return {"status": "not_running", **_daemon_state.to_dict()}
590
-
591
- _daemon_state._stop_event.set()
592
- _daemon_state.stopped_reason = "manual_stop"
593
- # Give the thread a moment to finish
594
- if _daemon_state._thread:
595
- _daemon_state._thread.join(timeout=5)
596
- _daemon_state.running = False
597
-
598
- return {"status": "stopped", **_daemon_state.to_dict()}
599
-
600
-
601
- def get_daemon_status() -> Dict[str, Any]:
602
- """Get current daemon status."""
603
- result = _daemon_state.to_dict()
604
-
605
- # Check for alert file
606
- if ALERT_FILE.exists():
607
- try:
608
- with open(ALERT_FILE, "r") as f:
609
- result["alert"] = json.load(f)
610
- except (OSError, json.JSONDecodeError):
611
- pass
612
-
613
- return result
614
-
615
-
616
- # ── Standalone Entry Point ───────────────────────────────────────────
617
-
618
- def main():
619
- """Run the daemon as a standalone process (for systemd).
620
-
621
- Supports argparse flags:
622
- --interval N Override poll interval in seconds (default: DELIMIT_INBOX_POLL_INTERVAL or 300)
623
- --once Run a single poll cycle and exit (useful for cron or testing)
624
- """
625
- import argparse
626
-
627
- parser = argparse.ArgumentParser(
628
- description="Delimit inbox polling daemon — email governance for pro@delimit.ai",
629
- )
630
- parser.add_argument(
631
- "--interval",
632
- type=int,
633
- default=None,
634
- help="Poll interval in seconds (default: DELIMIT_INBOX_POLL_INTERVAL env or 300)",
635
- )
636
- parser.add_argument(
637
- "--once",
638
- action="store_true",
639
- help="Run a single poll cycle and exit",
640
- )
641
- args = parser.parse_args()
642
-
643
- logging.basicConfig(
644
- level=logging.INFO,
645
- format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
646
- )
647
-
648
- if not os.environ.get("DELIMIT_SMTP_PASS"):
649
- logger.error("DELIMIT_SMTP_PASS environment variable is required.")
650
- raise SystemExit(1)
651
-
652
- # Override poll interval if --interval flag provided
653
- global POLL_INTERVAL
654
- if args.interval is not None:
655
- POLL_INTERVAL = args.interval
656
- logger.info("Poll interval overridden to %d seconds via --interval flag.", POLL_INTERVAL)
657
-
658
- if args.once:
659
- logger.info("Running single poll cycle (--once mode)")
660
- result = poll_once()
661
- if "error" in result:
662
- logger.error("Poll failed: %s", result["error"])
663
- raise SystemExit(1)
664
- logger.info(
665
- "Poll complete: %d processed, %d forwarded",
666
- result.get("processed", 0),
667
- result.get("forwarded", 0),
668
- )
669
- return
670
-
671
- logger.info("Starting Delimit inbox polling daemon (standalone mode)")
672
- _daemon_state.running = True
673
- _daemon_state._stop_event.clear()
674
-
675
- try:
676
- _daemon_loop()
677
- except KeyboardInterrupt:
678
- logger.info("Interrupted. Shutting down.")
679
- _daemon_state._stop_event.set()
680
- _daemon_state.running = False
681
-
682
-
683
- if __name__ == "__main__":
684
- main()