auntui 0.2.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.
aun_daemon.py ADDED
@@ -0,0 +1,709 @@
1
+ #!/usr/bin/env python3
2
+ """AUN Daemon (MVP v0.1) — headless JSON-RPC bridge for AUN Mac App.
3
+
4
+ Reads NDJSON requests from stdin, writes NDJSON responses + events to stdout.
5
+ Runtime logs go to stderr. Protocol specified in ./protocol.md.
6
+
7
+ Usage:
8
+ python3 aun_daemon.py [--data-dir ~/.aun]
9
+
10
+ Environment:
11
+ AUN_CLI_DATA data root dir (default ~/.aun), same semantics as auntui.py
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import json
19
+ import logging
20
+ import os
21
+ import re
22
+ import signal
23
+ import sqlite3
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Any, Optional
28
+
29
+ # force utf-8 stdio
30
+ if hasattr(sys.stdout, "reconfigure"):
31
+ sys.stdout.reconfigure(encoding="utf-8")
32
+ if hasattr(sys.stderr, "reconfigure"):
33
+ sys.stderr.reconfigure(encoding="utf-8")
34
+
35
+ logging.basicConfig(
36
+ level=os.environ.get("AUN_DAEMON_LOG", "INFO").upper(),
37
+ format="[daemon] %(asctime)s %(levelname)s %(message)s",
38
+ stream=sys.stderr,
39
+ )
40
+ log = logging.getLogger("aun_daemon")
41
+
42
+ # ── Paths & config ──────────────────────────────────────────────────────
43
+ AUN_PATH: Path
44
+ DATA_DIR: Path
45
+ CONFIG_FILE: Path
46
+ MAX_RECENT_TARGETS = 10
47
+
48
+
49
+ def _init_paths(override: Optional[str]) -> None:
50
+ global AUN_PATH, DATA_DIR, CONFIG_FILE
51
+ base = Path(override).expanduser() if override else (
52
+ Path(os.environ.get("AUN_CLI_DATA", "")).expanduser()
53
+ if os.environ.get("AUN_CLI_DATA") else Path.home() / ".aun"
54
+ )
55
+ AUN_PATH = base
56
+ DATA_DIR = base / "aun-cli"
57
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
58
+ CONFIG_FILE = DATA_DIR / "config.json"
59
+
60
+
61
+ def _load_config() -> dict:
62
+ if CONFIG_FILE.exists():
63
+ try:
64
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
65
+ except (json.JSONDecodeError, OSError):
66
+ pass
67
+ return {}
68
+
69
+
70
+ def _save_config(cfg: dict) -> None:
71
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
72
+ CONFIG_FILE.write_text(
73
+ json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8"
74
+ )
75
+
76
+
77
+ # ── AID / target helpers ────────────────────────────────────────────────
78
+ _AID_RE = re.compile(
79
+ r"^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?"
80
+ r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?){2,}$"
81
+ )
82
+
83
+
84
+ def _is_valid_aid(name: str) -> bool:
85
+ return bool(name) and bool(_AID_RE.match(name))
86
+
87
+
88
+ def _short_name(aid: str) -> str:
89
+ return aid.split(".")[0] if aid else "?"
90
+
91
+
92
+ def _make_peer_target(aid: str) -> dict:
93
+ return {"type": "peer", "id": aid, "name": _short_name(aid)}
94
+
95
+
96
+ def _normalize_target(value: Any) -> Optional[dict]:
97
+ if isinstance(value, dict) and value.get("type") == "peer":
98
+ tid = str(value.get("id") or "")
99
+ if _is_valid_aid(tid):
100
+ return {"type": "peer", "id": tid, "name": value.get("name") or _short_name(tid)}
101
+ if isinstance(value, str) and _is_valid_aid(value):
102
+ return _make_peer_target(value)
103
+ return None
104
+
105
+
106
+ def _normalize_recent(cfg: dict) -> list[dict]:
107
+ raw = cfg.get("recent_targets", []) or []
108
+ out: list[dict] = []
109
+ for t in raw if isinstance(raw, list) else []:
110
+ n = _normalize_target(t)
111
+ if n:
112
+ out.append(n)
113
+ return out
114
+
115
+
116
+ def _record_target(cfg: dict, target: dict) -> dict:
117
+ """Update cfg in-place + persist, return updated cfg."""
118
+ if not target or target.get("type") != "peer":
119
+ return cfg
120
+ tid = target["id"]
121
+ targets = _normalize_recent(cfg)
122
+ targets = [t for t in targets if t.get("id") != tid]
123
+ targets.insert(0, target)
124
+ cfg["recent_targets"] = targets[:MAX_RECENT_TARGETS]
125
+ _save_config(cfg)
126
+ return cfg
127
+
128
+
129
+ # ── Message store (sqlite, mirror of auntui.MessageStore) ──────────────
130
+ class MessageStore:
131
+ def __init__(self, db_path: str):
132
+ self._db_path = db_path
133
+ self._conn: Optional[sqlite3.Connection] = None
134
+
135
+ def _ensure(self) -> None:
136
+ if self._conn is not None:
137
+ return
138
+ Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
139
+ self._conn = sqlite3.connect(self._db_path)
140
+ self._conn.execute("PRAGMA journal_mode=WAL")
141
+ self._conn.execute(
142
+ """CREATE TABLE IF NOT EXISTS messages (
143
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
144
+ message_id TEXT UNIQUE,
145
+ conversation_id TEXT NOT NULL,
146
+ conversation_type TEXT NOT NULL,
147
+ direction TEXT NOT NULL,
148
+ sender TEXT NOT NULL,
149
+ payload TEXT NOT NULL,
150
+ seq INTEGER,
151
+ timestamp INTEGER NOT NULL,
152
+ is_read INTEGER NOT NULL DEFAULT 0)"""
153
+ )
154
+ self._conn.execute(
155
+ "CREATE INDEX IF NOT EXISTS idx_conv_ts ON messages (conversation_id, timestamp)"
156
+ )
157
+ self._conn.execute(
158
+ "CREATE INDEX IF NOT EXISTS idx_conv_unread ON messages (conversation_id, is_read)"
159
+ )
160
+ self._conn.commit()
161
+
162
+ def save(self, **row) -> None:
163
+ self._ensure()
164
+ assert self._conn is not None
165
+ try:
166
+ self._conn.execute(
167
+ """INSERT OR IGNORE INTO messages
168
+ (message_id, conversation_id, conversation_type, direction,
169
+ sender, payload, seq, timestamp, is_read)
170
+ VALUES (?,?,?,?,?,?,?,?,?)""",
171
+ (
172
+ row.get("message_id"),
173
+ row["conversation_id"],
174
+ row["conversation_type"],
175
+ row["direction"],
176
+ row["sender"],
177
+ row["payload"],
178
+ row.get("seq"),
179
+ row["timestamp"],
180
+ row.get("is_read", 0),
181
+ ),
182
+ )
183
+ self._conn.commit()
184
+ except Exception as e:
185
+ log.warning("store.save failed: %s", e)
186
+
187
+ def history(self, conversation_id: str, limit: int, before_id: Optional[int]) -> list[dict]:
188
+ self._ensure()
189
+ assert self._conn is not None
190
+ limit = max(1, min(int(limit or 50), 200))
191
+ if before_id:
192
+ cur = self._conn.execute(
193
+ """SELECT id, message_id, conversation_id, conversation_type,
194
+ direction, sender, payload, seq, timestamp, is_read
195
+ FROM messages WHERE conversation_id = ? AND id < ?
196
+ ORDER BY timestamp DESC LIMIT ?""",
197
+ (conversation_id, before_id, limit),
198
+ )
199
+ else:
200
+ cur = self._conn.execute(
201
+ """SELECT id, message_id, conversation_id, conversation_type,
202
+ direction, sender, payload, seq, timestamp, is_read
203
+ FROM messages WHERE conversation_id = ?
204
+ ORDER BY timestamp DESC LIMIT ?""",
205
+ (conversation_id, limit),
206
+ )
207
+ cols = [d[0] for d in cur.description]
208
+ rows = [dict(zip(cols, r)) for r in cur.fetchall()]
209
+ rows.reverse()
210
+ return rows
211
+
212
+ def mark_read(self, conversation_id: str) -> None:
213
+ self._ensure()
214
+ assert self._conn is not None
215
+ self._conn.execute(
216
+ "UPDATE messages SET is_read = 1 WHERE conversation_id = ? AND is_read = 0",
217
+ (conversation_id,),
218
+ )
219
+ self._conn.commit()
220
+
221
+ def close(self) -> None:
222
+ if self._conn is not None:
223
+ self._conn.close()
224
+ self._conn = None
225
+
226
+
227
+ # ── Errors ──────────────────────────────────────────────────────────────
228
+ class DaemonError(Exception):
229
+ def __init__(self, code: str, message: str, recoverable: bool = False, data: Any = None):
230
+ super().__init__(message)
231
+ self.code = code
232
+ self.message = message
233
+ self.recoverable = recoverable
234
+ self.data = data
235
+
236
+ def to_dict(self) -> dict:
237
+ out: dict[str, Any] = {
238
+ "code": self.code,
239
+ "message": self.message,
240
+ "recoverable": self.recoverable,
241
+ }
242
+ if self.data is not None:
243
+ out["data"] = self.data
244
+ return out
245
+
246
+
247
+ # ── AUN service ─────────────────────────────────────────────────────────
248
+ class AUNService:
249
+ """Thin wrapper around AUNClient that translates SDK callbacks into daemon events."""
250
+
251
+ def __init__(self, emit_event):
252
+ self._emit = emit_event
253
+ self.client = None
254
+ self.aid: Optional[str] = None
255
+ self.target: Optional[dict] = None
256
+ self.connected = False
257
+ self.gateway: Optional[str] = None
258
+ self._store: Optional[MessageStore] = None
259
+ self._seen_msg_ids: set[str] = set()
260
+ self._chat_id = ""
261
+
262
+ def _get_store(self) -> MessageStore:
263
+ if self._store is None:
264
+ db_dir = AUN_PATH / "AIDs" / (self.aid or "_") if self.aid else DATA_DIR
265
+ self._store = MessageStore(str(db_dir / "messages.db"))
266
+ return self._store
267
+
268
+ async def initialize(self, aid: Optional[str]) -> dict:
269
+ from aun_core import AUNClient
270
+ from aun_core.keystore.file import FileKeyStore
271
+ try:
272
+ from importlib.metadata import version as _pkg_version
273
+ sdk_ver = _pkg_version("fastaun")
274
+ except Exception:
275
+ try:
276
+ from aun_core import __version__ as sdk_ver # type: ignore
277
+ except Exception:
278
+ sdk_ver = "unknown"
279
+
280
+ cfg = _load_config()
281
+ target_aid = aid or cfg.get("aid")
282
+ if not target_aid:
283
+ raise DaemonError("NO_AID", "No AID configured. Pass params.aid or configure via aun CLI.")
284
+ if not _is_valid_aid(target_aid):
285
+ raise DaemonError("INVALID_AID", f"Invalid AID: {target_aid}")
286
+
287
+ ks = FileKeyStore(AUN_PATH)
288
+ has_dir = (ks._aids_root / target_aid).is_dir()
289
+ local = ks.load_identity(target_aid)
290
+ if local is None and not has_dir:
291
+ raise DaemonError(
292
+ "AID_NOT_FOUND",
293
+ f"AID {target_aid} not found in keystore. Create it via: aun aid new {target_aid}",
294
+ )
295
+ if local is None or "private_key_pem" not in (local or {}):
296
+ raise DaemonError(
297
+ "AID_NOT_FOUND", f"AID {target_aid} private key missing"
298
+ )
299
+
300
+ self.client = AUNClient({"aun_path": str(AUN_PATH)}, debug=False)
301
+ self.aid = target_aid
302
+
303
+ self.client.on("message.received", self._on_message)
304
+ self.client.on("connection.state", self._on_state)
305
+
306
+ try:
307
+ auth = await self.client.auth.authenticate({"aid": target_aid})
308
+ except Exception as e:
309
+ raise DaemonError("AUTH_FAILED", f"Authentication failed: {e}", recoverable=True)
310
+
311
+ try:
312
+ await self.client.connect({
313
+ "access_token": auth["access_token"],
314
+ "gateway": auth["gateway"],
315
+ "auto_reconnect": True,
316
+ "slot_id": (self.target or {}).get("id") or "default",
317
+ })
318
+ except Exception as e:
319
+ raise DaemonError("GATEWAY_DISCONNECTED", f"Connect failed: {e}", recoverable=True)
320
+
321
+ self.connected = True
322
+ self.gateway = auth.get("gateway")
323
+ # update device-bound chat_id (used to filter echo-back messages)
324
+ device_id = getattr(self.client, "_device_id", "") or ""
325
+ slot = (self.target or {}).get("id") or "default"
326
+ self._chat_id = f"{target_aid}:{device_id}:{slot}"
327
+
328
+ # restore previous target (if any)
329
+ raw_target = cfg.get("target")
330
+ self.target = _normalize_target(raw_target)
331
+
332
+ # persist current aid
333
+ cfg["aid"] = target_aid
334
+ _save_config(cfg)
335
+
336
+ recent = _normalize_recent(cfg)
337
+
338
+ snapshot = {
339
+ "aid": target_aid,
340
+ "target": self.target,
341
+ "recent_targets": recent,
342
+ "gateway": self.gateway,
343
+ "sdk_version": sdk_ver,
344
+ }
345
+
346
+ # emit ready + connection_state events asynchronously
347
+ self._emit("ready", {
348
+ "aid": target_aid,
349
+ "target": self.target,
350
+ "gateway": self.gateway,
351
+ })
352
+ self._emit("connection_state", {"state": "connected", "reason": None})
353
+ return snapshot
354
+
355
+ async def set_target(self, aid: str) -> dict:
356
+ if not _is_valid_aid(aid):
357
+ raise DaemonError("INVALID_AID", f"Invalid AID: {aid}")
358
+ target = _make_peer_target(aid)
359
+ self.target = target
360
+ cfg = _load_config()
361
+ cfg["target"] = target
362
+ cfg = _record_target(cfg, target)
363
+ # keep chat_id slot aligned with target so echo filtering works
364
+ if self.client is not None:
365
+ device_id = getattr(self.client, "_device_id", "") or ""
366
+ self._chat_id = f"{self.aid}:{device_id}:{target['id']}"
367
+ return {"target": target}
368
+
369
+ async def send_text(self, text: str, encrypt: bool = True) -> dict:
370
+ if not self.target:
371
+ raise DaemonError("NO_TARGET", "No target set. Call set_target first.")
372
+ if not self.client or not self.connected:
373
+ raise DaemonError("NOT_CONNECTED", "Not connected to gateway.", recoverable=True)
374
+ target_id = self.target["id"]
375
+ payload = {"type": "text", "text": text, "chat_id": self._chat_id}
376
+ try:
377
+ result = await self.client.call(
378
+ "message.send",
379
+ {"to": target_id, "payload": payload, "encrypt": bool(encrypt)},
380
+ )
381
+ except Exception as e:
382
+ raise DaemonError("SEND_FAILED", f"Send failed: {e}", data={"raw": str(e)})
383
+
384
+ ts = int(time.time() * 1000)
385
+ msg_id = ""
386
+ if isinstance(result, dict):
387
+ msg_id = result.get("message_id") or ""
388
+ if not msg_id:
389
+ inner = result.get("message")
390
+ if isinstance(inner, dict):
391
+ msg_id = inner.get("message_id") or ""
392
+ if not msg_id:
393
+ msg_id = f"sent_{target_id}_{ts}"
394
+
395
+ self._get_store().save(
396
+ message_id=msg_id,
397
+ conversation_id=target_id,
398
+ conversation_type="peer",
399
+ direction="sent",
400
+ sender=self.aid,
401
+ payload=text,
402
+ seq=None,
403
+ timestamp=ts,
404
+ is_read=1,
405
+ )
406
+ cfg = _load_config()
407
+ _record_target(cfg, self.target)
408
+
409
+ self._emit("message_sent", {
410
+ "message_id": msg_id,
411
+ "target": self.target,
412
+ "text": text,
413
+ "ts": ts,
414
+ })
415
+ return {"message_id": msg_id, "target": self.target, "ts": ts}
416
+
417
+ async def list_messages(self, target_id: str, limit: int = 50, before_id: Optional[int] = None) -> dict:
418
+ if not target_id:
419
+ raise DaemonError("INVALID_PARAMS", "target_id is required")
420
+ rows = self._get_store().history(target_id, limit, before_id)
421
+ messages = [
422
+ {
423
+ "id": r["id"],
424
+ "message_id": r.get("message_id"),
425
+ "conversation_id": r["conversation_id"],
426
+ "conversation_type": r["conversation_type"],
427
+ "direction": r["direction"],
428
+ "sender": r["sender"],
429
+ "text": r["payload"],
430
+ "seq": r.get("seq"),
431
+ "ts": r["timestamp"],
432
+ "is_read": r.get("is_read", 0),
433
+ }
434
+ for r in rows
435
+ ]
436
+ self._get_store().mark_read(target_id)
437
+ return {"messages": messages}
438
+
439
+ async def list_recent_targets(self) -> dict:
440
+ cfg = _load_config()
441
+ return {"targets": _normalize_recent(cfg)}
442
+
443
+ async def get_status(self) -> dict:
444
+ return {
445
+ "connected": self.connected,
446
+ "aid": self.aid,
447
+ "target": self.target,
448
+ "gateway": self.gateway,
449
+ }
450
+
451
+ async def shutdown(self) -> dict:
452
+ try:
453
+ if self.client:
454
+ try:
455
+ await self.client.disconnect()
456
+ except Exception as e:
457
+ log.warning("client.disconnect error: %s", e)
458
+ finally:
459
+ if self._store:
460
+ self._store.close()
461
+ return {"ok": True}
462
+
463
+ # ── SDK callbacks → events ──────────────────────────────────────────
464
+ async def _on_message(self, data):
465
+ if not isinstance(data, dict):
466
+ return
467
+ msg_id = data.get("message_id") or ""
468
+ if msg_id:
469
+ if msg_id in self._seen_msg_ids:
470
+ return
471
+ self._seen_msg_ids.add(msg_id)
472
+ if len(self._seen_msg_ids) > 500:
473
+ self._seen_msg_ids = set(list(self._seen_msg_ids)[-250:])
474
+
475
+ from_aid = data.get("from") or "?"
476
+ if from_aid == self.aid:
477
+ # echo from our own other instances — ignore in MVP
478
+ return
479
+
480
+ payload = data.get("payload", "")
481
+ text = (
482
+ payload.get("text") if isinstance(payload, dict)
483
+ else str(payload)
484
+ )
485
+ if text is None:
486
+ text = json.dumps(payload, ensure_ascii=False) if isinstance(payload, dict) else ""
487
+
488
+ # skip processing/menu status notifications (MVP)
489
+ if isinstance(payload, dict):
490
+ ptype = payload.get("type")
491
+ if ptype in ("processing", "menu.response", "menu.query") or ptype == "event":
492
+ return
493
+
494
+ seq = data.get("seq")
495
+ ts = int(time.time() * 1000)
496
+ e2ee = bool(data.get("e2ee"))
497
+
498
+ if not msg_id:
499
+ msg_id = f"recv_{from_aid}_{ts}"
500
+
501
+ self._get_store().save(
502
+ message_id=msg_id,
503
+ conversation_id=from_aid,
504
+ conversation_type="peer",
505
+ direction="recv",
506
+ sender=from_aid,
507
+ payload=text,
508
+ seq=seq,
509
+ timestamp=ts,
510
+ is_read=0,
511
+ )
512
+
513
+ cfg = _load_config()
514
+ _record_target(cfg, _make_peer_target(from_aid))
515
+
516
+ self._emit("message_received", {
517
+ "message_id": msg_id,
518
+ "from": from_aid,
519
+ "conversation_id": from_aid,
520
+ "conversation_type": "peer",
521
+ "text": text,
522
+ "seq": seq,
523
+ "ts": ts,
524
+ "e2ee": e2ee,
525
+ })
526
+
527
+ async def _on_state(self, data):
528
+ if not isinstance(data, dict):
529
+ return
530
+ state = data.get("state") or "unknown"
531
+ reason = data.get("reason")
532
+ self.connected = (state == "connected")
533
+ self._emit("connection_state", {"state": state, "reason": reason})
534
+
535
+
536
+ # ── JSON-RPC dispatcher ─────────────────────────────────────────────────
537
+ class Dispatcher:
538
+ def __init__(self, service: AUNService):
539
+ self.service = service
540
+ self._shutdown_requested = False
541
+
542
+ async def dispatch(self, method: str, params: dict) -> Any:
543
+ p = params or {}
544
+ if method == "initialize":
545
+ return await self.service.initialize(aid=p.get("aid"))
546
+ if method == "set_target":
547
+ aid = p.get("aid")
548
+ if not aid:
549
+ raise DaemonError("INVALID_PARAMS", "aid is required")
550
+ return await self.service.set_target(aid)
551
+ if method == "send_text":
552
+ text = p.get("text")
553
+ if text is None or not isinstance(text, str):
554
+ raise DaemonError("INVALID_PARAMS", "text is required and must be a string")
555
+ return await self.service.send_text(text, encrypt=p.get("encrypt", True))
556
+ if method == "list_messages":
557
+ return await self.service.list_messages(
558
+ target_id=p.get("target_id", ""),
559
+ limit=p.get("limit", 50),
560
+ before_id=p.get("before_id"),
561
+ )
562
+ if method == "list_recent_targets":
563
+ return await self.service.list_recent_targets()
564
+ if method == "get_status":
565
+ return await self.service.get_status()
566
+ if method == "shutdown":
567
+ self._shutdown_requested = True
568
+ return await self.service.shutdown()
569
+ raise DaemonError("UNKNOWN_METHOD", f"Unknown method: {method}")
570
+
571
+
572
+ # ── Daemon main loop ────────────────────────────────────────────────────
573
+ class Daemon:
574
+ def __init__(self):
575
+ self._out_lock = asyncio.Lock()
576
+ self._event_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
577
+ self.service = AUNService(self._emit)
578
+ self.dispatcher = Dispatcher(self.service)
579
+ self._stop_event = asyncio.Event()
580
+
581
+ def _emit(self, event: str, data: dict) -> None:
582
+ """Thread-safe event emitter (can be called from sync SDK callbacks)."""
583
+ try:
584
+ self._event_queue.put_nowait({"event": event, "data": data})
585
+ except asyncio.QueueFull:
586
+ log.warning("event queue full, dropping event: %s", event)
587
+
588
+ async def _write(self, obj: dict) -> None:
589
+ line = json.dumps(obj, ensure_ascii=False, default=str) + "\n"
590
+ async with self._out_lock:
591
+ try:
592
+ sys.stdout.write(line)
593
+ sys.stdout.flush()
594
+ except (BrokenPipeError, OSError):
595
+ self._stop_event.set()
596
+
597
+ async def _handle_request(self, raw: str) -> None:
598
+ try:
599
+ req = json.loads(raw)
600
+ except json.JSONDecodeError as e:
601
+ await self._write({
602
+ "id": None,
603
+ "error": {"code": "INVALID_PARAMS", "message": f"Bad JSON: {e}", "recoverable": False},
604
+ })
605
+ return
606
+
607
+ req_id = req.get("id")
608
+ method = req.get("method")
609
+ params = req.get("params") or {}
610
+
611
+ if not isinstance(method, str):
612
+ await self._write({
613
+ "id": req_id,
614
+ "error": {"code": "INVALID_PARAMS", "message": "method is required", "recoverable": False},
615
+ })
616
+ return
617
+
618
+ try:
619
+ result = await self.dispatcher.dispatch(method, params)
620
+ await self._write({"id": req_id, "result": result})
621
+ except DaemonError as e:
622
+ log.info("method %s → error %s: %s", method, e.code, e.message)
623
+ await self._write({"id": req_id, "error": e.to_dict()})
624
+ except Exception as e:
625
+ log.exception("method %s unexpected exception", method)
626
+ await self._write({
627
+ "id": req_id,
628
+ "error": {
629
+ "code": "INTERNAL_ERROR",
630
+ "message": f"{type(e).__name__}: {e}",
631
+ "recoverable": False,
632
+ },
633
+ })
634
+
635
+ if self.dispatcher._shutdown_requested and method == "shutdown":
636
+ self._stop_event.set()
637
+
638
+ async def _read_loop(self) -> None:
639
+ loop = asyncio.get_running_loop()
640
+ while not self._stop_event.is_set():
641
+ line = await loop.run_in_executor(None, sys.stdin.readline)
642
+ if not line:
643
+ log.info("stdin closed, stopping")
644
+ self._stop_event.set()
645
+ break
646
+ line = line.strip()
647
+ if not line:
648
+ continue
649
+ # handle concurrently so long-running methods don't block short ones
650
+ asyncio.create_task(self._handle_request(line))
651
+
652
+ async def _event_loop(self) -> None:
653
+ while not self._stop_event.is_set():
654
+ try:
655
+ evt = await asyncio.wait_for(self._event_queue.get(), timeout=0.5)
656
+ except asyncio.TimeoutError:
657
+ continue
658
+ await self._write(evt)
659
+
660
+ async def run(self) -> int:
661
+ def _sig_handler(signum, _frame):
662
+ log.info("signal %s received, stopping", signum)
663
+ self._stop_event.set()
664
+
665
+ try:
666
+ signal.signal(signal.SIGTERM, _sig_handler)
667
+ signal.signal(signal.SIGINT, _sig_handler)
668
+ except ValueError:
669
+ pass # non-main thread: skip
670
+
671
+ reader = asyncio.create_task(self._read_loop())
672
+ events = asyncio.create_task(self._event_loop())
673
+
674
+ await self._stop_event.wait()
675
+
676
+ for t in (reader, events):
677
+ if not t.done():
678
+ t.cancel()
679
+ for t in (reader, events):
680
+ try:
681
+ await t
682
+ except asyncio.CancelledError:
683
+ pass
684
+
685
+ # drain any remaining events briefly
686
+ while not self._event_queue.empty():
687
+ try:
688
+ await self._write(self._event_queue.get_nowait())
689
+ except Exception:
690
+ break
691
+ return 0
692
+
693
+
694
+ def main() -> int:
695
+ ap = argparse.ArgumentParser(description="AUN headless daemon (MVP v0.1)")
696
+ ap.add_argument("--data-dir", help="override AUN data dir (default ~/.aun or $AUN_CLI_DATA)")
697
+ args = ap.parse_args()
698
+
699
+ _init_paths(args.data_dir)
700
+ log.info("aun_daemon starting data_dir=%s", AUN_PATH)
701
+
702
+ try:
703
+ return asyncio.run(Daemon().run())
704
+ except KeyboardInterrupt:
705
+ return 130
706
+
707
+
708
+ if __name__ == "__main__":
709
+ sys.exit(main())