@inline-chat/hermes-agent-adapter 0.0.20 → 0.0.22-alpha.0

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.
package/README.md CHANGED
@@ -25,6 +25,7 @@ Supported:
25
25
  - `INLINE_TOKEN`, `INLINE_BOT_TOKEN`, `platforms.inline.token`, and `inline.token` auth paths, including simple `${ENV_NAME}` config references.
26
26
  - Supervised loopback Node sidecar using the Inline realtime SDK.
27
27
  - Realtime inbound messages, catch-up, replies to bot messages, and action callbacks.
28
+ - Inbound SDK receipts remain pending until Python handles the event. Temporary routing-metadata and authorization failures retry before deduplication or effects, preserving per-chat order while other chats can progress. Lost acknowledgement responses retry only the acknowledgement. Agent-button preflight retries are bounded, then show a retry prompt so an unavailable button cannot indefinitely block the shared action cursor. Shutdown leaves unresolved receipts available for catch-up; this is an at-least-once handoff, not a durable exactly-once guarantee for model turns or external effects.
28
29
  - Outbound text, Markdown parsing, opt-in edit-message streaming, long-message splitting, edits, deletes, typing, and presence.
29
30
  - Inline reply-thread routing, explicit-request auto mode, `/threads` controls, explicit `/follow` and `/unfollow` dialog relevance controls, parent chat metadata, parent/thread prompt fallback, and thread-specific skill bindings.
30
31
  - Native Hermes `inline` tool for current-chat/thread reads, bounded history and search, exact message lookup, button-message sends, editing/deleting bot-owned messages, reactions, pin/unpin/list pins, reply-thread creation, top-level thread/chat creation outside the current conversation, and avatar presence/status.
@@ -33,7 +34,7 @@ Supported:
33
34
  - DM and group policies, user allowlists, group sender allowlists, mention requirements, strict mention mode, allowed chats, and free-response chats.
34
35
  - Native Inline `/` command-menu sync for Hermes slash commands, including `/threads`, `/follow`, `/unfollow`, `/inline_sync`, `/inline_version`, and `/update`; typed slash commands continue to work even if menu sync is disabled or rejected.
35
36
  - Inline-native buttons for clarify prompts, command approvals, slash confirmations, and model selection.
36
- - Agent-created `send_message`/`edit_message` button rows with opaque callback data. A callback is acknowledged immediately and becomes a normal Hermes turn naming the source message and exact action fields. The normal response edits that source message and clears omitted buttons; the agent can instead call `edit_message` with replacement buttons and finish with `NO_REPLY` so the explicit edit remains authoritative.
37
+ - Agent-created `send_message`/`edit_message` button rows with opaque callback data. A callback is acknowledged after its target and access checks succeed, then enters Hermes as a normal turn naming the source message and exact action fields. The normal response edits that source message and clears omitted buttons; the agent can instead call `edit_message` with replacement buttons and finish with `NO_REPLY` so the explicit edit remains authoritative.
37
38
  - Outbound local photo, video, voice, and document uploads with configurable size caps.
38
39
  - Inbound photo, video, voice, and document summaries, with URL-backed media cached locally for Hermes when available.
39
40
  - Reactions on bot messages, plus opt-in lifecycle/system events as synthetic Hermes messages.
@@ -50,6 +51,18 @@ Unsupported or intentionally limited:
50
51
  - Live voice sessions or calls. Voice file messages are supported, but realtime audio is not.
51
52
  - Media without a usable Inline CDN/local URL. Those messages still produce text summaries, but Hermes may not receive a local file path.
52
53
 
54
+ ## Alpha 0.0.22-alpha.0
55
+
56
+ This prerelease includes typed service-notice filtering before agent admission,
57
+ current shared-session setup guidance, and the pinned realtime SDK `0.0.18`.
58
+ Hermes keeps its supported agent activity surface; unsupported experimental tool
59
+ timelines remain disabled.
60
+
61
+ ```sh
62
+ npm install -g @inline-chat/hermes-agent-adapter@0.0.22-alpha.0
63
+ inline-hermes install --force
64
+ ```
65
+
53
66
  ## Install
54
67
 
55
68
  ```sh
@@ -155,7 +168,7 @@ uv run ./hermes plugins list --plain --no-bundled
155
168
  Expected local output includes:
156
169
 
157
170
  ```text
158
- enabled user 0.0.20 inline-platform
171
+ enabled user 0.0.21 inline-platform
159
172
  ```
160
173
 
161
174
  ## Update Or Reinstall
@@ -253,26 +266,14 @@ platforms:
253
266
  enabled: true
254
267
  ```
255
268
 
256
- Inline follows Hermes' native work-chat defaults: show typing/presence while a
257
- turn is running, keep tool-call progress out of the room by default, and keep
258
- edit-based token streaming opt-in. If an older Hermes config has global
259
- `display.tool_progress: all`, add the per-platform override below so terminal
260
- and tool progress bubbles are not left in Inline chats:
261
-
262
- ```yaml
263
- display:
264
- platforms:
265
- inline:
266
- tool_progress: off
267
- cleanup_progress: true
268
- streaming: false
269
- interim_assistant_messages: false
270
- ```
269
+ Experimental tool timelines are unavailable in this adapter. The old
270
+ `INLINE_EXPERIMENTAL_AGENT_ACTIVITY` setting is ignored and logs a warning when
271
+ enabled. Hermes uses ordinary typing, optional processing reactions, and replies.
271
272
 
272
- To temporarily show live tool progress, set `tool_progress: new` or
273
- `tool_progress: all` and leave `cleanup_progress: true` so Hermes deletes the
274
- progress message after a successful final reply. Token streaming requires both
275
- top-level `streaming.enabled: true` and
273
+ Inline keeps tool progress and interim assistant messages quiet by default.
274
+ Explicit Hermes display settings still take precedence; if you enable native
275
+ tool progress, Hermes uses its ordinary progress messages. Token streaming remains
276
+ opt-in through both `streaming.enabled: true` and
276
277
  `display.platforms.inline.streaming: true`.
277
278
 
278
279
  Hermes-native sends and scheduled deliveries accept Inline's explicit target
@@ -334,6 +335,22 @@ Access control follows Hermes' native platform model:
334
335
 
335
336
  Policy is evaluated in three ordered stages: **access**, then **wake**, then **delivery**. Access checks the chat and sender policy and is a hard gate; a mention, reply, callback, or command never grants access to a blocked chat or actor. Wake decides whether an allowed group turn invokes Hermes: free-response chats wake normally, while mention-gated chats require an explicit mention unless a configured reply-to-bot or followed-thread exception applies. Delivery keeps existing child-thread conversations in place; top-level `auto` creates a child only for explicit thread intent, `on` always creates one, and `off` stays flat.
336
337
 
338
+ `open` controls intake; it does not grant permission to use the bot. After
339
+ Inline's sender and group/thread restrictions pass, the adapter asks Hermes'
340
+ registered authorization callback before executing local commands, creating
341
+ reply threads, downloading media, collecting context, or exposing runtime
342
+ settings. This uses the same pairing and profile-aware authorization as native
343
+ Hermes adapters. An unapproved sender reaches Hermes' pairing or rejection
344
+ flow with a minimal text event and no preceding adapter mutations or media work.
345
+ Pairing approval does not override an explicit Inline allowlist, disabled
346
+ policy, or excluded group. DMs remain exempt from `allowed_chats`.
347
+ Child-thread authorization preserves both the child and parent chat IDs so
348
+ Hermes can select the correct profile. If required group metadata is unavailable,
349
+ the adapter denies the operation instead of guessing a profile. Controls that
350
+ change the parent's reply-thread mode also require access to that parent;
351
+ thread-local model and following settings remain available to an authorized
352
+ child-thread user.
353
+
337
354
  Equivalent Hermes YAML can use `allow_from`, `allowed_users`,
338
355
  `group_allow_from`, `dm_policy`, `group_policy`, `require_mention`,
339
356
  `strict_mention`, `allowed_chats`, `free_response_chats`, `reply_threads`,
