@inline-chat/hermes-agent-adapter 0.0.20 → 0.0.21

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.
@@ -155,7 +156,7 @@ uv run ./hermes plugins list --plain --no-bundled
155
156
  Expected local output includes:
156
157
 
157
158
  ```text
158
- enabled user 0.0.20 inline-platform
159
+ enabled user 0.0.21 inline-platform
159
160
  ```
160
161
 
161
162
  ## Update Or Reinstall
@@ -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
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.21",
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
@@ -973,6 +980,7 @@ class InlineAdapter(BasePlatformAdapter):
973
980
  self._last_catalog_sync: Optional[Dict[str, Any]] = None
974
981
  self._inbound_task: Optional[asyncio.Task] = None
975
982
  self._bot_settings_tasks: set[asyncio.Task] = set()
983
+ self._inbound_deliveries: Dict[str, asyncio.Task] = {}
976
984
  self._bot_settings_locks: Dict[str, asyncio.Lock] = {}
977
985
  self._bot_settings_lock_users: Dict[str, int] = {}
978
986
  self._bot_settings_model_catalog_cache: Optional[tuple[float, List[Dict[str, Any]]]] = None
@@ -1147,7 +1155,7 @@ class InlineAdapter(BasePlatformAdapter):
1147
1155
  try:
1148
1156
  info = await asyncio.wait_for(
1149
1157
  self._get_chat_info(chat_id),
1150
- timeout=_BOT_SETTINGS_CHAT_INFO_TIMEOUT_SECONDS,
1158
+ timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
1151
1159
  )
1152
1160
  except asyncio.TimeoutError:
1153
1161
  logger.warning("[inline] agent settings chat metadata timed out")
@@ -1163,13 +1171,15 @@ class InlineAdapter(BasePlatformAdapter):
1163
1171
  scope_chat_id = parent_chat_id or chat_id
1164
1172
  is_reply_thread = bool(parent_chat_id)
1165
1173
  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,
1174
+ can_inspect = self._actor_authorization(
1175
+ chat_type, actor_id, chat_id,
1176
+ thread_id=chat_id if is_reply_thread else None,
1177
+ parent_chat_id=parent_chat_id,
1178
+ is_bot=_inline_sender_profile(event).get("bot") is True,
1170
1179
  )
1171
- if not can_inspect:
1172
- return {"access": "guideOnly", "scope_id": scope_chat_id, "reply_threads": "auto"}
1180
+ if can_inspect is not True:
1181
+ return {"access": "guideOnly", "scope_id": scope_chat_id, "reply_threads": "auto",
1182
+ **({"unavailable_reason": "authorization"} if can_inspect is None else {})}
1173
1183
 
1174
1184
  source = self.build_source(
1175
1185
  chat_id=chat_id,
@@ -1180,7 +1190,8 @@ class InlineAdapter(BasePlatformAdapter):
1180
1190
  parent_chat_id=parent_chat_id,
1181
1191
  )
1182
1192
  runner = self._bot_settings_runner()
1183
- can_modify = self._actor_authorized(chat_type, actor_id)
1193
+ can_modify = can_inspect
1194
+ can_set_reply_threads = not is_reply_thread or await self._chat_actor_authorized(scope_chat_id, actor_id)
1184
1195
  context: Dict[str, Any] = {
1185
1196
  "access": "full" if can_modify else "readOnly",
1186
1197
  "scope_id": scope_chat_id,
@@ -1189,7 +1200,8 @@ class InlineAdapter(BasePlatformAdapter):
1189
1200
  "chat_type": chat_type,
1190
1201
  "is_reply_thread": is_reply_thread,
1191
1202
  "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),
1203
+ "reply_threads": self._reply_thread_mode_for_chat(scope_chat_id) if can_set_reply_threads else None,
1204
+ "can_set_reply_threads": can_set_reply_threads,
1193
1205
  "can_set_default_model": can_modify,
1194
1206
  "source": source,
1195
1207
  "runner": runner,
@@ -1323,7 +1335,7 @@ class InlineAdapter(BasePlatformAdapter):
1323
1335
  if context.get("access") == "guideOnly":
1324
1336
  unavailable_text = (
1325
1337
  "Hermes could not verify this chat. Try again when the bot is reachable."
1326
- if context.get("unavailable_reason") == "chat_metadata"
1338
+ if context.get("unavailable_reason") in {"chat_metadata", "authorization"}
1327
1339
  else "This chat is not allowed by Hermes' access policy."
1328
1340
  )
1329
1341
  return {
@@ -1386,15 +1398,26 @@ class InlineAdapter(BasePlatformAdapter):
1386
1398
  "id": "following", "label": "Following", "description": "Wake on eligible activity.", **common,
1387
1399
  "control": {"oneofKind": "toggle", "toggle": {"value": bool(context.get("following"))}},
1388
1400
  }]})
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
- ],
1401
+ if context.get("can_set_reply_threads"):
1402
+ sections.append({"id": "replies", "items": [{
1403
+ "id": "reply-threads", "label": "Reply in threads", **common,
1404
+ "control": {"oneofKind": "select", "select": {
1405
+ "value": context.get("reply_threads") or "auto",
1406
+ "options": [
1407
+ {"value": "auto", "label": "Auto", "description": "Agent decides.", "disabled": False},
1408
+ {"value": "on", "label": "On", "description": "Always use threads.", "disabled": False},
1409
+ {"value": "off", "label": "Off", "description": "Stay in chat.", "disabled": False},
1410
+ ],
1411
+ }},
1412
+ }]})
1413
+ else:
1414
+ sections.append({"id": "replies", "items": [{
1415
+ "id": "reply-threads-access", "label": "Reply in threads", "disabled": False,
1416
+ "control": {"oneofKind": "info", "info": {
1417
+ "text": ("Parent chat access is temporarily unavailable. Try again."
1418
+ if context.get("can_set_reply_threads") is None
1419
+ else "Changing reply mode requires access to the parent chat."),
1420
+ "tone": _INLINE_BOT_SETTINGS_TONE_WARNING,
1398
1421
  }},
1399
1422
  }]})
