alter-runtime 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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,173 @@
1
+ """HTTP authentication helpers for upstream calls into ``mcp.truealter.com``.
2
+
3
+ The production edge sits behind Cloudflare Access. Sovereign desktop
4
+ daemons authenticate by injecting the ``CF-Access-Client-Id`` +
5
+ ``CF-Access-Client-Secret`` headers on every request — without them, CF
6
+ Access intercepts at the edge with a 302 redirect to the
7
+ ``truealteraccess.com`` login flow and the daemon's ``Authorization:
8
+ Bearer <jwt>`` is never seen by the origin.
9
+
10
+ This module is the Stream 1 implementation of D-SUBSTRATE-UNIFIED-1 §2.3
11
+ Option A (service-token-per-device, interim posture). Option B
12
+ (substrate-mTLS, canonical) lands in Stream 6 and supersedes this module.
13
+
14
+ Two-tier credential resolution:
15
+
16
+ 1. Process environment — ``CF_ACCESS_CLIENT_ID`` + ``CF_ACCESS_CLIENT_SECRET``
17
+ read directly. This is the primary path: systemd ``EnvironmentFile=``,
18
+ launchd ``EnvironmentVariables``, Windows Service env injection,
19
+ subprocess inheritance from a shell that sourced the envfile.
20
+
21
+ 2. Envfile fallback — when the process env is unset, parse
22
+ ``~/.config/alter/cf-access.env`` directly. This handles the
23
+ cross-platform reality that envfile parsers diverge: bash needs
24
+ ``export VAR=value`` for subprocess inheritance, systemd's
25
+ ``EnvironmentFile=`` rejects the ``export`` prefix on older versions,
26
+ and launchd's ``EnvironmentVariables`` uses a third format entirely.
27
+ Reading the envfile directly in Python sidesteps every system-level
28
+ parser quirk and keeps the daemon's credential path platform-uniform.
29
+
30
+ The envfile parser is intentionally lenient: it accepts ``VAR=value``,
31
+ ``export VAR=value``, surrounding whitespace, simple ``${OTHER_VAR}``
32
+ back-references for the two CF Access vars we care about, and inline
33
+ ``#`` comments. It does NOT execute shell — no command substitution, no
34
+ glob expansion, no arithmetic. The only goal is to extract two strings.
35
+
36
+ When both tiers return nothing the helper returns ``{}`` — never raises,
37
+ never logs at warning level. The HTTP client treats absent CF Access
38
+ credentials as "no edge auth required" and proceeds with the existing
39
+ session-JWT bearer. Dev hosts, CI runners, and tests are unaffected.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import os
46
+ from pathlib import Path
47
+
48
+ __all__ = ["cf_access_default_headers", "backend_default_headers"]
49
+
50
+ _logger = logging.getLogger(__name__)
51
+
52
+ #: Canonical envfile location. Honours ``XDG_CONFIG_HOME`` for the same
53
+ #: reason ``alter_runtime.config.session_path()`` does — most users
54
+ #: leave it unset and the file lives under ``~/.config/alter/``.
55
+ _ENVFILE_RELATIVE: str = ".config/alter/cf-access.env"
56
+
57
+ #: The two CF Access service-token variable names the edge requires.
58
+ #: ``CF_ACCESS_MCP_*`` aliases are deliberately not consulted — the
59
+ #: cross-bridge ``cf-access.env`` shape exports a generic alias
60
+ #: (``CF_ACCESS_CLIENT_ID``) that we treat as authoritative.
61
+ _ID_VAR: str = "CF_ACCESS_CLIENT_ID"
62
+ _SECRET_VAR: str = "CF_ACCESS_CLIENT_SECRET"
63
+
64
+
65
+ def cf_access_default_headers() -> dict[str, str]:
66
+ """Return CF Access service-token headers from env or envfile.
67
+
68
+ Resolution order:
69
+
70
+ 1. Process environment (``CF_ACCESS_CLIENT_ID`` /
71
+ ``CF_ACCESS_CLIENT_SECRET``).
72
+ 2. ``~/.config/alter/cf-access.env`` parsed by
73
+ :func:`_parse_envfile`.
74
+
75
+ Returns the two-key header dict when both credentials resolve to
76
+ non-empty strings; returns ``{}`` otherwise. Never raises.
77
+ """
78
+ client_id = os.environ.get(_ID_VAR, "").strip()
79
+ client_secret = os.environ.get(_SECRET_VAR, "").strip()
80
+
81
+ if not client_id or not client_secret:
82
+ envfile_values = _read_envfile_safely()
83
+ client_id = client_id or envfile_values.get(_ID_VAR, "").strip()
84
+ client_secret = client_secret or envfile_values.get(_SECRET_VAR, "").strip()
85
+
86
+ if not client_id or not client_secret:
87
+ return {}
88
+ return {
89
+ "CF-Access-Client-Id": client_id,
90
+ "CF-Access-Client-Secret": client_secret,
91
+ }
92
+
93
+
94
+ def backend_default_headers() -> dict[str, str]:
95
+ """Return headers attached to every outbound backend HTTP call.
96
+
97
+ Merges the canonical ``X-Alter-Client-*`` identity bundle
98
+ (D-MIN-VERSION-FLOOR-1 §3) with CF Access service-token headers when
99
+ available. Backend callsites use this in place of
100
+ :func:`cf_access_default_headers` so the floor middleware sees a
101
+ well-formed identification tuple on every request.
102
+
103
+ Header precedence: identity headers (``X-Alter-Client-*``) are constant
104
+ per process; CF Access headers are loaded lazily and overlay on top
105
+ (CF Access keys never collide with the X-Alter-* namespace, but the
106
+ overlay order keeps the contract explicit).
107
+
108
+ Defined here rather than in ``alter_runtime.floor_preflight`` so legacy
109
+ callers can adopt the helper without taking on the floor-preflight
110
+ import surface. Both modules cross-import each other otherwise.
111
+ """
112
+ # Lazy import to avoid the http_auth → floor_preflight → http_auth cycle.
113
+ from alter_runtime.floor_preflight import client_headers
114
+
115
+ merged: dict[str, str] = dict(client_headers())
116
+ merged.update(cf_access_default_headers())
117
+ return merged
118
+
119
+
120
+ def _read_envfile_safely() -> dict[str, str]:
121
+ """Read ``~/.config/alter/cf-access.env`` if present and parseable."""
122
+ path = Path.home() / _ENVFILE_RELATIVE
123
+ try:
124
+ text = path.read_text(encoding="utf-8")
125
+ except FileNotFoundError:
126
+ return {}
127
+ except OSError as exc:
128
+ _logger.debug("cf-access envfile read failed at %s: %s", path, exc)
129
+ return {}
130
+ try:
131
+ return _parse_envfile(text)
132
+ except Exception as exc: # noqa: BLE001 — last-resort safety
133
+ _logger.debug("cf-access envfile parse failed at %s: %s", path, exc)
134
+ return {}
135
+
136
+
137
+ def _parse_envfile(text: str) -> dict[str, str]:
138
+ """Parse a lenient envfile into a name→value dict.
139
+
140
+ Accepts ``VAR=value``, ``export VAR=value``, surrounding whitespace,
141
+ inline ``#`` comments, and single-level ``${OTHER}`` back-references
142
+ against names defined earlier in the same file (which covers the
143
+ canonical ``cf-access.env`` shape that aliases ``CF_ACCESS_CLIENT_ID``
144
+ onto ``CF_ACCESS_MCP_ORG_ALTER_CLIENT_ID``). Does not invoke a
145
+ shell; no command substitution, no globbing, no arithmetic.
146
+ """
147
+ out: dict[str, str] = {}
148
+ for raw in text.splitlines():
149
+ line = raw.strip()
150
+ if not line or line.startswith("#"):
151
+ continue
152
+ if line.startswith("export "):
153
+ line = line[len("export ") :].lstrip()
154
+ if "=" not in line:
155
+ continue
156
+ name, _, value = line.partition("=")
157
+ name = name.strip()
158
+ if not name or not name.replace("_", "").isalnum():
159
+ continue
160
+ # Strip inline comment (only when preceded by whitespace, so
161
+ # values containing literal '#' inside quotes survive).
162
+ if " #" in value:
163
+ value = value.split(" #", 1)[0]
164
+ value = value.strip()
165
+ # Strip a single layer of surrounding single or double quotes.
166
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
167
+ value = value[1:-1]
168
+ # Resolve ${OTHER} back-references against names already parsed.
169
+ if value.startswith("${") and value.endswith("}"):
170
+ ref = value[2:-1].strip()
171
+ value = out.get(ref, "")
172
+ out[name] = value
173
+ return out
@@ -0,0 +1,18 @@
1
+ """Notifiers - local fan-out surfaces that render identity events to the user's desktop.
2
+
3
+ Each notifier is a :class:`alter_runtime.daemon.Component` that subscribes to
4
+ the shared :class:`alter_runtime.subscribers.bus.EventBus` and renders a
5
+ matching event to a platform-native surface: freedesktop notifications on
6
+ Linux, NSUserNotificationCenter on macOS, toast-XML on Windows. The notifier
7
+ tier sits downstream of the InboxWriter projection - they consume the same
8
+ bus topic, so a notifier failure cannot starve the inbox write path and vice
9
+ versa.
10
+
11
+ Wave 1 ships :class:`DesktopNotifier` (Linux ``notify-send``). macOS / Windows
12
+ paths are deferred; the notifier tier is Linux-only for the private-dev
13
+ dogfood launch.
14
+ """
15
+
16
+ from alter_runtime.notifiers.desktop import DesktopNotifier
17
+
18
+ __all__ = ["DesktopNotifier"]
@@ -0,0 +1,321 @@
1
+ """DesktopNotifier - freedesktop toast on inbound ``alter_message``.
2
+
3
+ Subscribes to the same ``identity.event`` stream the :class:`InboxWriter`
4
+ drains and shells out to ``/usr/bin/notify-send`` on each inbound message
5
+ addressed to the local ``~handle``. Designed for Blake's awesomewm rig but
6
+ will work on any freedesktop.org-compliant desktop (GNOME, KDE, sway, i3,
7
+ bspwm, awesome, dwm + dunst, etc.) without configuration.
8
+
9
+ Design choices
10
+ --------------
11
+
12
+ * **Native feel.** Uses ``notify-send`` rather than the ``dbus-next`` path so
13
+ existing notification daemons (dunst, mako, the GNOME/KDE services) pick up
14
+ the message through the standard ``org.freedesktop.Notifications`` bus
15
+ interface. The flags passed match a messaging-app toast: ``--app-name`` +
16
+ ``--category="im.received"`` + ``--icon=mail-unread`` + a ``sound-name``
17
+ hint so the user's notification daemon plays the system message chime.
18
+ * **Non-intrusive.** ``--urgency=normal`` (not critical) means the toast is
19
+ dismissible and does not steal focus. ``--expire-time=8000`` keeps it on
20
+ screen for 8s, long enough to read the first line without becoming a
21
+ permanent shade.
22
+ * **Swallow and continue.** Every failure class (``notify-send`` missing, no
23
+ ``DISPLAY``/``WAYLAND_DISPLAY``, dbus not running, subprocess crash) is
24
+ logged at warning level and dropped. The notifier is a convenience surface;
25
+ the inbox JSONL is the source of truth and already landed on disk by the
26
+ time we see the event.
27
+ * **Privacy invariants (D-MSG10).** The notifier is a one-way render. It
28
+ never emits a read receipt, presence beacon, or typing indicator back onto
29
+ the bus. The only side effect is the toast itself.
30
+
31
+ The component ignores events whose ``kind`` is not ``"alter_message"`` and
32
+ events addressed to a different recipient (e.g. room-wide broadcasts with an
33
+ explicit ``recipient_handle`` that is not the local session handle).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import logging
40
+ import os
41
+ import re
42
+ import shutil
43
+ from typing import TYPE_CHECKING, Any
44
+
45
+ from alter_runtime.daemon import Component
46
+
47
+ if TYPE_CHECKING:
48
+ from alter_runtime.config import DaemonConfig, Session
49
+ from alter_runtime.subscribers.bus import EventBus
50
+
51
+ __all__ = ["DesktopNotifier"]
52
+
53
+ logger = logging.getLogger("alter_runtime.notifiers.desktop")
54
+
55
+ #: Absolute path to ``notify-send``. Hard-coded to match the flag set validated
56
+ #: against libnotify 0.8.x; a PATH lookup at run-time would be more flexible
57
+ #: but would also silently pick up stub binaries on sandboxed hosts.
58
+ NOTIFY_SEND_BIN: str = "/usr/bin/notify-send"
59
+
60
+ #: Maximum characters of ``body_md`` rendered in the toast body. Kept short so
61
+ #: the notification stays a glance rather than a read. The InboxWriter / CLI
62
+ #: ``alter msg read`` surfaces render the full body.
63
+ BODY_PREVIEW_CHARS: int = 140
64
+
65
+ #: Default toast dwell time in milliseconds (8s - long enough to read, short
66
+ #: enough that it is gone before the user finishes a cigarette).
67
+ EXPIRE_TIME_MS: int = 8000
68
+
69
+ #: App-name displayed in the notification centre. Matches the brand wordmark.
70
+ APP_NAME: str = "Alter"
71
+
72
+ #: FDO category; ``im.received`` is the canonical message-arrival category so
73
+ #: notification daemons (dunst et al.) can route it to a chat rule.
74
+ CATEGORY: str = "im.received"
75
+
76
+ #: libnotify icon name; ``mail-unread`` is present in every freedesktop icon
77
+ #: theme we care about. Falls back silently if absent.
78
+ ICON: str = "mail-unread"
79
+
80
+ #: Sound hint - ``message-new-instant`` is the sound-theme canonical event
81
+ #: for an incoming IM. Most themes map it to a short chime.
82
+ SOUND_NAME: str = "message-new-instant"
83
+
84
+ #: Markdown stripping - good enough for toast previews. Removes emphasis
85
+ #: markers, headings, code fences, inline links, and collapses newlines.
86
+ _MD_BOLD_ITALIC = re.compile(r"(\*{1,3}|_{1,3})(.+?)\1")
87
+ _MD_CODE_INLINE = re.compile(r"`([^`]+)`")
88
+ _MD_CODE_FENCE = re.compile(r"```[\s\S]*?```")
89
+ _MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]+\)")
90
+ _MD_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE)
91
+ _MD_BLOCKQUOTE = re.compile(r"^>\s?", re.MULTILINE)
92
+ _WHITESPACE = re.compile(r"\s+")
93
+
94
+
95
+ def _strip_markdown(text: str) -> str:
96
+ """Flatten a Markdown string to a single-line toast body."""
97
+ if not text:
98
+ return ""
99
+ text = _MD_CODE_FENCE.sub("", text)
100
+ text = _MD_CODE_INLINE.sub(r"\1", text)
101
+ text = _MD_LINK.sub(r"\1", text)
102
+ text = _MD_HEADING.sub("", text)
103
+ text = _MD_BLOCKQUOTE.sub("", text)
104
+ text = _MD_BOLD_ITALIC.sub(r"\2", text)
105
+ text = _WHITESPACE.sub(" ", text).strip()
106
+ return text
107
+
108
+
109
+ def _truncate(text: str, limit: int = BODY_PREVIEW_CHARS) -> str:
110
+ """Truncate ``text`` to ``limit`` chars with an ellipsis marker."""
111
+ if len(text) <= limit:
112
+ return text
113
+ return text[: limit - 1].rstrip() + "…"
114
+
115
+
116
+ class DesktopNotifier(Component):
117
+ """Renders inbound ``alter_message`` events to a freedesktop toast.
118
+
119
+ Parameters
120
+ ----------
121
+ config:
122
+ Loaded :class:`DaemonConfig`. Not currently consulted - kept for
123
+ future opt-in fields (urgency override, custom icon theme, etc.).
124
+ session:
125
+ Authenticated alter-cli session. ``session.handle`` is used to
126
+ filter messages addressed to a different recipient.
127
+ bus:
128
+ The shared :class:`EventBus`. Subscribes to ``identity.event``.
129
+ notify_send_bin:
130
+ Override the path to ``notify-send``. Tests inject a stub here.
131
+ """
132
+
133
+ name = "desktop_notifier"
134
+
135
+ def __init__(
136
+ self,
137
+ config: DaemonConfig,
138
+ session: Session,
139
+ bus: EventBus,
140
+ *,
141
+ notify_send_bin: str = NOTIFY_SEND_BIN,
142
+ ) -> None:
143
+ self._config = config
144
+ self._session = session
145
+ self._bus = bus
146
+ self._notify_send_bin = notify_send_bin
147
+ self._stop_event = asyncio.Event()
148
+ #: Monotonic count of toasts actually rendered (test introspection).
149
+ self._toasts_sent: int = 0
150
+ #: Monotonic count of send failures (test introspection).
151
+ self._send_failures: int = 0
152
+
153
+ # ------------------------------------------------------------------
154
+ # Component lifecycle
155
+ # ------------------------------------------------------------------
156
+
157
+ async def run(self) -> None:
158
+ # Refuse to pretend we are running a notifier if the binary is missing
159
+ # or no graphical session exists - log loudly once, then sleep. The
160
+ # supervisor will keep us alive (so a later ``DISPLAY`` export picks
161
+ # up) without us spamming a failed subprocess on every message.
162
+ if not self._has_usable_backend():
163
+ logger.warning(
164
+ "desktop_notifier: notify-send or graphical session unavailable; "
165
+ "notifier started in no-op mode (messages still reach inbox.jsonl)"
166
+ )
167
+
168
+ self._bus.subscribe("identity.event", self._on_event)
169
+ logger.info(
170
+ "desktop_notifier started handle=%s bin=%s",
171
+ self._session.handle,
172
+ self._notify_send_bin,
173
+ )
174
+ try:
175
+ await self._stop_event.wait()
176
+ finally:
177
+ self._bus.unsubscribe("identity.event", self._on_event)
178
+ logger.info(
179
+ "desktop_notifier stopped handle=%s toasts=%d failures=%d",
180
+ self._session.handle,
181
+ self._toasts_sent,
182
+ self._send_failures,
183
+ )
184
+
185
+ async def stop(self) -> None:
186
+ self._stop_event.set()
187
+
188
+ # ------------------------------------------------------------------
189
+ # Bus callback
190
+ # ------------------------------------------------------------------
191
+
192
+ async def _on_event(self, event: Any) -> None:
193
+ """Filter for alter_message addressed to the local handle and render."""
194
+ if not isinstance(event, dict):
195
+ return
196
+ if event.get("kind") != "alter_message":
197
+ return
198
+
199
+ # Accept the same dual-shape the InboxWriter does: payload may be
200
+ # nested or flat. Prefer the nested body because the DO emitter puts
201
+ # the message fields there.
202
+ body = event.get("payload") if isinstance(event.get("payload"), dict) else event
203
+
204
+ # Recipient filter - a frame with an explicit recipient that isn't us
205
+ # is probably a room broadcast or a multi-cast and the desktop should
206
+ # not light up. Frames without a recipient field are treated as
207
+ # implicitly-for-us (the DO only emits messages on the owner's stream).
208
+ recipient = body.get("recipient_handle") or body.get("recipient")
209
+ if recipient and recipient != self._session.handle:
210
+ return
211
+
212
+ sender = body.get("sender_handle") or body.get("sender") or "~unknown"
213
+ body_md = body.get("body_md") or ""
214
+ preview = _truncate(_strip_markdown(str(body_md)))
215
+
216
+ await self._toast(sender=str(sender), preview=preview)
217
+
218
+ # ------------------------------------------------------------------
219
+ # notify-send invocation
220
+ # ------------------------------------------------------------------
221
+
222
+ async def _toast(self, *, sender: str, preview: str) -> None:
223
+ """Fire a ``notify-send`` with the canonical messaging flags."""
224
+ if not self._has_usable_backend():
225
+ # Logged once in run(); stay quiet here to avoid spam.
226
+ return
227
+
228
+ title = sender
229
+ body = preview or "(no body)"
230
+
231
+ argv = [
232
+ self._notify_send_bin,
233
+ f"--app-name={APP_NAME}",
234
+ "--urgency=normal",
235
+ f"--expire-time={EXPIRE_TIME_MS}",
236
+ f"--category={CATEGORY}",
237
+ f"--icon={ICON}",
238
+ f"--hint=string:sound-name:{SOUND_NAME}",
239
+ title,
240
+ body,
241
+ ]
242
+
243
+ try:
244
+ proc = await asyncio.create_subprocess_exec(
245
+ *argv,
246
+ stdout=asyncio.subprocess.DEVNULL,
247
+ stderr=asyncio.subprocess.PIPE,
248
+ )
249
+ try:
250
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=5.0)
251
+ except (TimeoutError, asyncio.TimeoutError):
252
+ # Kill a stuck notify-send rather than leaking the subprocess.
253
+ proc.kill()
254
+ await proc.wait()
255
+ self._send_failures += 1
256
+ logger.warning("desktop_notifier: notify-send timed out; killed child")
257
+ return
258
+ except FileNotFoundError:
259
+ self._send_failures += 1
260
+ logger.warning(
261
+ "desktop_notifier: %s not found - set ALTER_DESKTOP_NOTIFIER_DISABLED=1 "
262
+ "to silence this warning",
263
+ self._notify_send_bin,
264
+ )
265
+ return
266
+ except Exception as exc: # noqa: BLE001 - swallow every class of OS failure
267
+ self._send_failures += 1
268
+ logger.warning("desktop_notifier: spawn failed: %s", exc)
269
+ return
270
+
271
+ if proc.returncode != 0:
272
+ self._send_failures += 1
273
+ err = (stderr or b"").decode("utf-8", errors="replace").strip()
274
+ logger.warning(
275
+ "desktop_notifier: notify-send exit=%d stderr=%r",
276
+ proc.returncode,
277
+ err[:200],
278
+ )
279
+ return
280
+
281
+ self._toasts_sent += 1
282
+ logger.debug("desktop_notifier: toast sent sender=%s", sender)
283
+
284
+ # ------------------------------------------------------------------
285
+ # Environment probes
286
+ # ------------------------------------------------------------------
287
+
288
+ def _has_usable_backend(self) -> bool:
289
+ """Best-effort check that notify-send can actually post a toast.
290
+
291
+ Does not guarantee success - a running dbus session without a
292
+ notification daemon will still accept the call but drop it silently -
293
+ but catches the obvious cold-fail cases so we log once rather than
294
+ per-message.
295
+ """
296
+ if not shutil.which(self._notify_send_bin) and not os.path.exists(self._notify_send_bin):
297
+ return False
298
+ # Either X11 or Wayland session indicator is fine. Headless systemd
299
+ # services inherit ``XDG_RUNTIME_DIR`` but not ``DISPLAY``; dbus over
300
+ # the user bus works without either, so this is a soft heuristic.
301
+ if (
302
+ not os.environ.get("DISPLAY")
303
+ and not os.environ.get("WAYLAND_DISPLAY")
304
+ and not os.environ.get("DBUS_SESSION_BUS_ADDRESS")
305
+ ):
306
+ return False
307
+ return True
308
+
309
+ # ------------------------------------------------------------------
310
+ # Test introspection
311
+ # ------------------------------------------------------------------
312
+
313
+ @property
314
+ def toasts_sent(self) -> int:
315
+ """Count of toasts actually rendered (used by tests)."""
316
+ return self._toasts_sent
317
+
318
+ @property
319
+ def send_failures(self) -> int:
320
+ """Count of ``notify-send`` spawn / exit failures (used by tests)."""
321
+ return self._send_failures
@@ -0,0 +1,12 @@
1
+ """alter_runtime.sdk - Python SDK for ALTER's identity MCP server.
2
+
3
+ Exports ``AlterClient``, the async HTTP client lifted from
4
+ ``backend/openclaw-skill/alter_bot/mcp_client.py`` and renamed for the
5
+ distribution layer (D-RT5 - the PyPI package is ``alter-runtime``, not
6
+ ``alter-identity``, because the TypeScript npm package ``@alter/identity``
7
+ already claims that name).
8
+ """
9
+
10
+ from alter_runtime.sdk.client import AlterClient, MCPResponse
11
+
12
+ __all__ = ["AlterClient", "MCPResponse"]