@@ -361,11 +378,14 @@ platforms:
361
378
  skills: ["support-triage", "incident-report"]
362
379
  ```
363
380
 
364
- Inline-native button callbacks, such as approvals and clarify choices, require
365
- the clicking actor to pass an explicit Inline or global Hermes allowlist, or
366
- `INLINE_ALLOW_ALL_USERS=true` / `GATEWAY_ALLOW_ALL_USERS=true`. This includes
367
- model-picker callbacks. The stricter callback gate prevents group-visible
368
- buttons from becoming a bypass when message intake is otherwise `open`.
381
+ Inline-native button callbacks, including approvals, clarify choices,
382
+ model pickers, and thread controls, use the same local restrictions and Hermes
383
+ authorization callback. Pairing-approved users can use them without a second
384
+ Inline allowlist. A denial, error, or unknown result from a registered callback
385
+ blocks the action. Only standalone adapters without a registered callback use
386
+ explicit Inline/global allowlists or allow-all settings as a fallback; `open`
387
+ alone never authorizes controls. Settings requests from unauthorized users show
388
+ an access guide without runtime or model information.
369
389
  Adapter-owned controls use `system:` action IDs and stay in these deterministic
370
390
  handlers. Agent-authored callbacks use `agent:` IDs and follow the ordinary
371
391
  message intake policy because they are conversational input, not approval or
@@ -416,6 +436,30 @@ the normal reply.
416
436
  The plugin id is `inline`, which is intentionally the same id an eventual
417
437
  bundled Hermes adapter should use.
418
438
 
439
+ ## Shared transcripts in groups and threads
440
+
441
+ To share one conversation transcript among admitted participants in a group
442
+ or thread, add these keys at the root of the `config.yaml` used by the Hermes
443
+ gateway process, outside `platforms.inline.extra`:
444
+
445
+ ```yaml
446
+ group_sessions_per_user: false
447
+ thread_sessions_per_user: false
448
+ ```
449
+
450
+ Hermes defaults `group_sessions_per_user` to `true` (groups split by sender)
451
+ and `thread_sessions_per_user` to `false` (threads shared). These settings
452
+ affect every messaging platform governed by that gateway configuration.
453
+ For separate profile gateways, configure each gateway's profile separately.
454
+ With a multiplexed gateway, do not assume a secondary profile's values control
455
+ its sessions; this adapter does not qualify per-profile session-policy overrides.
456
+
457
+ Distinct Hermes profile/gateway namespaces keep separate conversation sessions.
458
+ Swapping a bot token within one profile does not create a new transcript namespace.
459
+ These flags select shared sessions; they do not merge existing histories, import
460
+ all Inline history, grant permissions, or change access, mention, or bot wake rules.
461
+ Restart the gateway through its existing process manager after editing.
462
+
419
463
  ## Error Reporting And Privacy
420
464
 
421
465
  The Inline adapter and its supervised sidecar keep error reporting disabled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inline-chat/hermes-agent-adapter",
3
- "version": "0.0.20",
3
+ "version": "0.0.22-alpha.0",
4
4
  "description": "Hermes Agent platform adapter for Inline, with a native Python plugin and bundled Inline realtime sidecar.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -70,7 +70,10 @@ _DEDUP_MAX_SIZE = 5000
70
70
  _DEDUP_WINDOW_SECONDS = 48 * 3600
71
71
  _CHAT_INFO_CACHE_SECONDS = 10 * 60
72
72
  _CHAT_INFO_CACHE_MAX_SIZE = 512
73
- _BOT_SETTINGS_CHAT_INFO_TIMEOUT_SECONDS = 2.0
73
+ _CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS = 2.0
74
+ _INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS = 30.0
75
+ _INBOUND_RETRY_INITIAL_SECONDS = 1.0
76
+ _MAX_INBOUND_DELIVERIES = 16
74
77
  # Provider discovery may invoke host CLIs. Keep repeated panel opens and
75
78
  # unrelated mutations off that slow path while always injecting the live
76
79
  # current/default model into the document below.
@@ -191,6 +194,10 @@ _INLINE_DISPLAY_DEFAULTS = {
191
194
  }
192
195
 
193
196
 
197
+ class InlineInboundDeferred(RuntimeError):
198
+ """Pre-effect dependency unavailable; keep the SDK delivery unacknowledged."""
199
+
200
+
194
201
  class InlineSidecarError(RuntimeError):
195
202
  def __init__(self, path: str, status_code: int, message: str, error_kind: str = "unknown", raw: Optional[Any] = None):
196
203
  self.path = path
@@ -223,11 +230,12 @@ def _install_inline_display_defaults() -> None:
223
230
  defaults = getattr(_display_config, "_PLATFORM_DEFAULTS", None)
224
231
  if not isinstance(defaults, dict):
225
232
  return
233
+ inline_defaults = dict(_INLINE_DISPLAY_DEFAULTS)
226
234
  current = defaults.get("inline")
227
235
  if isinstance(current, dict):
228
- defaults["inline"] = {**_INLINE_DISPLAY_DEFAULTS, **current}
236
+ defaults["inline"] = {**inline_defaults, **current}
229
237
  else:
230
- defaults["inline"] = dict(_INLINE_DISPLAY_DEFAULTS)
238
+ defaults["inline"] = inline_defaults
231
239
  except Exception:
232
240
  logger.debug("[inline] failed to install display defaults", exc_info=True)
233
241
 
@@ -805,6 +813,8 @@ class InlineAdapter(BasePlatformAdapter):
805
813
  super().__init__(config, Platform("inline"))
806
814
  extra = config.extra or {}
807
815
 
816
+ if _truthy(os.getenv("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")):
817
+ logger.warning("[inline] experimental tool timelines are unavailable; ignoring INLINE_EXPERIMENTAL_AGENT_ACTIVITY and using ordinary typing")
808
818
  self._token = _config_token(config)
809
819
  self._base_url = os.getenv("INLINE_BASE_URL") or extra.get("base_url") or "https://api.inline.chat"
810
820
  self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or os.getenv("INLINE_SIDECAR_BIND"))
@@ -973,6 +983,7 @@ class InlineAdapter(BasePlatformAdapter):
973
983
  self._last_catalog_sync: Optional[Dict[str, Any]] = None
974
984
  self._inbound_task: Optional[asyncio.Task] = None
975
985
  self._bot_settings_tasks: set[asyncio.Task] = set()
986
+ self._inbound_deliveries: Dict[str, asyncio.Task] = {}
976
987
  self._bot_settings_locks: Dict[str, asyncio.Lock] = {}
977
988
  self._bot_settings_lock_users: Dict[str, int] = {}
978
989
  self._bot_settings_model_catalog_cache: Optional[tuple[float, List[Dict[str, Any]]]] = None
@@ -1147,7 +1158,7 @@ class InlineAdapter(BasePlatformAdapter):
1147
1158
  try:
1148
1159
  info = await asyncio.wait_for(
1149
1160
  self._get_chat_info(chat_id),
1150
- timeout=_BOT_SETTINGS_CHAT_INFO_TIMEOUT_SECONDS,
1161
+ timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
1151
1162
  )
1152
1163
  except asyncio.TimeoutError:
1153
1164
  logger.warning("[inline] agent settings chat metadata timed out")
@@ -1163,13 +1174,15 @@ class InlineAdapter(BasePlatformAdapter):
1163
1174
  scope_chat_id = parent_chat_id or chat_id
1164
1175
  is_reply_thread = bool(parent_chat_id)
1165
1176
  chat_type = self._bot_settings_chat_type(info)
1166
- can_inspect = self._allowed(chat_type, actor_id) and self._chat_allowed(
1167
- chat_id,
1168
- chat_id if is_reply_thread else None,
1169
- parent_chat_id,
1177
+ can_inspect = self._actor_authorization(
1178
+ chat_type, actor_id, chat_id,
1179
+ thread_id=chat_id if is_reply_thread else None,
1180
+ parent_chat_id=parent_chat_id,
1181
+ is_bot=_inline_sender_profile(event).get("bot") is True,
1170
1182
  )
1171
- if not can_inspect:
1172
- return {"access": "guideOnly", "scope_id": scope_chat_id, "reply_threads": "auto"}
1183
+ if can_inspect is not True:
1184
+ return {"access": "guideOnly", "scope_id": scope_chat_id, "reply_threads": "auto",
1185
+ **({"unavailable_reason": "authorization"} if can_inspect is None else {})}
1173
1186
 
1174
1187
  source = self.build_source(
1175
1188
  chat_id=chat_id,
@@ -1180,7 +1193,8 @@ class InlineAdapter(BasePlatformAdapter):
1180
1193
  parent_chat_id=parent_chat_id,
1181
1194
  )
1182
1195
  runner = self._bot_settings_runner()
1183
- can_modify = self._actor_authorized(chat_type, actor_id)
1196
+ can_modify = can_inspect
1197
+ can_set_reply_threads = not is_reply_thread or await self._chat_actor_authorized(scope_chat_id, actor_id)
1184
1198
  context: Dict[str, Any] = {
1185
1199
  "access": "full" if can_modify else "readOnly",
1186
1200
  "scope_id": scope_chat_id,
@@ -1189,7 +1203,8 @@ class InlineAdapter(BasePlatformAdapter):
1189
1203
  "chat_type": chat_type,
1190
1204
  "is_reply_thread": is_reply_thread,
1191
1205
  "following": None if chat_type == "dm" else self._chat_follow_mode_following(info),
1192
- "reply_threads": self._reply_thread_mode_for_chat(scope_chat_id),
1206
+ "reply_threads": self._reply_thread_mode_for_chat(scope_chat_id) if can_set_reply_threads else None,
1207
+ "can_set_reply_threads": can_set_reply_threads,
1193
1208
  "can_set_default_model": can_modify,
1194
1209
  "source": source,
1195
1210
  "runner": runner,
@@ -1323,7 +1338,7 @@ class InlineAdapter(BasePlatformAdapter):
1323
1338
  if context.get("access") == "guideOnly":
1324
1339
  unavailable_text = (
1325
1340
  "Hermes could not verify this chat. Try again when the bot is reachable."
1326
- if context.get("unavailable_reason") == "chat_metadata"
1341
+ if context.get("unavailable_reason") in {"chat_metadata", "authorization"}
1327
1342
  else "This chat is not allowed by Hermes' access policy."
1328
1343
  )
1329
1344
  return {
@@ -1386,15 +1401,26 @@ class InlineAdapter(BasePlatformAdapter):
1386
1401
  "id": "following", "label": "Following", "description": "Wake on eligible activity.", **common,
1387
1402
  "control": {"oneofKind": "toggle", "toggle": {"value": bool(context.get("following"))}},
1388
1403
  }]})
1389
- sections.append({"id": "replies", "items": [{
1390
- "id": "reply-threads", "label": "Reply in threads", **common,
1391
- "control": {"oneofKind": "select", "select": {
1392
- "value": context.get("reply_threads") or "auto",
1393
- "options": [
1394
- {"value": "auto", "label": "Auto", "description": "Agent decides.", "disabled": False},
1395
- {"value": "on", "label": "On", "description": "Always use threads.", "disabled": False},
1396
- {"value": "off", "label": "Off", "description": "Stay in chat.", "disabled": False},
1397
- ],
1404
+ if context.get("can_set_reply_threads"):
1405
+ sections.append({"id": "replies", "items": [{
1406
+ "id": "reply-threads", "label": "Reply in threads", **common,
1407
+ "control": {"oneofKind": "select", "select": {
1408
+ "value": context.get("reply_threads") or "auto",
1409
+ "options": [
1410
+ {"value": "auto", "label": "Auto", "description": "Agent decides.", "disabled": False},
1411
+ {"value": "on", "label": "On", "description": "Always use threads.", "disabled": False},
1412
+ {"value": "off", "label": "Off", "description": "Stay in chat.", "disabled": False},
1413
+ ],
1414
+ }},
1415
+ }]})
1416
+ else:
1417
+ sections.append({"id": "replies", "items": [{
1418
+ "id": "reply-threads-access", "label": "Reply in threads", "disabled": False,
1419
+ "control": {"oneofKind": "info", "info": {
1420
+ "text": ("Parent chat access is temporarily unavailable. Try again."
1421
+ if context.get("can_set_reply_threads") is None
1422
+ else "Changing reply mode requires access to the parent chat."),
1423
+ "tone": _INLINE_BOT_SETTINGS_TONE_WARNING,
1398
1424
  }},
1399
1425
  }]})
1400
1426
  if disabled:
@@ -1492,6 +1518,8 @@ class InlineAdapter(BasePlatformAdapter):
1492
1518
  self._invalidate_chat_info(context["chat_id"])
1493
1519
  return
1494
1520
  if item_id == "reply-threads" and value_body.get("oneofKind") == "stringValue":
1521
+ if not context.get("can_set_reply_threads"):
1522
+ raise ValueError("parent chat thread settings are not authorized")
1495
1523
  mode = str(value_body.get("stringValue") or "")
1496
1524
  if mode not in {"auto", "on", "off"}:
1497
1525
  raise ValueError("invalid reply mode")
@@ -1768,6 +1796,7 @@ class InlineAdapter(BasePlatformAdapter):
1768
1796
  *,
1769
1797
  chat_id: str,
1770
1798
  msg_id: str,
1799
+ from_id: str,
1771
1800
  text: str,
1772
1801
  chat_type: str,
1773
1802
  thread_id: Optional[str],
@@ -1779,6 +1808,9 @@ class InlineAdapter(BasePlatformAdapter):
1779
1808
 
1780
1809
  metadata = {"thread_id": thread_id} if thread_id else None
1781
1810
  target_chat_id = parent_chat_id or chat_id
1811
+ if target_chat_id != chat_id and not await self._chat_actor_authorized(target_chat_id, from_id):
1812
+ await self.send(chat_id, "Parent chat access could not be confirmed. Check access and try again.", reply_to=msg_id, metadata=metadata)
1813
+ return True
1782
1814
  if action == "on":
1783
1815
  self._set_reply_threads_for_chat(target_chat_id, "on")
1784
1816
  elif action == "off":
@@ -1935,6 +1967,12 @@ class InlineAdapter(BasePlatformAdapter):
1935
1967
  except Exception:
1936
1968
  pass
1937
1969
  self._inbound_task = None
1970
+ deliveries = list(self._inbound_deliveries.values())
1971
+ for task in deliveries:
1972
+ task.cancel()
1973
+ if deliveries:
1974
+ await asyncio.gather(*deliveries, return_exceptions=True)
1975
+ self._inbound_deliveries.clear()
1938
1976
  for task in list(self._bot_settings_tasks):
1939
1977
  task.cancel()
1940
1978
  if self._bot_settings_tasks:
@@ -2247,6 +2285,78 @@ class InlineAdapter(BasePlatformAdapter):
2247
2285
  event = json.loads(line)
2248
2286
  except json.JSONDecodeError:
2249
2287
  return
2288
+ delivery_id = event.get("_inlineDeliveryId")
2289
+ if not isinstance(delivery_id, str) or not delivery_id:
2290
+ await self._dispatch_inbound(event)
2291
+ return
2292
+ # The SDK bounds outstanding receipts and orders each chat. Retain only
2293
+ # their active coroutines; reconnect replay must not run one twice.
2294
+ if delivery_id in self._inbound_deliveries:
2295
+ return
2296
+ # An ACK may reach the sidecar while its response is lost, releasing an
2297
+ # SDK slot before this coroutine finishes. Bound those extra ACK waiters
2298
+ # too, using stream backpressure rather than another event queue.
2299
+ while len(self._inbound_deliveries) >= _MAX_INBOUND_DELIVERIES:
2300
+ await asyncio.wait(tuple(self._inbound_deliveries.values()), return_when=asyncio.FIRST_COMPLETED)
2301
+ task = asyncio.create_task(self._consume_inbound_delivery(delivery_id, event))
2302
+ self._inbound_deliveries[delivery_id] = task
2303
+
2304
+ def finished(completed: asyncio.Task) -> None:
2305
+ self._inbound_deliveries.pop(delivery_id, None)
2306
+ if not completed.cancelled():
2307
+ try:
2308
+ completed.result()
2309
+ except Exception as exc:
2310
+ self._report_error("inbound.delivery", exc)
2311
+ task.add_done_callback(finished)
2312
+
2313
+ async def _consume_inbound_delivery(self, delivery_id: str, event: Dict[str, Any]) -> None:
2314
+ try:
2315
+ await self._handle_inbound_delivery(delivery_id, event)
2316
+ except Exception as exc:
2317
+ # Do not ACK an unexpected failure or silently pin its chat forever.
2318
+ # Native notification shields runner teardown/reconnect from the
2319
+ # cancellation of this task during adapter.disconnect().
2320
+ self._report_error("inbound.delivery", exc)
2321
+ self._set_fatal_error("INBOUND_FAILED", str(exc), retryable=True)
2322
+ await self._notify_fatal_error()
2323
+
2324
+ async def _handle_inbound_delivery(self, delivery_id: str, event: Dict[str, Any]) -> None:
2325
+ delay = _INBOUND_RETRY_INITIAL_SECONDS
2326
+ attempts = 0
2327
+ while True:
2328
+ attempts += 1
2329
+ try:
2330
+ await self._dispatch_inbound(event)
2331
+ break
2332
+ except InlineSidecarError as exc:
2333
+ if exc.error_kind not in {"forbidden", "not_found"}:
2334
+ raise
2335
+ logger.info("[inline] inbound target is no longer accessible; retiring delivery")
2336
+ break
2337
+ except InlineInboundDeferred as exc:
2338
+ # Action events share the SDK's user-cursor barrier. A persistent
2339
+ # outage must not hold every chat behind a stale button press.
2340
+ if event.get("kind") == "message.action.invoke" and attempts >= 2:
2341
+ await self._answer_action(str(event.get("interactionId") or ""),
2342
+ "Temporarily unavailable. Please try again.")
2343
+ break
2344
+ logger.warning("[inline] inbound preflight unavailable; retrying in %.1fs: %s", delay, exc)
2345
+ await asyncio.sleep(delay)
2346
+ delay = min(delay * 2, 30.0)
2347
+ # A lost ACK response must retry only the ACK, never local commands or
2348
+ # model delivery. The endpoint is idempotent, including after reconnect.
2349
+ delay = _INBOUND_RETRY_INITIAL_SECONDS
2350
+ while True:
2351
+ try:
2352
+ await self._sidecar_call("/inbound/ack", {"deliveryId": delivery_id})
2353
+ return
2354
+ except Exception:
2355
+ logger.warning("[inline] inbound acknowledgement unavailable; retrying in %.1fs", delay)
2356
+ await asyncio.sleep(delay)
2357
+ delay = min(delay * 2, 30.0)
2358
+
2359
+ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
2250
2360
  self._me_id = str(event.get("meId") or self._me_id or "") or None
2251
2361
  self._me_username = _normalize_inline_username(event.get("meUsername") or self._me_username)
2252
2362
  # Chat snapshots include mutable dialog and routing fields such as
@@ -2257,10 +2367,16 @@ class InlineAdapter(BasePlatformAdapter):
2257
2367
  self._invalidate_chat_info(event.get("chatId"))
2258
2368
  kind = event.get("kind")
2259
2369
  if kind == "bot.chatSettings.request":
2260
- self._schedule_bot_settings_task(self._handle_bot_settings_request(event))
2370
+ if event.get("_inlineDeliveryId"):
2371
+ await self._handle_bot_settings_request(event)
2372
+ else:
2373
+ self._schedule_bot_settings_task(self._handle_bot_settings_request(event))
2261
2374
  return
2262
2375
  if kind == "bot.chatSettings.item.invoke":
2263
- self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
2376
+ if event.get("_inlineDeliveryId"):
2377
+ await self._handle_bot_settings_item(event)
2378
+ else:
2379
+ self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
2264
2380
  return
2265
2381
  if kind == "message.action.invoke":
2266
2382
  if await self._handle_action(event):
@@ -2331,7 +2447,14 @@ class InlineAdapter(BasePlatformAdapter):
2331
2447
 
2332
2448
  async def _dispatch_message(self, event: Dict[str, Any], *, edit: bool = False) -> None:
2333
2449
  agent_action = event.get("_inlineAgentAction") if isinstance(event.get("_inlineAgentAction"), dict) else None
2334
- msg = event.get("message") or {}
2450
+ msg = event.get("message")
2451
+ if not isinstance(msg, dict):
2452
+ return
2453
+ # History/join recovery and older sidecars can reach this entry directly.
2454
+ # Service fallback text and mentions are never conversational direction.
2455
+ raw = msg.get("raw") if isinstance(msg.get("raw"), dict) else {}
2456
+ if isinstance(msg.get("serviceMessage"), dict) or isinstance(raw.get("serviceMessage"), dict):
2457
+ return
2335
2458
  msg_id = str(msg.get("id") or "")
2336
2459
  chat_id = str(event.get("chatId") or msg.get("chatId") or "")
2337
2460
  if not msg_id or not chat_id:
@@ -2349,10 +2472,6 @@ class InlineAdapter(BasePlatformAdapter):
2349
2472
  dedup_key = f"new:{chat_id}:msg:{msg_id}:date:{message_date}"
2350
2473
  else:
2351
2474
  dedup_key = f"new:{chat_id}:msg:{msg_id}"
2352
- if self._is_duplicate(dedup_key):
2353
- return
2354
- if not edit and self._is_duplicate_message_instance(chat_id, msg, event):
2355
- return
2356
2475
  from_id = str(msg.get("fromId") or "")
2357
2476
  if msg.get("out") or (self._me_id and from_id == self._me_id):
2358
2477
  return
@@ -2361,18 +2480,7 @@ class InlineAdapter(BasePlatformAdapter):
2361
2480
 
2362
2481
  raw_message_text = str(msg.get("message") or "")
2363
2482
  text = raw_message_text.strip()
2364
- media_text, media_urls, media_types, message_type = await self._normalize_media(msg)
2365
- if media_text:
2366
- text = f"{text}\n{media_text}".strip() if text else media_text
2367
- if not text and not media_urls:
2368
- text = "[Inline message with no text]"
2369
2483
  explicitly_mentions_me = self._message_entity_mentions_me(msg)
2370
- if event.get("_inlineSenderProvenanceVerified") is False and not explicitly_mentions_me:
2371
- self._remember_observed_context(chat_id, msg, text)
2372
- return
2373
- if sender_profile.get("bot") is True and not explicitly_mentions_me:
2374
- self._remember_observed_context(chat_id, msg, text)
2375
- return
2376
2484
 
2377
2485
  chat_type = self._chat_type_from_message(msg)
2378
2486
  thread_id = self._thread_id_from_message(msg)
@@ -2381,7 +2489,20 @@ class InlineAdapter(BasePlatformAdapter):
2381
2489
  chat_name = chat_id
2382
2490
  chat_info: Dict[str, Any] = {}
2383
2491
  if chat_type == "group":
2384
- chat_info = await self._get_chat_info(chat_id)
2492
+ try:
2493
+ chat_info = await asyncio.wait_for(
2494
+ self._get_chat_info(chat_id, required=True),
2495
+ timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS if agent_action else _INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS,
2496
+ )
2497
+ except asyncio.TimeoutError as exc:
2498
+ raise InlineInboundDeferred("chat metadata timed out") from exc
2499
+ except InlineSidecarError as exc:
2500
+ if exc.error_kind in {"forbidden", "not_found"}:
2501
+ logger.info("[inline] chat is no longer accessible; retiring inbound delivery")
2502
+ return
2503
+ raise
2504
+ if not chat_info:
2505
+ raise InlineInboundDeferred("chat metadata unavailable")
2385
2506
  chat_name = self._chat_title_from_info(chat_info) or chat_id
2386
2507
  info_parent_chat_id = self._chat_info_id(chat_info, "parentChatId")
2387
2508
  info_parent_message_id = self._chat_info_id(chat_info, "parentMessageId")
@@ -2402,9 +2523,52 @@ class InlineAdapter(BasePlatformAdapter):
2402
2523
  )
2403
2524
  if has_command_target and not command_addressed_to_me:
2404
2525
  return
2526
+ actor_authorized = self._actor_authorization(
2527
+ chat_type, from_id, chat_id, thread_id=thread_id,
2528
+ parent_chat_id=parent_chat_id, is_bot=sender_profile.get("bot") is True,
2529
+ )
2530
+ if actor_authorized is None:
2531
+ raise InlineInboundDeferred("host authorization unavailable")
2532
+ if self._is_duplicate(dedup_key):
2533
+ return
2534
+ if not edit and self._is_duplicate_message_instance(chat_id, msg, event):
2535
+ return
2536
+ context_only = not explicitly_mentions_me and (
2537
+ event.get("_inlineSenderProvenanceVerified") is False or sender_profile.get("bot") is True
2538
+ )
2539
+ if not actor_authorized:
2540
+ if context_only:
2541
+ return
2542
+ # Let Hermes pair/ignore/decline the sender, without first downloading
2543
+ # media, collecting context, changing settings, or creating a thread.
2544
+ source = self.build_source(
2545
+ chat_id=chat_id, chat_name=chat_name, chat_type=chat_type,
2546
+ user_id=from_id, user_name=sender_name or None,
2547
+ thread_id=thread_id, parent_chat_id=parent_chat_id, message_id=msg_id,
2548
+ is_bot=sender_profile.get("bot") is True,
2549
+ )
2550
+ await self.handle_message(MessageEvent(
2551
+ text=text or "[Inline message with no text]", message_type=MessageType.TEXT,
2552
+ source=source, raw_message=event,
2553
+ message_id=build_inline_agent_action_turn_id(
2554
+ agent_action.get("messageId"), agent_action.get("interactionId"),
2555
+ ) if agent_action else msg_id,
2556
+ timestamp=self._timestamp(event.get("date") or msg.get("date")),
2557
+ allow_gateway_control=not bool(agent_action),
2558
+ ))
2559
+ return
2560
+ media_text, media_urls, media_types, message_type = await self._normalize_media(msg)
2561
+ if media_text:
2562
+ text = f"{text}\n{media_text}".strip() if text else media_text
2563
+ if not text and not media_urls:
2564
+ text = "[Inline message with no text]"
2565
+ if context_only:
2566
+ self._remember_observed_context(chat_id, msg, text)
2567
+ return
2405
2568
  if not agent_action and await self._handle_thread_command(
2406
2569
  chat_id=chat_id,
2407
2570
  msg_id=msg_id,
2571
+ from_id=from_id,
2408
2572
  text=text,
2409
2573
  chat_type=chat_type,
2410
2574
  thread_id=thread_id,
@@ -2584,6 +2748,7 @@ class InlineAdapter(BasePlatformAdapter):
2584
2748
  chat_type=chat_type,
2585
2749
  user_id=from_id,
2586
2750
  user_name=sender_name or None,
2751
+ is_bot=sender_profile.get("bot") is True,
2587
2752
  thread_id=thread_id,
2588
2753
  parent_chat_id=parent_chat_id,
2589
2754
  message_id=msg_id,
@@ -2618,7 +2783,6 @@ class InlineAdapter(BasePlatformAdapter):
2618
2783
 
2619
2784
  async def _dispatch_agent_action(self, event: Dict[str, Any]) -> None:
2620
2785
  interaction_id = str(event.get("interactionId") or "")
2621
- await self._answer_action(interaction_id, "")
2622
2786
 
2623
2787
  chat_id = str(event.get("chatId") or "")
2624
2788
  message_id = str(event.get("messageId") or "")
@@ -2626,13 +2790,19 @@ class InlineAdapter(BasePlatformAdapter):
2626
2790
  if not chat_id or not message_id or not interaction_id or not actor_user_id:
2627
2791
  logger.warning("[inline] ignored incomplete agent action event")
2628
2792
  return
2629
- target = await self._fetch_message(chat_id, message_id)
2793
+ try:
2794
+ target = await asyncio.wait_for(
2795
+ self._fetch_message(chat_id, message_id, required=True),
2796
+ timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
2797
+ )
2798
+ except asyncio.TimeoutError as exc:
2799
+ raise InlineInboundDeferred("action target lookup timed out") from exc
2630
2800
  if not target:
2631
- logger.info("[inline] ignored agent action for unavailable message %s", message_id)
2801
+ await self._answer_action(interaction_id, "Action expired or no longer accessible.")
2632
2802
  return
2633
2803
 
2634
2804
  synthetic_message = dict(target)
2635
- for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies"):
2805
+ for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies", "sender"):
2636
2806
  synthetic_message.pop(stale_key, None)
2637
2807
  synthetic_message.update({
2638
2808
  "id": message_id,
@@ -2653,6 +2823,7 @@ class InlineAdapter(BasePlatformAdapter):
2653
2823
  "message": synthetic_message,
2654
2824
  "_inlineAgentAction": dict(event),
2655
2825
  })
2826
+ await self._answer_action(interaction_id, "")
2656
2827
 
2657
2828
  def _message_explicitly_mentions_me(self, msg: Dict[str, Any]) -> bool:
2658
2829
  if not self._me_id:
@@ -2758,13 +2929,19 @@ class InlineAdapter(BasePlatformAdapter):
2758
2929
  if self._me_id and user_id == self._me_id:
2759
2930
  return
2760
2931
  key = f"{event.get('kind')}:{chat_id}:{message_id}:{user_id}:{emoji}:{event.get('seq') or ''}"
2761
- if self._is_duplicate(key):
2762
- return
2763
2932
 
2764
2933
  target = await self._fetch_message(chat_id, message_id)
2765
2934
  chat_type = self._chat_type_from_message(target or {"peerId": {"type": {"oneofKind": "chat"}}})
2766
2935
  if not self._allowed(chat_type, user_id):
2767
2936
  return
2937
+ scope = await self._message_scope(chat_id, target or {}, required=True)
2938
+ if scope is None:
2939
+ return
2940
+ chat_type, thread_id, parent_chat_id = scope
2941
+ if chat_type == "group" and not self._chat_allowed(chat_id, thread_id, parent_chat_id):
2942
+ return
2943
+ if self._is_duplicate(key):
2944
+ return
2768
2945
  target_text = str((target or {}).get("message") or "") or None
2769
2946
  target_author = str((target or {}).get("fromId") or "") or None
2770
2947
  target_is_own = bool(self._me_id and target_author == self._me_id)
@@ -2777,6 +2954,8 @@ class InlineAdapter(BasePlatformAdapter):
2777
2954
  chat_type=chat_type,
2778
2955
  user_id=user_id,
2779
2956
  user_name=_inline_sender_identity(_inline_sender_profile(event))[0] or None,
2957
+ thread_id=thread_id,
2958
+ parent_chat_id=parent_chat_id,
2780
2959
  message_id=key,
2781
2960
  )
2782
2961
  await self.handle_message(MessageEvent(
@@ -2813,7 +2992,13 @@ class InlineAdapter(BasePlatformAdapter):
2813
2992
  text = "message:history_cleared"
2814
2993
  if not chat_id or not text:
2815
2994
  return
2816
- if self._group_policy == "disabled":
2995
+ if self._group_policy == "disabled" or (user_id and not self._allowed("group", user_id)):
2996
+ return
2997
+ scope = await self._message_scope(chat_id, {}, required=True)
2998
+ if scope is None:
2999
+ return
3000
+ _, thread_id, parent_chat_id = scope
3001
+ if not self._chat_allowed(chat_id, thread_id, parent_chat_id):
2817
3002
  return
2818
3003
  key = f"{kind}:{chat_id}:{user_id}:{event.get('seq') or ''}:{text}"
2819
3004
  if self._is_duplicate(key):
@@ -2824,8 +3009,12 @@ class InlineAdapter(BasePlatformAdapter):
2824
3009
  chat_type="group",
2825
3010
  user_id=user_id or None,
2826
3011
  user_name=_inline_sender_identity(_inline_sender_profile(event))[0] or None,
3012
+ thread_id=thread_id,
3013
+ parent_chat_id=parent_chat_id,
2827
3014
  message_id=key,
2828
3015
  )
3016
+ # Delete/history events may have no actor. Preserve the host's own
3017
+ # authorization of these events rather than inventing a sender grant.
2829
3018
  await self.handle_message(MessageEvent(
2830
3019
  text=text,
2831
3020
  message_type=MessageType.TEXT,
@@ -3665,7 +3854,7 @@ class InlineAdapter(BasePlatformAdapter):
3665
3854
  return None
3666
3855
  return str(reply_to)
3667
3856
 
3668
- async def _get_chat_info(self, chat_id: str) -> Dict[str, Any]:
3857
+ async def _get_chat_info(self, chat_id: str, *, required: bool = False) -> Dict[str, Any]:
3669
3858
  target = _target_from_chat_id(chat_id)
3670
3859
  normalized = str(target.get("chatId") or "").strip()
3671
3860
  if not normalized:
@@ -3679,7 +3868,16 @@ class InlineAdapter(BasePlatformAdapter):
3679
3868
  data = await self._sidecar_call("/chat", {"target": {"chatId": normalized}})
3680
3869
  result = data.get("result") if isinstance(data, dict) else None
3681
3870
  info = result if isinstance(result, dict) else {}
3682
- except Exception:
3871
+ except Exception as exc:
3872
+ if required:
3873
+ if isinstance(exc, InlineSidecarError) and exc.error_kind in {"forbidden", "not_found"}:
3874
+ raise
3875
+ raise InlineInboundDeferred("chat metadata request failed") from exc
3876
+ return {}
3877
+ resolved_id = self._chat_info_id(info, "id")
3878
+ if not info or (resolved_id is not None and resolved_id != normalized):
3879
+ if required:
3880
+ raise InlineInboundDeferred("chat metadata unavailable or mismatched")
3683
3881
  return {}
3684
3882
  self._chat_info_cache[normalized] = (now, info)
3685
3883
  self._chat_info_cache.move_to_end(normalized)
@@ -3779,12 +3977,14 @@ class InlineAdapter(BasePlatformAdapter):
3779
3977
  return self._id_allowed(self._group_allow_from, from_id)
3780
3978
  return True
3781
3979
 
3782
- async def _fetch_message(self, chat_id: str, message_id: str) -> Optional[Dict[str, Any]]:
3980
+ async def _fetch_message(self, chat_id: str, message_id: str, *, required: bool = False) -> Optional[Dict[str, Any]]:
3783
3981
  try:
3784
3982
  data = await self._sidecar_call("/messages", {"target": _target_from_chat_id(chat_id), "messageIds": [message_id]})
3785
3983
  messages = (data.get("result") or {}).get("messages") or []
3786
3984
  return messages[0] if messages else None
3787
- except Exception:
3985
+ except Exception as exc:
3986
+ if required and not (isinstance(exc, InlineSidecarError) and exc.error_kind in {"forbidden", "not_found"}):
3987
+ raise InlineInboundDeferred("action target temporarily unavailable") from exc
3788
3988
  return None
3789
3989
 
3790
3990
  async def _handle_action(self, event: Dict[str, Any]) -> bool:
@@ -3825,43 +4025,117 @@ class InlineAdapter(BasePlatformAdapter):
3825
4025
  async def _action_allowed(self, event: Dict[str, Any]) -> bool:
3826
4026
  actor_id = str(event.get("actorUserId") or "").strip()
3827
4027
  interaction_id = str(event.get("interactionId") or "")
3828
- chat_type = await self._action_chat_type(event)
3829
- if self._actor_authorized(chat_type, actor_id):
3830
- return True
3831
- await self._answer_action(interaction_id, "Not authorized")
3832
- logger.info("[inline] blocked action actor=%s chat_type=%s action=%s", actor_id or "unknown", chat_type or "unknown", event.get("actionId") or "")
4028
+ chat_id = str(event.get("chatId") or "")
4029
+ message_id = str(event.get("messageId") or "")
4030
+ msg = await self._fetch_message(chat_id, message_id) if chat_id and message_id else None
4031
+ scope = await self._message_scope(chat_id, msg) if msg else None
4032
+ verdict = None
4033
+ if scope is not None:
4034
+ chat_type, thread_id, parent_chat_id = scope
4035
+ verdict = self._actor_authorization(
4036
+ chat_type, actor_id, chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id,
4037
+ is_bot=_inline_sender_profile(event).get("bot") is True,
4038
+ )
4039
+ if verdict is True:
4040
+ return True
4041
+ await self._answer_action(interaction_id, "Access check temporarily unavailable. Try again." if verdict is None else "Not authorized")
4042
+ logger.info("[inline] blocked action actor=%s chat=%s action=%s", actor_id or "unknown", chat_id or "unknown", event.get("actionId") or "")
3833
4043
  return False
3834
4044
 
3835
- def _actor_authorized(self, chat_type: Optional[str], actor_id: str) -> bool:
3836
- if not actor_id or not chat_type:
4045
+ async def _message_scope(
4046
+ self, chat_id: str, msg: Dict[str, Any], *, required: bool = False,
4047
+ ) -> Optional[tuple[str, Optional[str], Optional[str]]]:
4048
+ """Resolve the same parent/thread identity used by normal message intake."""
4049
+ chat_type = self._chat_type_from_message(msg)
4050
+ thread_id = self._thread_id_from_message(msg)
4051
+ parent_chat_id = self._parent_chat_id_from_message(msg)
4052
+ if chat_type == "group" and not thread_id:
4053
+ if not parent_chat_id:
4054
+ try:
4055
+ info = await asyncio.wait_for(
4056
+ self._get_chat_info(chat_id, required=required),
4057
+ timeout=_INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS if required else _CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
4058
+ )
4059
+ except asyncio.TimeoutError as exc:
4060
+ if required:
4061
+ raise InlineInboundDeferred("chat metadata timed out") from exc
4062
+ return None
4063
+ if not info:
4064
+ if required:
4065
+ raise InlineInboundDeferred("chat metadata unavailable")
4066
+ return None
4067
+ parent_chat_id = self._chat_info_id(info, "parentChatId")
4068
+ if parent_chat_id:
4069
+ thread_id = chat_id
4070
+ return chat_type, thread_id, parent_chat_id
4071
+
4072
+ async def _chat_actor_authorized(self, chat_id: str, actor_id: str) -> Optional[bool]:
4073
+ """Check the actual target when a child control changes parent-wide state."""
4074
+ try:
4075
+ info = await asyncio.wait_for(
4076
+ self._get_chat_info(chat_id), timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
4077
+ )
4078
+ except asyncio.TimeoutError:
4079
+ return None
4080
+ if not info:
4081
+ return None
4082
+ parent_chat_id = self._chat_info_id(info, "parentChatId")
4083
+ return self._actor_authorization(
4084
+ self._bot_settings_chat_type(info), actor_id, chat_id,
4085
+ thread_id=chat_id if parent_chat_id else None, parent_chat_id=parent_chat_id,
4086
+ )
4087
+
4088
+ def _actor_authorized(
4089
+ self, chat_type: Optional[str], actor_id: str, chat_id: Optional[str] = None, *,
4090
+ thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None, is_bot: bool = False,
4091
+ ) -> bool:
4092
+ return self._actor_authorization(
4093
+ chat_type, actor_id, chat_id, thread_id=thread_id,
4094
+ parent_chat_id=parent_chat_id, is_bot=is_bot,
4095
+ ) is True
4096
+
4097
+ def _actor_authorization(
4098
+ self, chat_type: Optional[str], actor_id: str, chat_id: Optional[str] = None, *,
4099
+ thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None, is_bot: bool = False,
4100
+ ) -> Optional[bool]:
4101
+ # The host may trust an adapter's allowlist policy on the assumption that
4102
+ # intake already checked it. Always enforce our restrictions before asking.
4103
+ if not actor_id or chat_type not in {"dm", "group"} or not self._allowed(chat_type, actor_id):
3837
4104
  return False
4105
+ if chat_type == "group" and not self._chat_allowed(chat_id or "", thread_id, parent_chat_id):
4106
+ return False
4107
+ if getattr(self, "_authorization_check", None) is not None:
4108
+ # Hermes owns pairing, profile routing, and runtime authorization.
4109
+ # Unknown/error is unavailable, never a reason to bypass a wired checker.
4110
+ runner = getattr(self, "gateway_runner", None)
4111
+ if parent_chat_id and runner is not None:
4112
+ # The three-argument native callback drops parent_chat_id. Inline
4113
+ # child chats need it for parent-routed profiles, so ask the same
4114
+ # host predicate with the complete source, retaining transport identity.
4115
+ try:
4116
+ source = self.build_source(
4117
+ chat_id=chat_id or "", chat_type=chat_type, user_id=actor_id,
4118
+ thread_id=thread_id, parent_chat_id=parent_chat_id, is_bot=is_bot,
4119
+ )
4120
+ if getattr(source, "profile_route_rejected", False):
4121
+ return False
4122
+ verdict = runner._is_user_authorized_for_source(source)
4123
+ return verdict if isinstance(verdict, bool) else None
4124
+ except Exception:
4125
+ logger.warning("[inline] thread authorization unavailable", exc_info=True)
4126
+ return None
4127
+ return self._is_sender_authorized(
4128
+ actor_id, chat_type, chat_id, is_bot=is_bot, thread_id=thread_id,
4129
+ )
4130
+ # Standalone adapters have no runner. Only explicit grants are usable;
4131
+ # an open intake policy alone must not authorize credentialed side effects.
3838
4132
  if self._allow_all or _truthy(os.getenv("GATEWAY_ALLOW_ALL_USERS"), False):
3839
4133
  return True
3840
4134
  if self._id_allowed(self._parse_id_set(os.getenv("GATEWAY_ALLOWED_USERS")), actor_id):
3841
4135
  return True
3842
- if chat_type == "dm":
3843
- if self._dm_policy == "disabled":
3844
- return False
3845
- if self._dm_policy == "allowlist" or self._allow_from:
3846
- return self._id_allowed(self._allow_from, actor_id)
3847
- return False
3848
- if self._group_policy == "disabled":
3849
- return False
3850
- if self._id_allowed(self._group_allow_from, actor_id):
3851
- return True
3852
- if self._id_allowed(self._allow_from, actor_id):
3853
- return True
3854
- return False
3855
-
3856
- async def _action_chat_type(self, event: Dict[str, Any]) -> Optional[str]:
3857
- chat_id = str(event.get("chatId") or "")
3858
- message_id = str(event.get("messageId") or "")
3859
- if not chat_id or not message_id:
3860
- return None
3861
- msg = await self._fetch_message(chat_id, message_id)
3862
- if not msg:
3863
- return None
3864
- return self._chat_type_from_message(msg)
4136
+ return self._id_allowed(self._allow_from, actor_id) or (
4137
+ chat_type == "group" and self._id_allowed(self._group_allow_from, actor_id)
4138
+ )
3865
4139
 
3866
4140
  async def _handle_clarify_action(self, event: Dict[str, Any]) -> bool:
3867
4141
  action_id = str(event.get("actionId") or "")
@@ -3990,7 +4264,12 @@ class InlineAdapter(BasePlatformAdapter):
3990
4264
  if not target_chat_id:
3991
4265
  await self._answer_action(interaction_id, "Thread controls expired")
3992
4266
  return True
3993
- if not await self._thread_action_allowed(event, state):
4267
+ if not await self._action_allowed(event):
4268
+ return True
4269
+ if target_chat_id != chat_id and not await self._chat_actor_authorized(
4270
+ target_chat_id, str(event.get("actorUserId") or ""),
4271
+ ):
4272
+ await self._answer_action(interaction_id, "Parent chat access could not be confirmed. Check access and try again.")
3994
4273
  return True
3995
4274
  try:
3996
4275
  if choice == "reset":
@@ -4012,24 +4291,6 @@ class InlineAdapter(BasePlatformAdapter):
4012
4291
  await self._answer_action(interaction_id, "Thread setting failed")
4013
4292
  return True
4014
4293
 
4015
- async def _thread_action_allowed(self, event: Dict[str, Any], state: Dict[str, Any]) -> bool:
4016
- actor_id = str(event.get("actorUserId") or "").strip()
4017
- interaction_id = str(event.get("interactionId") or "")
4018
- chat_type = await self._action_chat_type(event)
4019
- if self._actor_authorized(chat_type, actor_id):
4020
- return True
4021
- display_chat_id = self._chat_key(state.get("display_chat_id"))
4022
- target_chat_id = self._chat_key(state.get("target_chat_id"))
4023
- if chat_type and actor_id and self._allowed(chat_type, actor_id):
4024
- if chat_type == "dm":
4025
- return True
4026
- thread_id = display_chat_id if target_chat_id and display_chat_id != target_chat_id else None
4027
- if self._chat_allowed(display_chat_id or str(event.get("chatId") or ""), thread_id, target_chat_id):
4028
- return True
4029
- await self._answer_action(interaction_id, "Not authorized")
4030
- logger.info("[inline] blocked thread action actor=%s chat_type=%s action=%s", actor_id or "unknown", chat_type or "unknown", event.get("actionId") or "")
4031
- return False
4032
-
4033
4294
  @staticmethod
4034
4295
  def _is_model_picker_action(action_id: str) -> bool:
4035
4296
  return action_id.startswith(("mp:", "mpg:", "mm:", "mc:", "mg:", "mb:", "mx:"))
@@ -27,7 +27,7 @@ _CLI_INSTALL_URL = "https://inline.chat/cli/install.sh"
27
27
  _MAX_TOKEN_BYTES = 16 * 1024
28
28
  _MAX_PROBE_RESPONSE_BYTES = 64 * 1024
29
29
  _MACHINE_SETUP_PROTOCOL_VERSION = 1
30
- _PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.20"
30
+ _PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.22-alpha.0"
31
31
  _ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
32
32
 
33
33
 
@@ -514,15 +514,17 @@ def _compatibility_status() -> dict:
514
514
  return {"ok": False, "reason": "plugin_load_failed"}
515
515
  if "inline" not in loaded.tools_registered:
516
516
  return {"ok": False, "reason": "tool_not_registered"}
517
- # Older supported Hermes releases predate the moved-import scanner.
518
- # Their loader still checks real imports and plugin registration above.
517
+ # The moved-import scanner is optional: hosts may predate it or retain
518
+ # only updater stubs after removing the compat layer. Actual loading and
519
+ # this installation's tool registration above remain mandatory.
519
520
  try:
520
- from hermes_cli.plugin_compat import plugin_hits
521
+ import hermes_cli.plugin_compat as plugin_compat
521
522
  except ModuleNotFoundError as error:
522
523
  if error.name != "hermes_cli.plugin_compat":
523
524
  raise
524
525
  else:
525
- if plugin_hits(loaded.manifest):
526
+ plugin_hits = getattr(plugin_compat, "plugin_hits", None)
527
+ if plugin_hits is not None and plugin_hits(loaded.manifest):
526
528
  return {"ok": False, "reason": "deprecated_imports"}
527
529
  return {"ok": True, "reason": "loaded", "pluginPath": str(plugin_dir)}
528
530
  except Exception:
@@ -3,7 +3,7 @@ label: Inline
3
3
  kind: platform
4
4
  provides_tools:
5
5
  - inline
6
- version: 0.0.20
6
+ version: 0.0.22-alpha.0
7
7
  requires_hermes: ">=0.21.3"
8
8
  description: >
9
9
  Inline platform adapter for Hermes Agent. The adapter runs as a native
@@ -5599,10 +5599,13 @@ var require_websocket_server = __commonJS(function(exports, module) {
5599
5599
  import http from "node:http";
5600
5600
 
5601
5601
  // src/sidecar/inbound-stream.ts
5602
+ import { randomUUID } from "node:crypto";
5603
+
5602
5604
  class InboundStream {
5603
5605
  consumer = null;
5604
5606
  stopped = false;
5605
5607
  changed = new Set;
5608
+ pending = new Map;
5606
5609
  attach(consumer) {
5607
5610
  if (this.stopped) {
5608
5611
  consumer.end();
@@ -5621,6 +5624,13 @@ class InboundStream {
5621
5624
  this.wake();
5622
5625
  previous?.end();
5623
5626
  }
5627
+ acknowledge(deliveryId) {
5628
+ const receipt = this.pending.get(deliveryId);
5629
+ if (!receipt)
5630
+ return;
5631
+ receipt.acknowledged = true;
5632
+ this.wake();
5633
+ }
5624
5634
  close() {
5625
5635
  this.stopped = true;
5626
5636
  const previous = this.consumer;
@@ -5629,54 +5639,41 @@ class InboundStream {
5629
5639
  previous?.end();
5630
5640
  }
5631
5641
  async deliver(event) {
5632
- const line = JSON.stringify(event) + `
5642
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
5643
+ throw new Error("Inbound delivery requires an event object");
5644
+ }
5645
+ const deliveryId = randomUUID();
5646
+ const line = JSON.stringify({ ...event, _inlineDeliveryId: deliveryId }) + `
5633
5647
  `;