1400
1423
  if disabled:
@@ -1492,6 +1515,8 @@ class InlineAdapter(BasePlatformAdapter):
1492
1515
  self._invalidate_chat_info(context["chat_id"])
1493
1516
  return
1494
1517
  if item_id == "reply-threads" and value_body.get("oneofKind") == "stringValue":
1518
+ if not context.get("can_set_reply_threads"):
1519
+ raise ValueError("parent chat thread settings are not authorized")
1495
1520
  mode = str(value_body.get("stringValue") or "")
1496
1521
  if mode not in {"auto", "on", "off"}:
1497
1522
  raise ValueError("invalid reply mode")
@@ -1768,6 +1793,7 @@ class InlineAdapter(BasePlatformAdapter):
1768
1793
  *,
1769
1794
  chat_id: str,
1770
1795
  msg_id: str,
1796
+ from_id: str,
1771
1797
  text: str,
1772
1798
  chat_type: str,
1773
1799
  thread_id: Optional[str],
@@ -1779,6 +1805,9 @@ class InlineAdapter(BasePlatformAdapter):
1779
1805
 
1780
1806
  metadata = {"thread_id": thread_id} if thread_id else None
1781
1807
  target_chat_id = parent_chat_id or chat_id
1808
+ if target_chat_id != chat_id and not await self._chat_actor_authorized(target_chat_id, from_id):
1809
+ await self.send(chat_id, "Parent chat access could not be confirmed. Check access and try again.", reply_to=msg_id, metadata=metadata)
1810
+ return True
1782
1811
  if action == "on":
1783
1812
  self._set_reply_threads_for_chat(target_chat_id, "on")
1784
1813
  elif action == "off":
@@ -1935,6 +1964,12 @@ class InlineAdapter(BasePlatformAdapter):
1935
1964
  except Exception:
1936
1965
  pass
1937
1966
  self._inbound_task = None
1967
+ deliveries = list(self._inbound_deliveries.values())
1968
+ for task in deliveries:
1969
+ task.cancel()
1970
+ if deliveries:
1971
+ await asyncio.gather(*deliveries, return_exceptions=True)
1972
+ self._inbound_deliveries.clear()
1938
1973
  for task in list(self._bot_settings_tasks):
1939
1974
  task.cancel()
1940
1975
  if self._bot_settings_tasks:
@@ -2247,6 +2282,78 @@ class InlineAdapter(BasePlatformAdapter):
2247
2282
  event = json.loads(line)
2248
2283
  except json.JSONDecodeError:
2249
2284
  return
2285
+ delivery_id = event.get("_inlineDeliveryId")
2286
+ if not isinstance(delivery_id, str) or not delivery_id:
2287
+ await self._dispatch_inbound(event)
2288
+ return
2289
+ # The SDK bounds outstanding receipts and orders each chat. Retain only
2290
+ # their active coroutines; reconnect replay must not run one twice.
2291
+ if delivery_id in self._inbound_deliveries:
2292
+ return
2293
+ # An ACK may reach the sidecar while its response is lost, releasing an
2294
+ # SDK slot before this coroutine finishes. Bound those extra ACK waiters
2295
+ # too, using stream backpressure rather than another event queue.
2296
+ while len(self._inbound_deliveries) >= _MAX_INBOUND_DELIVERIES:
2297
+ await asyncio.wait(tuple(self._inbound_deliveries.values()), return_when=asyncio.FIRST_COMPLETED)
2298
+ task = asyncio.create_task(self._consume_inbound_delivery(delivery_id, event))
2299
+ self._inbound_deliveries[delivery_id] = task
2300
+
2301
+ def finished(completed: asyncio.Task) -> None:
2302
+ self._inbound_deliveries.pop(delivery_id, None)
2303
+ if not completed.cancelled():
2304
+ try:
2305
+ completed.result()
2306
+ except Exception as exc:
2307
+ self._report_error("inbound.delivery", exc)
2308
+ task.add_done_callback(finished)
2309
+
2310
+ async def _consume_inbound_delivery(self, delivery_id: str, event: Dict[str, Any]) -> None:
2311
+ try:
2312
+ await self._handle_inbound_delivery(delivery_id, event)
2313
+ except Exception as exc:
2314
+ # Do not ACK an unexpected failure or silently pin its chat forever.
2315
+ # Native notification shields runner teardown/reconnect from the
2316
+ # cancellation of this task during adapter.disconnect().
2317
+ self._report_error("inbound.delivery", exc)
2318
+ self._set_fatal_error("INBOUND_FAILED", str(exc), retryable=True)
2319
+ await self._notify_fatal_error()
2320
+
2321
+ async def _handle_inbound_delivery(self, delivery_id: str, event: Dict[str, Any]) -> None:
2322
+ delay = _INBOUND_RETRY_INITIAL_SECONDS
2323
+ attempts = 0
2324
+ while True:
2325
+ attempts += 1
2326
+ try:
2327
+ await self._dispatch_inbound(event)
2328
+ break
2329
+ except InlineSidecarError as exc:
2330
+ if exc.error_kind not in {"forbidden", "not_found"}:
2331
+ raise
2332
+ logger.info("[inline] inbound target is no longer accessible; retiring delivery")
2333
+ break
2334
+ except InlineInboundDeferred as exc:
2335
+ # Action events share the SDK's user-cursor barrier. A persistent
2336
+ # outage must not hold every chat behind a stale button press.
2337
+ if event.get("kind") == "message.action.invoke" and attempts >= 2:
2338
+ await self._answer_action(str(event.get("interactionId") or ""),
2339
+ "Temporarily unavailable. Please try again.")
2340
+ break
2341
+ logger.warning("[inline] inbound preflight unavailable; retrying in %.1fs: %s", delay, exc)
2342
+ await asyncio.sleep(delay)
2343
+ delay = min(delay * 2, 30.0)
2344
+ # A lost ACK response must retry only the ACK, never local commands or
2345
+ # model delivery. The endpoint is idempotent, including after reconnect.
2346
+ delay = _INBOUND_RETRY_INITIAL_SECONDS
2347
+ while True:
2348
+ try:
2349
+ await self._sidecar_call("/inbound/ack", {"deliveryId": delivery_id})
2350
+ return
2351
+ except Exception:
2352
+ logger.warning("[inline] inbound acknowledgement unavailable; retrying in %.1fs", delay)
2353
+ await asyncio.sleep(delay)
2354
+ delay = min(delay * 2, 30.0)
2355
+
2356
+ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
2250
2357
  self._me_id = str(event.get("meId") or self._me_id or "") or None
2251
2358
  self._me_username = _normalize_inline_username(event.get("meUsername") or self._me_username)
2252
2359
  # Chat snapshots include mutable dialog and routing fields such as
@@ -2257,10 +2364,16 @@ class InlineAdapter(BasePlatformAdapter):
2257
2364
  self._invalidate_chat_info(event.get("chatId"))
2258
2365
  kind = event.get("kind")
2259
2366
  if kind == "bot.chatSettings.request":
2260
- self._schedule_bot_settings_task(self._handle_bot_settings_request(event))
2367
+ if event.get("_inlineDeliveryId"):
2368
+ await self._handle_bot_settings_request(event)
2369
+ else:
2370
+ self._schedule_bot_settings_task(self._handle_bot_settings_request(event))
2261
2371
  return
2262
2372
  if kind == "bot.chatSettings.item.invoke":
2263
- self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
2373
+ if event.get("_inlineDeliveryId"):
2374
+ await self._handle_bot_settings_item(event)
2375
+ else:
2376
+ self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
2264
2377
  return
2265
2378
  if kind == "message.action.invoke":
2266
2379
  if await self._handle_action(event):