5634
- while (!this.stopped) {
5635
- const owner = this.consumer;
5636
- if (!owner) {
5637
- await new Promise((resolve) => this.changed.add(resolve));
5638
- continue;
5639
- }
5640
- let cleanup = () => {};
5641
- const drained = new Promise((resolve) => {
5642
- const finish = (ok) => {
5643
- cleanup();
5644
- resolve(ok);
5645
- };
5646
- const onDrain = () => finish(true);
5647
- const onChange = () => finish(false);
5648
- cleanup = () => {
5649
- owner.off("drain", onDrain);
5650
- owner.off("close", onChange);
5651
- owner.off("error", onChange);
5652
- this.changed.delete(onChange);
5653
- };
5654
- owner.once("drain", onDrain);
5655
- owner.once("close", onChange);
5656
- owner.once("error", onChange);
5657
- this.changed.add(onChange);
5658
- });
5659
- try {
5660
- if (owner.destroyed || owner.writableEnded) {
5661
- if (this.consumer === owner)
5662
- this.consumer = null;
5648
+ const receipt = { acknowledged: false };
5649
+ this.pending.set(deliveryId, receipt);
5650
+ let writtenTo = null;
5651
+ try {
5652
+ while (!this.stopped) {
5653
+ if (receipt.acknowledged)
5654
+ return;
5655
+ const owner = this.consumer;
5656
+ if (owner && owner !== writtenTo) {
5657
+ try {
5658
+ if (owner.destroyed || owner.writableEnded) {
5659
+ if (this.consumer === owner)
5660
+ this.consumer = null;
5661
+ continue;
5662
+ }
5663
+ writtenTo = owner;
5664
+ owner.write(line);
5665
+ } catch {
5666
+ if (this.consumer === owner)
5667
+ this.consumer = null;
5668
+ }
5663
5669
  continue;
5664
5670
  }
5665
- const accepted = owner.write(line);
5666
- if (this.consumer !== owner || this.stopped)
5667
- continue;
5668
- if (accepted)
5669
- return;
5670
- if (await drained && this.consumer === owner && !this.stopped)
5671
- return;
5672
- } catch {
5673
- if (this.consumer === owner)
5674
- this.consumer = null;
5675
- } finally {
5676
- cleanup();
5671
+ await new Promise((resolve) => this.changed.add(resolve));
5677
5672
  }
5673
+ throw new Error("Inbound stream closed before delivery completed");
5674
+ } finally {
5675
+ this.pending.delete(deliveryId);
5678
5676
  }
5679
- throw new Error("Inbound stream closed before delivery completed");
5680
5677
  }
5681
5678
  wake() {
5682
5679
  for (const resolve of this.changed)
@@ -5705,6 +5702,13 @@ var botSettingsEventKinds = new Set([
5705
5702
  function inboundEventNeedsSenderResolution(event) {
5706
5703
  return !botSettingsEventKinds.has(event.kind ?? "");
5707
5704
  }
5705
+ function inboundEventIsServiceMessage(event) {
5706
+ if (event.kind !== "message.new" && event.kind !== "message.edit")
5707
+ return false;
5708
+ const message = asOptionalRecord(event.message);
5709
+ const raw = asOptionalRecord(message?.raw);
5710
+ return Boolean(asOptionalRecord(message?.serviceMessage) || asOptionalRecord(raw?.serviceMessage));
5711
+ }
5708
5712
 
5709
5713
  class SidecarError extends Error {
5710
5714
  errorKind;
@@ -5826,6 +5830,7 @@ function normalizeMessage(message) {
5826
5830
  replies: message.replies ?? null,
5827
5831
  actions: message.actions ?? null,
5828
5832
  rev: message.rev ?? null,
5833
+ ...asOptionalRecord(message.serviceMessage) ? { serviceMessage: message.serviceMessage } : {},
5829
5834
  raw: message
5830
5835
  };
5831
5836
  }
@@ -6024,6 +6029,8 @@ function explicitlyMentionsSelf(event, meId) {
6024
6029
  return message?.entities?.entities?.some((e) => e.entity?.oneofKind === "mention" && e.entity.mention?.userId?.toString() === meId) ?? false;
6025
6030
  }
6026
6031
  async function deliverInboundEvent(event, owner) {
6032
+ if (inboundEventIsServiceMessage(event))
6033
+ return;
6027
6034
  const explicitMention = explicitlyMentionsSelf(event, owner.meId);
6028
6035
  while (!owner.signal.aborted) {
6029
6036
  const resolution = explicitMention ? { provenanceVerified: false } : inboundEventNeedsSenderResolution(event) ? await owner.resolveSender(event) : { provenanceVerified: true };
@@ -46757,7 +46764,7 @@ function readUsers(result, kind) {
46757
46764
  }
46758
46765
 
46759
46766
  // src/sidecar/telemetry.ts
46760
- import { randomUUID } from "node:crypto";
46767
+ import { randomUUID as randomUUID2 } from "node:crypto";
46761
46768
  import { readFile as readFile2 } from "node:fs/promises";
46762
46769
  var TELEMETRY_TIMEOUT_MS = 2000;
46763
46770
  var TELEMETRY_DEDUP_MS = 5 * 60000;
@@ -46818,7 +46825,7 @@ function buildHermesTelemetryEvent(operation, error, options = {}) {
46818
46825
  const exception = error instanceof Error ? error : new Error(String(error ?? "Unknown error"));
46819
46826
  const frames = parseStack(exception, env);
46820
46827
  return {
46821
- event_id: randomUUID().replaceAll("-", ""),
46828
+ event_id: randomUUID2().replaceAll("-", ""),
46822
46829
  timestamp: new Date().toISOString(),
46823
46830
  platform: "javascript",
46824
46831
  level: "error",
@@ -47090,6 +47097,13 @@ async function handleRequest(req, res) {
47090
47097
  writeJson(res, 405, { ok: false, error: "method not allowed", errorKind: "bad_format" });
47091
47098
  return;
47092
47099
  }
47100
+ if (url.pathname === "/inbound/ack") {
47101
+ const body2 = asRecord(await readJsonBody(req));
47102
+ const deliveryId = readRequiredString(body2, "deliveryId");
47103
+ inboundStream.acknowledge(deliveryId);
47104
+ writeJson(res, 200, { ok: true, result: {} });
47105
+ return;
47106
+ }
47093
47107
  if (!connected && url.pathname !== "/shutdown") {
47094
47108
  writeJson(res, 503, {
47095
47109
  ok: false,