@@ -2349,10 +2462,6 @@ class InlineAdapter(BasePlatformAdapter):
2349
2462
  dedup_key = f"new:{chat_id}:msg:{msg_id}:date:{message_date}"
2350
2463
  else:
2351
2464
  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
2465
  from_id = str(msg.get("fromId") or "")
2357
2466
  if msg.get("out") or (self._me_id and from_id == self._me_id):
2358
2467
  return
@@ -2361,18 +2470,7 @@ class InlineAdapter(BasePlatformAdapter):
2361
2470
 
2362
2471
  raw_message_text = str(msg.get("message") or "")
2363
2472
  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
2473
  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
2474
 
2377
2475
  chat_type = self._chat_type_from_message(msg)
2378
2476
  thread_id = self._thread_id_from_message(msg)
@@ -2381,7 +2479,20 @@ class InlineAdapter(BasePlatformAdapter):
2381
2479
  chat_name = chat_id
2382
2480
  chat_info: Dict[str, Any] = {}
2383
2481
  if chat_type == "group":
2384
- chat_info = await self._get_chat_info(chat_id)
2482
+ try:
2483
+ chat_info = await asyncio.wait_for(
2484
+ self._get_chat_info(chat_id, required=True),
2485
+ timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS if agent_action else _INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS,
2486
+ )
2487
+ except asyncio.TimeoutError as exc:
2488
+ raise InlineInboundDeferred("chat metadata timed out") from exc
2489
+ except InlineSidecarError as exc:
2490
+ if exc.error_kind in {"forbidden", "not_found"}:
2491
+ logger.info("[inline] chat is no longer accessible; retiring inbound delivery")
2492
+ return
2493
+ raise
2494
+ if not chat_info:
2495
+ raise InlineInboundDeferred("chat metadata unavailable")
2385
2496
  chat_name = self._chat_title_from_info(chat_info) or chat_id
2386
2497
  info_parent_chat_id = self._chat_info_id(chat_info, "parentChatId")
2387
2498
  info_parent_message_id = self._chat_info_id(chat_info, "parentMessageId")
@@ -2402,9 +2513,52 @@ class InlineAdapter(BasePlatformAdapter):
2402
2513
  )
2403
2514
  if has_command_target and not command_addressed_to_me:
2404
2515
  return
2516
+ actor_authorized = self._actor_authorization(
2517
+ chat_type, from_id, chat_id, thread_id=thread_id,
2518
+ parent_chat_id=parent_chat_id, is_bot=sender_profile.get("bot") is True,
2519
+ )
2520
+ if actor_authorized is None:
2521
+ raise InlineInboundDeferred("host authorization unavailable")
2522
+ if self._is_duplicate(dedup_key):
2523
+ return
2524
+ if not edit and self._is_duplicate_message_instance(chat_id, msg, event):
2525
+ return
2526
+ context_only = not explicitly_mentions_me and (
2527
+ event.get("_inlineSenderProvenanceVerified") is False or sender_profile.get("bot") is True
2528
+ )
2529
+ if not actor_authorized:
2530
+ if context_only:
2531
+ return
2532
+ # Let Hermes pair/ignore/decline the sender, without first downloading
2533
+ # media, collecting context, changing settings, or creating a thread.
2534
+ source = self.build_source(
2535
+ chat_id=chat_id, chat_name=chat_name, chat_type=chat_type,
2536
+ user_id=from_id, user_name=sender_name or None,
2537
+ thread_id=thread_id, parent_chat_id=parent_chat_id, message_id=msg_id,
2538
+ is_bot=sender_profile.get("bot") is True,
2539
+ )
2540
+ await self.handle_message(MessageEvent(
2541
+ text=text or "[Inline message with no text]", message_type=MessageType.TEXT,
2542
+ source=source, raw_message=event,
2543
+ message_id=build_inline_agent_action_turn_id(
2544
+ agent_action.get("messageId"), agent_action.get("interactionId"),
2545
+ ) if agent_action else msg_id,
2546
+ timestamp=self._timestamp(event.get("date") or msg.get("date")),
2547
+ allow_gateway_control=not bool(agent_action),
2548
+ ))
2549
+ return
2550
+ media_text, media_urls, media_types, message_type = await self._normalize_media(msg)
2551
+ if media_text:
2552
+ text = f"{text}\n{media_text}".strip() if text else media_text
2553
+ if not text and not media_urls:
2554
+ text = "[Inline message with no text]"
2555
+ if context_only:
2556
+ self._remember_observed_context(chat_id, msg, text)
2557
+ return
2405
2558
  if not agent_action and await self._handle_thread_command(
2406
2559
  chat_id=chat_id,
2407
2560
  msg_id=msg_id,
2561
+ from_id=from_id,
2408
2562
  text=text,
2409
2563
  chat_type=chat_type,
2410
2564
  thread_id=thread_id,
@@ -2584,6 +2738,7 @@ class InlineAdapter(BasePlatformAdapter):
2584
2738
  chat_type=chat_type,
2585
2739
  user_id=from_id,
2586
2740
  user_name=sender_name or None,
2741
+ is_bot=sender_profile.get("bot") is True,
2587
2742
  thread_id=thread_id,
2588
2743
  parent_chat_id=parent_chat_id,
2589
2744
  message_id=msg_id,
@@ -2618,7 +2773,6 @@ class InlineAdapter(BasePlatformAdapter):
2618
2773
 
2619
2774
  async def _dispatch_agent_action(self, event: Dict[str, Any]) -> None:
2620
2775
  interaction_id = str(event.get("interactionId") or "")
2621
- await self._answer_action(interaction_id, "")
2622
2776
 
2623
2777
  chat_id = str(event.get("chatId") or "")
2624
2778
  message_id = str(event.get("messageId") or "")
@@ -2626,13 +2780,19 @@ class InlineAdapter(BasePlatformAdapter):
2626
2780
  if not chat_id or not message_id or not interaction_id or not actor_user_id:
2627
2781
  logger.warning("[inline] ignored incomplete agent action event")
2628
2782
  return
2629
- target = await self._fetch_message(chat_id, message_id)
2783
+ try:
2784
+ target = await asyncio.wait_for(
2785
+ self._fetch_message(chat_id, message_id, required=True),
2786
+ timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
2787
+ )
2788
+ except asyncio.TimeoutError as exc:
2789
+ raise InlineInboundDeferred("action target lookup timed out") from exc
2630
2790
  if not target:
2631
- logger.info("[inline] ignored agent action for unavailable message %s", message_id)
2791
+ await self._answer_action(interaction_id, "Action expired or no longer accessible.")
2632
2792
  return
2633
2793
 
2634
2794
  synthetic_message = dict(target)
2635
- for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies"):
2795
+ for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies", "sender"):
2636
2796
  synthetic_message.pop(stale_key, None)
2637
2797
  synthetic_message.update({
2638
2798
  "id": message_id,
@@ -2653,6 +2813,7 @@ class InlineAdapter(BasePlatformAdapter):
2653
2813
  "message": synthetic_message,
2654
2814
  "_inlineAgentAction": dict(event),
2655
2815
  })
2816
+ await self._answer_action(interaction_id, "")
2656
2817
 
2657
2818
  def _message_explicitly_mentions_me(self, msg: Dict[str, Any]) -> bool:
2658
2819
  if not self._me_id:
@@ -2758,13 +2919,19 @@ class InlineAdapter(BasePlatformAdapter):
2758
2919
  if self._me_id and user_id == self._me_id:
2759
2920
  return
2760
2921
  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
2922
 
2764
2923
  target = await self._fetch_message(chat_id, message_id)
2765
2924
  chat_type = self._chat_type_from_message(target or {"peerId": {"type": {"oneofKind": "chat"}}})
2766
2925
  if not self._allowed(chat_type, user_id):
2767
2926
  return
2927
+ scope = await self._message_scope(chat_id, target or {}, required=True)
2928
+ if scope is None:
2929
+ return
2930
+ chat_type, thread_id, parent_chat_id = scope
2931
+ if chat_type == "group" and not self._chat_allowed(chat_id, thread_id, parent_chat_id):
2932
+ return
2933
+ if self._is_duplicate(key):
2934
+ return
2768
2935
  target_text = str((target or {}).get("message") or "") or None
2769
2936
  target_author = str((target or {}).get("fromId") or "") or None
2770
2937
  target_is_own = bool(self._me_id and target_author == self._me_id)
@@ -2777,6 +2944,8 @@ class InlineAdapter(BasePlatformAdapter):
2777
2944
  chat_type=chat_type,
2778
2945
  user_id=user_id,
2779
2946
  user_name=_inline_sender_identity(_inline_sender_profile(event))[0] or None,
2947
+ thread_id=thread_id,
2948
+ parent_chat_id=parent_chat_id,
2780
2949
  message_id=key,
2781
2950
  )
2782
2951
  await self.handle_message(MessageEvent(
@@ -2813,7 +2982,13 @@ class InlineAdapter(BasePlatformAdapter):
2813
2982
  text = "message:history_cleared"
2814
2983
  if not chat_id or not text:
2815
2984
  return
2816
- if self._group_policy == "disabled":
2985
+ if self._group_policy == "disabled" or (user_id and not self._allowed("group", user_id)):
2986
+ return
2987
+ scope = await self._message_scope(chat_id, {}, required=True)
2988
+ if scope is None:
2989
+ return
2990
+ _, thread_id, parent_chat_id = scope
2991
+ if not self._chat_allowed(chat_id, thread_id, parent_chat_id):
2817
2992
  return
2818
2993
  key = f"{kind}:{chat_id}:{user_id}:{event.get('seq') or ''}:{text}"
2819
2994
  if self._is_duplicate(key):
@@ -2824,8 +2999,12 @@ class InlineAdapter(BasePlatformAdapter):
2824
2999
  chat_type="group",
2825
3000
  user_id=user_id or None,
2826
3001
  user_name=_inline_sender_identity(_inline_sender_profile(event))[0] or None,
3002
+ thread_id=thread_id,
3003
+ parent_chat_id=parent_chat_id,
2827
3004
  message_id=key,
2828
3005
  )
3006
+ # Delete/history events may have no actor. Preserve the host's own
3007
+ # authorization of these events rather than inventing a sender grant.
2829
3008
  await self.handle_message(MessageEvent(
2830
3009
  text=text,
2831
3010
  message_type=MessageType.TEXT,
@@ -3665,7 +3844,7 @@ class InlineAdapter(BasePlatformAdapter):
3665
3844
  return None
3666
3845
  return str(reply_to)
3667
3846
 
3668
- async def _get_chat_info(self, chat_id: str) -> Dict[str, Any]:
3847
+ async def _get_chat_info(self, chat_id: str, *, required: bool = False) -> Dict[str, Any]:
3669
3848
  target = _target_from_chat_id(chat_id)
3670
3849
  normalized = str(target.get("chatId") or "").strip()
3671
3850
  if not normalized:
@@ -3679,7 +3858,16 @@ class InlineAdapter(BasePlatformAdapter):
3679
3858
  data = await self._sidecar_call("/chat", {"target": {"chatId": normalized}})
3680
3859
  result = data.get("result") if isinstance(data, dict) else None
3681
3860
  info = result if isinstance(result, dict) else {}
3682
- except Exception:
3861
+ except Exception as exc:
3862
+ if required:
3863
+ if isinstance(exc, InlineSidecarError) and exc.error_kind in {"forbidden", "not_found"}:
3864
+ raise
3865
+ raise InlineInboundDeferred("chat metadata request failed") from exc
3866
+ return {}
3867
+ resolved_id = self._chat_info_id(info, "id")
3868
+ if not info or (resolved_id is not None and resolved_id != normalized):
3869
+ if required:
3870
+ raise InlineInboundDeferred("chat metadata unavailable or mismatched")
3683
3871
  return {}
3684
3872
  self._chat_info_cache[normalized] = (now, info)
3685
3873
  self._chat_info_cache.move_to_end(normalized)
@@ -3779,12 +3967,14 @@ class InlineAdapter(BasePlatformAdapter):
3779
3967
  return self._id_allowed(self._group_allow_from, from_id)
3780
3968
  return True
3781
3969
 
3782
- async def _fetch_message(self, chat_id: str, message_id: str) -> Optional[Dict[str, Any]]:
3970
+ async def _fetch_message(self, chat_id: str, message_id: str, *, required: bool = False) -> Optional[Dict[str, Any]]:
3783
3971
  try:
3784
3972
  data = await self._sidecar_call("/messages", {"target": _target_from_chat_id(chat_id), "messageIds": [message_id]})
3785
3973
  messages = (data.get("result") or {}).get("messages") or []
3786
3974
  return messages[0] if messages else None
3787
- except Exception:
3975
+ except Exception as exc:
3976
+ if required and not (isinstance(exc, InlineSidecarError) and exc.error_kind in {"forbidden", "not_found"}):
3977
+ raise InlineInboundDeferred("action target temporarily unavailable") from exc
3788
3978
  return None
3789
3979
 
3790
3980
  async def _handle_action(self, event: Dict[str, Any]) -> bool:
@@ -3825,43 +4015,117 @@ class InlineAdapter(BasePlatformAdapter):
3825
4015
  async def _action_allowed(self, event: Dict[str, Any]) -> bool:
3826
4016
  actor_id = str(event.get("actorUserId") or "").strip()
3827
4017
  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 "")
4018
+ chat_id = str(event.get("chatId") or "")
4019
+ message_id = str(event.get("messageId") or "")
4020
+ msg = await self._fetch_message(chat_id, message_id) if chat_id and message_id else None
4021
+ scope = await self._message_scope(chat_id, msg) if msg else None
4022
+ verdict = None
4023
+ if scope is not None:
4024
+ chat_type, thread_id, parent_chat_id = scope
4025
+ verdict = self._actor_authorization(
4026
+ chat_type, actor_id, chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id,
4027
+ is_bot=_inline_sender_profile(event).get("bot") is True,
4028
+ )
4029
+ if verdict is True:
4030
+ return True
4031
+ await self._answer_action(interaction_id, "Access check temporarily unavailable. Try again." if verdict is None else "Not authorized")
4032
+ logger.info("[inline] blocked action actor=%s chat=%s action=%s", actor_id or "unknown", chat_id or "unknown", event.get("actionId") or "")
3833
4033
  return False
3834
4034
 
3835
- def _actor_authorized(self, chat_type: Optional[str], actor_id: str) -> bool:
3836
- if not actor_id or not chat_type:
4035
+ async def _message_scope(
4036
+ self, chat_id: str, msg: Dict[str, Any], *, required: bool = False,
4037
+ ) -> Optional[tuple[str, Optional[str], Optional[str]]]:
4038
+ """Resolve the same parent/thread identity used by normal message intake."""
4039
+ chat_type = self._chat_type_from_message(msg)
4040
+ thread_id = self._thread_id_from_message(msg)
4041
+ parent_chat_id = self._parent_chat_id_from_message(msg)
4042
+ if chat_type == "group" and not thread_id:
4043
+ if not parent_chat_id:
4044
+ try:
4045
+ info = await asyncio.wait_for(
4046
+ self._get_chat_info(chat_id, required=required),
4047
+ timeout=_INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS if required else _CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
4048
+ )
4049
+ except asyncio.TimeoutError as exc:
4050
+ if required:
4051
+ raise InlineInboundDeferred("chat metadata timed out") from exc
4052
+ return None
4053
+ if not info:
4054
+ if required:
4055
+ raise InlineInboundDeferred("chat metadata unavailable")
4056
+ return None
4057
+ parent_chat_id = self._chat_info_id(info, "parentChatId")
4058
+ if parent_chat_id:
4059
+ thread_id = chat_id
4060
+ return chat_type, thread_id, parent_chat_id
4061
+
4062
+ async def _chat_actor_authorized(self, chat_id: str, actor_id: str) -> Optional[bool]:
4063
+ """Check the actual target when a child control changes parent-wide state."""
4064
+ try:
4065
+ info = await asyncio.wait_for(
4066
+ self._get_chat_info(chat_id), timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
4067
+ )
4068
+ except asyncio.TimeoutError:
4069
+ return None
4070
+ if not info:
4071
+ return None
4072
+ parent_chat_id = self._chat_info_id(info, "parentChatId")
4073
+ return self._actor_authorization(
4074
+ self._bot_settings_chat_type(info), actor_id, chat_id,
4075
+ thread_id=chat_id if parent_chat_id else None, parent_chat_id=parent_chat_id,
4076
+ )
4077
+
4078
+ def _actor_authorized(
4079
+ self, chat_type: Optional[str], actor_id: str, chat_id: Optional[str] = None, *,
4080
+ thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None, is_bot: bool = False,
4081
+ ) -> bool:
4082
+ return self._actor_authorization(
4083
+ chat_type, actor_id, chat_id, thread_id=thread_id,
4084
+ parent_chat_id=parent_chat_id, is_bot=is_bot,
4085
+ ) is True
4086
+
4087
+ def _actor_authorization(
4088
+ self, chat_type: Optional[str], actor_id: str, chat_id: Optional[str] = None, *,
4089
+ thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None, is_bot: bool = False,
4090
+ ) -> Optional[bool]:
4091
+ # The host may trust an adapter's allowlist policy on the assumption that
4092
+ # intake already checked it. Always enforce our restrictions before asking.
4093
+ if not actor_id or chat_type not in {"dm", "group"} or not self._allowed(chat_type, actor_id):
3837
4094
  return False
4095
+ if chat_type == "group" and not self._chat_allowed(chat_id or "", thread_id, parent_chat_id):
4096
+ return False
4097
+ if getattr(self, "_authorization_check", None) is not None:
4098
+ # Hermes owns pairing, profile routing, and runtime authorization.
4099
+ # Unknown/error is unavailable, never a reason to bypass a wired checker.
4100
+ runner = getattr(self, "gateway_runner", None)
4101
+ if parent_chat_id and runner is not None:
4102
+ # The three-argument native callback drops parent_chat_id. Inline
4103
+ # child chats need it for parent-routed profiles, so ask the same
4104
+ # host predicate with the complete source, retaining transport identity.
4105
+ try:
4106
+ source = self.build_source(
4107
+ chat_id=chat_id or "", chat_type=chat_type, user_id=actor_id,
4108
+ thread_id=thread_id, parent_chat_id=parent_chat_id, is_bot=is_bot,
4109
+ )
4110
+ if getattr(source, "profile_route_rejected", False):
4111
+ return False
4112
+ verdict = runner._is_user_authorized_for_source(source)
4113
+ return verdict if isinstance(verdict, bool) else None
4114
+ except Exception:
4115
+ logger.warning("[inline] thread authorization unavailable", exc_info=True)
4116
+ return None
4117
+ return self._is_sender_authorized(
4118
+ actor_id, chat_type, chat_id, is_bot=is_bot, thread_id=thread_id,
4119
+ )
4120
+ # Standalone adapters have no runner. Only explicit grants are usable;
4121
+ # an open intake policy alone must not authorize credentialed side effects.
3838
4122
  if self._allow_all or _truthy(os.getenv("GATEWAY_ALLOW_ALL_USERS"), False):
3839
4123
  return True
3840
4124
  if self._id_allowed(self._parse_id_set(os.getenv("GATEWAY_ALLOWED_USERS")), actor_id):
3841
4125
  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)
4126
+ return self._id_allowed(self._allow_from, actor_id) or (
4127
+ chat_type == "group" and self._id_allowed(self._group_allow_from, actor_id)
4128
+ )
3865
4129
 
3866
4130
  async def _handle_clarify_action(self, event: Dict[str, Any]) -> bool:
3867
4131
  action_id = str(event.get("actionId") or "")
@@ -3990,7 +4254,12 @@ class InlineAdapter(BasePlatformAdapter):
3990
4254
  if not target_chat_id:
3991
4255
  await self._answer_action(interaction_id, "Thread controls expired")
3992
4256
  return True
3993
- if not await self._thread_action_allowed(event, state):
4257
+ if not await self._action_allowed(event):
4258
+ return True
4259
+ if target_chat_id != chat_id and not await self._chat_actor_authorized(
4260
+ target_chat_id, str(event.get("actorUserId") or ""),
4261
+ ):
4262
+ await self._answer_action(interaction_id, "Parent chat access could not be confirmed. Check access and try again.")
3994
4263
  return True
3995
4264
  try:
3996
4265
  if choice == "reset":
@@ -4012,24 +4281,6 @@ class InlineAdapter(BasePlatformAdapter):
4012
4281
  await self._answer_action(interaction_id, "Thread setting failed")
4013
4282
  return True
4014
4283
 
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
4284
  @staticmethod
4034
4285
  def _is_model_picker_action(action_id: str) -> bool:
4035
4286
  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.21"
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.21
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)
@@ -46757,7 +46754,7 @@ function readUsers(result, kind) {
46757
46754
  }
46758
46755
 
46759
46756
  // src/sidecar/telemetry.ts
46760
- import { randomUUID } from "node:crypto";
46757
+ import { randomUUID as randomUUID2 } from "node:crypto";
46761
46758
  import { readFile as readFile2 } from "node:fs/promises";
46762
46759
  var TELEMETRY_TIMEOUT_MS = 2000;
46763
46760
  var TELEMETRY_DEDUP_MS = 5 * 60000;
@@ -46818,7 +46815,7 @@ function buildHermesTelemetryEvent(operation, error, options = {}) {
46818
46815
  const exception = error instanceof Error ? error : new Error(String(error ?? "Unknown error"));
46819
46816
  const frames = parseStack(exception, env);
46820
46817
  return {
46821
- event_id: randomUUID().replaceAll("-", ""),
46818
+ event_id: randomUUID2().replaceAll("-", ""),
46822
46819
  timestamp: new Date().toISOString(),
46823
46820
  platform: "javascript",
46824
46821
  level: "error",
@@ -47090,6 +47087,13 @@ async function handleRequest(req, res) {
47090
47087
  writeJson(res, 405, { ok: false, error: "method not allowed", errorKind: "bad_format" });
47091
47088
  return;
47092
47089
  }
47090
+ if (url.pathname === "/inbound/ack") {
47091
+ const body2 = asRecord(await readJsonBody(req));
47092
+ const deliveryId = readRequiredString(body2, "deliveryId");
47093
+ inboundStream.acknowledge(deliveryId);
47094
+ writeJson(res, 200, { ok: true, result: {} });
47095
+ return;
47096
+ }
47093
47097
  if (!connected && url.pathname !== "/shutdown") {
47094
47098
  writeJson(res, 503, {
47095
47099
  ok: false,