@securecoms/agent-bridge 0.37.0 → 0.38.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
@@ -973,6 +973,22 @@ copied after the owner's next typed message.
973
973
 
974
974
  ---
975
975
 
976
+ ## Release notes: 0.38.0
977
+
978
+ <!-- [COPY, released on Eric's "complete all three items" 2026-11-11; notes not yet reviewed by Eric] -->
979
+
980
+ #### What's new
981
+ - **Your agent sees your pictures.** When you send a picture, your agent can now see it and describe it. This works for OpenClaw, Hermes and SecureComs agents. It works for PNG, JPEG, GIF and WebP pictures up to 5 MB.
982
+ - **A picture and its caption are one message.** When you send a picture with a caption, your agent gets them together and answers once.
983
+ - **Safer pictures.** Your agent reads a picture only when the file really is the picture type it claims to be. In a team room, a picture follows the same @mention rules as a text message. The pictures are kept in private folders that only your account can read.
984
+ - **Hermes keeps your messages.** If Hermes stops in the middle of a reply, it now picks up your message again when it restarts, in almost all cases. You can still send `/stop` while Hermes is working.
985
+
986
+ #### Upgrade
987
+
988
+ Upgrade the bridge and restart your agent. For a SecureComs agent, also upgrade to @securecoms/agent-runtime 0.18.0. For OpenClaw or Hermes, copy the new plugin files from the bridge's `connectors` folder.
989
+
990
+ If you go back to 0.37.0, your agent keeps working, but it sees each picture and its caption as two messages.
991
+
976
992
  ## Release notes: 0.37.0
977
993
 
978
994
  <!-- [COPY, approved by Eric 2026-11-01 ("Notes ok"), connected apps removal confirmation] -->
@@ -13,6 +13,9 @@ Flow:
13
13
  --> Hermes gateway --> LLM --> send() --> daemon --MLS--> owner
14
14
  outbound: the agent's reply is delivered to send(), which POSTs it to the
15
15
  daemon's /v1/send; the daemon encrypts and relays it.
16
+ ack: an inbound frame is acked to the daemon once Hermes has durably
17
+ accepted its turn (see _receive_message); after that, crash
18
+ recovery is Hermes's own (its persisted active-turn marker).
16
19
 
17
20
  This is the universal daemon-client pattern wearing a Hermes BasePlatformAdapter
18
21
  shim. The same daemon serves any framework; only this thin shim is
@@ -60,6 +63,7 @@ import json
60
63
  import logging
61
64
  import os
62
65
  import time
66
+ from collections import OrderedDict
63
67
  from datetime import datetime
64
68
  from typing import Any, Dict, Optional
65
69
 
@@ -108,6 +112,46 @@ _REFUSAL_NOTICE_SENT: set = set()
108
112
  _REFUSAL_NOTICE_MAX = 4096
109
113
  # Inbound frames seen without an authorization block (daemon too old).
110
114
  _FRAMES_WITHOUT_AUTHORIZATION = 0
115
+ # Inbound attachments dropped by securecoms_transport.scan_inbound_attachments.
116
+ _ATTACHMENTS_DROPPED = 0
117
+ # Unknown-channel lookups (review r2): one /v1/channels refresh per unknown
118
+ # channel per minute, each bounded so a slow daemon cannot stall a turn.
119
+ _UNKNOWN_CHANNEL_RETRY_S = 60.0
120
+ _UNKNOWN_CHANNEL_LOOKUPS_MAX = 4096
121
+ _CHANNEL_REFRESH_TIMEOUT_S = 5.0
122
+
123
+ # Ack on durable acceptance (see _receive_message). Hermes's handle_message
124
+ # returns as soon as it has spawned the turn as a background task, so an ack
125
+ # sent when it returned could reach the daemon before Hermes held the message
126
+ # anywhere durable: a crash then lost it (the daemon never redelivers an acked
127
+ # row). Acking after the WHOLE turn is wrong too: the daemon delivers one
128
+ # message per channel and the next only after the ack (ws-server.ts F2), which
129
+ # would hold back /stop, /approve and clarify answers until the turn ended, and
130
+ # Hermes already resumes a crashed turn from its own durable marker, so a
131
+ # daemon redelivery on top would run it twice. So the ack goes out once Hermes
132
+ # has persisted its active-turn marker for this event, or finished with it.
133
+ # Redeliveries are deduped by message_id in a bounded ledger.
134
+ _DEDUP_MAX_ENTRIES = 2048
135
+ _DEDUP_TTL_S = 600.0
136
+ # How long to wait for Hermes to accept a turn that starts its own session
137
+ # task before acking anyway (holding the ack holds the channel, F2).
138
+ _ACCEPT_WAIT_S = 10.0
139
+ _ACCEPT_POLL_S = 0.02
140
+ # One warning per process when this Hermes lacks the acceptance signal.
141
+ _TURN_TRACKING_WARNED = False
142
+
143
+ # Hermes's Signal adapter mapping (_MEDIA_TYPE_BY_MIME_PREFIX): first matching
144
+ # prefix wins; anything else is a DOCUMENT so Hermes surfaces the file path.
145
+ _MEDIA_TYPE_BY_MIME_PREFIX = (("audio/", "VOICE"), ("image/", "PHOTO"), ("video/", "VIDEO"))
146
+
147
+
148
+ def _message_type_for_mimes(mimes):
149
+ name = next(
150
+ (n for prefix, n in _MEDIA_TYPE_BY_MIME_PREFIX if any(m.startswith(prefix) for m in mimes)),
151
+ "DOCUMENT",
152
+ )
153
+ return getattr(MessageType, name, MessageType.TEXT)
154
+
111
155
 
112
156
  _PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
113
157
  _BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
@@ -146,6 +190,69 @@ def _error_class(error: object) -> str:
146
190
  return name
147
191
 
148
192
 
193
+ class _DeliveryLedger:
194
+ """Bounded record of inbound message_ids that are in flight or done.
195
+
196
+ begin() answers "new" (record it in flight and dispatch), "in_flight" (a
197
+ redelivery of a turn still running: do not dispatch, the original's ack
198
+ covers it) or "done" (already finished: ack again, no turn). An in-flight
199
+ entry is never evicted or expired, since dropping it would let a
200
+ redelivery run the turn twice; the daemon keeps at most one message per
201
+ channel in flight, so those stay few. Done entries expire after ttl_s and
202
+ the oldest go first once the ledger holds more than max_entries, except
203
+ pinned ones (acked by us but never delivered to the daemon).
204
+ """
205
+
206
+ def __init__(self, max_entries: int, ttl_s: float, now) -> None:
207
+ self._max = max_entries
208
+ self._ttl = ttl_s
209
+ self._now = now
210
+ # message_id -> monotonic finish time, or None while in flight.
211
+ self._entries: "OrderedDict[str, Optional[float]]" = OrderedDict()
212
+ # Done ids whose ack never reached the daemon: kept past TTL and LRU,
213
+ # so a redelivery after a long outage is acked, never re-run.
214
+ self._pinned: set = set()
215
+
216
+ def __len__(self) -> int:
217
+ return len(self._entries)
218
+
219
+ def begin(self, message_id: str) -> str:
220
+ self._prune()
221
+ if message_id in self._entries:
222
+ return "in_flight" if self._entries[message_id] is None else "done"
223
+ self._entries[message_id] = None
224
+ return "new"
225
+
226
+ def finish(self, message_id: str) -> None:
227
+ self._entries.pop(message_id, None)
228
+ self._entries[message_id] = self._now()
229
+ self._prune()
230
+
231
+ def forget(self, message_id: str) -> None:
232
+ self._entries.pop(message_id, None)
233
+ self._pinned.discard(message_id)
234
+
235
+ def pin(self, message_id: str) -> None:
236
+ if message_id in self._entries:
237
+ self._pinned.add(message_id)
238
+
239
+ def unpin(self, message_id: str) -> None:
240
+ self._pinned.discard(message_id)
241
+
242
+ def _prune(self) -> None:
243
+ now = self._now()
244
+ excess = len(self._entries) - self._max
245
+ for mid, done_at in list(self._entries.items()):
246
+ if done_at is None or mid in self._pinned:
247
+ continue
248
+ if now - done_at > self._ttl:
249
+ del self._entries[mid]
250
+ excess -= 1
251
+ elif excess > 0:
252
+ del self._entries[mid]
253
+ excess -= 1
254
+
255
+
149
256
  def _read_file_text(path: str) -> str:
150
257
  with open(path, "r") as f:
151
258
  return f.read()
@@ -180,11 +287,20 @@ class SecureComsAdapter(BasePlatformAdapter):
180
287
  # SEC-06 #18: reply_to -> {"seq": n, "pending": text|None}; see _block_key.
181
288
  self._block_seq: Dict[str, Dict[str, Any]] = {}
182
289
  self._session = None # aiohttp.ClientSession - the CURRENT connected link
290
+ # channel_id -> monotonic time of the last lookup for an unknown channel.
291
+ self._unknown_channel_lookups: Dict[str, float] = {}
183
292
  self._ws = None
184
293
  self._recv_task: Optional[asyncio.Task] = None
185
294
  self._tick_task: Optional[asyncio.Task] = None
186
295
  self._stopped = False # set by disconnect(); stops the reconnect supervisor
187
296
  self._hold_reason: Optional[str] = None # None == not holding
297
+ # Inbound message_ids awaiting their ack or acked, and the tasks that
298
+ # each wait for Hermes to accept one turn and then send its ack.
299
+ self._deliveries = _DeliveryLedger(_DEDUP_MAX_ENTRIES, _DEDUP_TTL_S, time.monotonic)
300
+ self._ack_waiters: set = set()
301
+ # message_ids whose ack was dropped (no link, or the send raised), in
302
+ # order; flushed on the next successful connect.
303
+ self._unsent_acks: "OrderedDict[str, None]" = OrderedDict()
188
304
 
189
305
  self._status = securecoms_transport.ChannelStatusManager(
190
306
  self._send_status_frame, now=time.monotonic
@@ -291,6 +407,7 @@ class SecureComsAdapter(BasePlatformAdapter):
291
407
  self._token = target.token
292
408
  self._leave_hold()
293
409
  await self._send_hello()
410
+ await self._flush_unsent_acks()
294
411
  await self._refresh_channels()
295
412
  return True
296
413
 
@@ -389,19 +506,31 @@ class SecureComsAdapter(BasePlatformAdapter):
389
506
  await task
390
507
  except asyncio.CancelledError:
391
508
  pass
509
+ # Unsent acks are dropped on purpose: the daemon re-buffers whatever is
510
+ # unacked when the socket closes and redelivers it to the next consumer.
511
+ waiters = list(self._ack_waiters)
512
+ for task in waiters:
513
+ task.cancel()
514
+ if waiters:
515
+ await asyncio.gather(*waiters, return_exceptions=True)
516
+ self._ack_waiters.clear()
392
517
  await self._teardown_link()
393
518
 
519
+ async def _fetch_channels(self) -> None:
520
+ async with self._session.get(
521
+ f"{self.bridge_url}/v1/channels", headers=self._auth()
522
+ ) as r:
523
+ if r.status == 200:
524
+ for c in await r.json():
525
+ self._channels[c["channel_id"]] = {
526
+ "name": c.get("name") or c["channel_id"],
527
+ "is_dm": bool(c.get("is_dm")),
528
+ }
529
+
394
530
  async def _refresh_channels(self) -> None:
395
531
  try:
396
- async with self._session.get(
397
- f"{self.bridge_url}/v1/channels", headers=self._auth()
398
- ) as r:
399
- if r.status == 200:
400
- for c in await r.json():
401
- self._channels[c["channel_id"]] = {
402
- "name": c.get("name") or c["channel_id"],
403
- "is_dm": bool(c.get("is_dm")),
404
- }
532
+ # Bounded: an inbound turn may wait on this (review r2).
533
+ await asyncio.wait_for(self._fetch_channels(), timeout=_CHANNEL_REFRESH_TIMEOUT_S)
405
534
  except Exception as e:
406
535
  logger.debug(
407
536
  "SecureComs: channel refresh failed (error_class=%s)",
@@ -452,34 +581,261 @@ class SecureComsAdapter(BasePlatformAdapter):
452
581
  except (ValueError, TypeError):
453
582
  continue
454
583
  if frame.get("type") == "message":
455
- await self._dispatch(frame)
456
- # at-least-once: ack every accepted frame, dropped or not
457
- # (echo-guard drops still need the daemon to stop
458
- # redelivering them).
459
- try:
460
- await self._ws.send_json(
461
- {"type": "ack", "message_id": frame.get("message_id")}
462
- )
463
- except Exception as e:
464
- logger.warning(
465
- "SecureComs: ack failed (error_class=%s)",
466
- _error_class(e),
467
- )
584
+ await self._receive_message(frame)
468
585
  elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
469
586
  return # WS closed → supervisor reconnects
470
587
 
471
- async def _dispatch(self, frame: Dict[str, Any]) -> None:
472
- if not self._message_handler:
588
+ async def _receive_message(self, frame: Dict[str, Any]) -> None:
589
+ """At-least-once intake for one `message` frame.
590
+
591
+ Ack timing:
592
+ - dropped before Hermes (echo guard, refused ungranted turn, no
593
+ handler): at once, as before, so the daemon stops redelivering;
594
+ - handled inline by Hermes, queued behind a busy session, or not
595
+ queued for a turn (a /stop, an /approve, a clarify answer, a
596
+ follow-up, a steer, a queue-mode debounce): at once;
597
+ - starting its own Hermes turn: once Hermes has durably accepted
598
+ it (_await_acceptance), at most _ACCEPT_WAIT_S later.
599
+ The wait runs in its own task, so this pump keeps reading and other
600
+ channels keep flowing; handle_message is still called here, in frame
601
+ order. A redelivery of an id still waiting is not dispatched (the
602
+ original's ack covers it); one already acked is acked again with no
603
+ turn. A frame without a message_id is not deduped (unchanged).
604
+ """
605
+ mid = frame.get("message_id")
606
+ key = mid if isinstance(mid, str) and mid else None
607
+ if key is not None:
608
+ state = self._deliveries.begin(key)
609
+ if state == "done":
610
+ await self._send_ack(mid)
611
+ return
612
+ if state == "in_flight":
613
+ return
614
+ try:
615
+ event = await self._dispatch(frame)
616
+ except BaseException:
617
+ # Unacked, exactly as before: the supervisor reconnects and the
618
+ # daemon redelivers, which must then run the turn again.
619
+ if key is not None:
620
+ self._deliveries.forget(key)
621
+ raise
622
+ if event is None or not self._awaits_acceptance(event):
623
+ if key is not None:
624
+ self._deliveries.finish(key)
625
+ await self._send_ack(mid)
473
626
  return
627
+ task = asyncio.create_task(self._ack_on_acceptance(mid, key, event))
628
+ self._ack_waiters.add(task)
629
+ task.add_done_callback(self._ack_waiters.discard)
630
+
631
+ # Hermes internals this relies on (v2026.9.24), all read-only:
632
+ # - MessageEvent._gateway_accepted: reset to False on entry to
633
+ # handle_message (gateway/platforms/base.py:3953), True when the event
634
+ # starts a session task (:3990) or is queued for one (:4052, :4064,
635
+ # gateway/run_busy.py:88-97 _enqueue_fifo).
636
+ # - _event_session_key / _active_sessions / _session_tasks: read before
637
+ # handle_message to tell "starts its own turn" from "queued behind a
638
+ # busy session" (base.py:2488, 1924, 1928, 3983-3987).
639
+ # - MessageEvent._gateway_active_turn_token: set by the runner's
640
+ # _mark_durable_active_turn (gateway/run_inbound.py:1754-1767) only
641
+ # after SessionStore.mark_turn_active has persisted the marker
642
+ # (gateway/session_lifecycle.py:115-145, write before publish), at
643
+ # gateway/run_turn.py:2090. An unclean restart promotes the marker to
644
+ # resume_pending (session_lifecycle.py:156-185) and resumes the
645
+ # session (gateway/run_startup.py:575-620).
646
+ # - on_processing_complete(event, outcome): called on every exit of
647
+ # _process_message_background (base.py:4520-4540).
648
+ # Without _gateway_accepted this degrades to acking at dispatch, the old
649
+ # behaviour, with one warning.
650
+ #
651
+ # Accepted gaps (each no worse than acking at dispatch, as main does):
652
+ # - The marker is written at run_turn.py:2090, but the user's message
653
+ # reaches the transcript only in _persist_turn_start just before the
654
+ # first LLM call (agent/turn_context.py:963, called at :1152). In
655
+ # between: the transcript load, _hmwa_run_session_hygiene (which can
656
+ # make a compression LLM call, run_turn.py:1346) and the agent build.
657
+ # A crash there resumes the session with an empty-text event
658
+ # (run_startup.py:610) and the acked message is lost.
659
+ # - A clean shutdown while the session's agent is still
660
+ # _AGENT_PENDING_SENTINEL (before the agent build) does not mark it
661
+ # resume_pending (gateway/run_shutdown.py:858-871), and a clean start
662
+ # discards orphan markers (session_lifecycle.py:189): acked, then lost.
663
+ # - A follow-up queued behind a busy session, a queue-mode debounce
664
+ # (base.py:3766-3796) or a steer is acked at once: Hermes holds it
665
+ # only in memory until its own turn starts (its pending queue is saved
666
+ # on clean shutdown only).
667
+
668
+ def _awaits_acceptance(self, event: Any) -> bool:
669
+ accepted = getattr(event, "_gateway_accepted", None)
670
+ if not isinstance(accepted, bool):
671
+ self._warn_no_turn_tracking()
672
+ return False
673
+ # Queued behind a busy session: not durable until its own turn
674
+ # starts, which may be minutes away, so waiting buys nothing and
675
+ # would hold the channel (a /stop typed next would wait the bound).
676
+ return accepted and not getattr(event, "_securecoms_session_was_busy", False)
677
+
678
+ def _session_was_busy(self, event: Any) -> bool:
679
+ """Read BEFORE handle_message: does this event's Hermes session
680
+ already have a live owner? Mirrors handle_message's own check
681
+ (base.py:3983-3987): a guard in _active_sessions whose owner task in
682
+ _session_tasks has not exited (a done owner is healed on entry)."""
683
+ key_fn = getattr(self, "_event_session_key", None)
684
+ active = getattr(self, "_active_sessions", None)
685
+ if not callable(key_fn) or not isinstance(active, dict):
686
+ return False
687
+ try:
688
+ key = key_fn(event)
689
+ except Exception:
690
+ return False
691
+ if key not in active:
692
+ return False
693
+ tasks = getattr(self, "_session_tasks", None)
694
+ task = tasks.get(key) if isinstance(tasks, dict) else None
695
+ done = getattr(task, "done", None)
696
+ return not (callable(done) and done())
697
+
698
+ @staticmethod
699
+ def _warn_no_turn_tracking() -> None:
700
+ global _TURN_TRACKING_WARNED
701
+ if _TURN_TRACKING_WARNED:
702
+ return
703
+ _TURN_TRACKING_WARNED = True
704
+ logger.warning(
705
+ "SecureComs: Hermes turn acceptance signal unavailable; acking at dispatch "
706
+ "(a crash before the turn starts can lose that message)"
707
+ )
708
+
709
+ async def on_processing_complete(self, event: Any, outcome: Any) -> None:
710
+ """Hermes lifecycle hook (base.py:3448): record the outcome on the
711
+ event for _await_acceptance, then keep the base behaviour."""
712
+ try:
713
+ event._securecoms_outcome = getattr(outcome, "name", None) or "UNKNOWN"
714
+ except Exception:
715
+ pass
716
+ base = getattr(super(), "on_processing_complete", None)
717
+ if callable(base):
718
+ await base(event, outcome)
719
+
720
+ async def _await_acceptance(self, event: Any) -> str:
721
+ """"accepted" once Hermes holds the turn durably (marker persisted) or
722
+ has finished it; "cancelled" when Hermes cancelled it before that
723
+ (gateway shutdown: cancel_background_tasks); "timeout" after
724
+ _ACCEPT_WAIT_S."""
725
+ loop = asyncio.get_running_loop()
726
+ deadline = loop.time() + _ACCEPT_WAIT_S
727
+ while True:
728
+ if getattr(event, "_gateway_active_turn_token", None):
729
+ return "accepted"
730
+ outcome = getattr(event, "_securecoms_outcome", None)
731
+ if outcome == "CANCELLED":
732
+ return "cancelled"
733
+ if outcome is not None:
734
+ return "accepted" # finished (success or failure): never redeliver a poison turn
735
+ remaining = deadline - loop.time()
736
+ if remaining <= 0:
737
+ return "timeout"
738
+ await asyncio.sleep(min(_ACCEPT_POLL_S, remaining))
739
+
740
+ async def _ack_on_acceptance(self, mid: Any, key: Optional[str], event: Any) -> None:
741
+ try:
742
+ result = await self._await_acceptance(event)
743
+ except BaseException:
744
+ # Cancelled (disconnect) or broken: unacked, and forgotten so a
745
+ # redelivery to this same adapter runs the turn.
746
+ if key is not None:
747
+ self._deliveries.forget(key)
748
+ raise
749
+ if result == "cancelled":
750
+ # Hermes dropped it before persisting a marker: no Hermes resume
751
+ # will cover it. Fail it back to the daemon, which requeues it
752
+ # behind its backoff (ws-server.ts nack) instead of holding the
753
+ # channel until this socket closes.
754
+ if key is not None:
755
+ self._deliveries.forget(key)
756
+ await self._send_nack(mid)
757
+ return
758
+ if result == "timeout":
759
+ logger.info("SecureComs: turn not yet accepted by Hermes at the ack bound; acking it")
760
+ # Done BEFORE the send: a redelivery racing in is acked, never re-run.
761
+ if key is not None:
762
+ self._deliveries.finish(key)
763
+ await self._send_ack(mid)
764
+
765
+ async def _send_ack(self, mid: Any) -> None:
766
+ """Ack on the CURRENT socket. The daemon's ack handler retires the id
767
+ durably for the agent (ackRuntimeMessages), not per socket, and also
768
+ clears it from that socket's in-flight map, which after a reconnect
769
+ holds the redelivered copy. With no socket the ack is dropped: the
770
+ ledger already says done, so the redelivery is acked on arrival."""
771
+ key = mid if isinstance(mid, str) and mid else None
772
+ ws = self._ws
773
+ if ws is None:
774
+ logger.debug("SecureComs: no daemon link for an ack; it is sent on reconnect")
775
+ self._hold_unsent_ack(key)
776
+ return
777
+ try:
778
+ await ws.send_json({"type": "ack", "message_id": mid})
779
+ except Exception as e:
780
+ logger.warning(
781
+ "SecureComs: ack failed (error_class=%s)",
782
+ _error_class(e),
783
+ )
784
+ self._hold_unsent_ack(key)
785
+ return
786
+ if key is not None:
787
+ self._unsent_acks.pop(key, None)
788
+ self._deliveries.unpin(key)
789
+
790
+ def _hold_unsent_ack(self, key: Optional[str]) -> None:
791
+ if key is None:
792
+ return
793
+ self._unsent_acks.pop(key, None)
794
+ self._unsent_acks[key] = None
795
+ self._deliveries.pin(key)
796
+
797
+ async def _flush_unsent_acks(self) -> None:
798
+ """Send acks dropped while the link was down, on the new socket."""
799
+ for key in list(self._unsent_acks):
800
+ if self._ws is None:
801
+ return
802
+ await self._send_ack(key)
803
+ if key in self._unsent_acks:
804
+ return # the send failed again; keep the rest for next time
805
+
806
+ async def _send_nack(self, mid: Any) -> None:
807
+ ws = self._ws
808
+ if ws is None or not isinstance(mid, str) or not mid:
809
+ return # the daemon re-buffers whatever is unacked when the socket closes
810
+ try:
811
+ await ws.send_json({"type": "nack", "message_id": mid})
812
+ except Exception as e:
813
+ logger.debug(
814
+ "SecureComs: nack failed (error_class=%s)",
815
+ _error_class(e),
816
+ )
817
+
818
+ async def _dispatch(self, frame: Dict[str, Any]) -> Optional[Any]:
819
+ """Hand one frame to Hermes. Returns the MessageEvent passed to
820
+ handle_message, or None when the frame was dropped before Hermes."""
821
+ if not self._message_handler:
822
+ return None
474
823
  if securecoms_transport.should_drop_inbound(frame, self._bound_identity):
475
- return # echo guard (contract §4.4): never re-feed our own sends
824
+ return None # echo guard (contract §4.4): never re-feed our own sends
476
825
  channel_id = frame.get("channel_id", "")
477
826
  sender_id = frame.get("sender_id", "")
478
827
  text = frame.get("text", "")
828
+ if channel_id not in self._channels and self._session is not None and self._may_look_up(channel_id):
829
+ # A channel created after connect: look it up before guessing, at
830
+ # most once per _UNKNOWN_CHANNEL_RETRY_S per channel (review r2).
831
+ await self._refresh_channels()
479
832
  meta = self._channels.get(channel_id, {})
480
- chat_type = "dm" if meta.get("is_dm", True) else "group"
833
+ # Review r1 M3: a channel whose metadata is not cached yet is a GROUP
834
+ # (Hermes keys group sessions per user), never a DM: guessing DM would
835
+ # put a room's messages into a direct-chat session.
836
+ chat_type = "dm" if meta.get("is_dm") is True else "group"
481
837
 
482
- global _FRAMES_WITHOUT_AUTHORIZATION
838
+ global _FRAMES_WITHOUT_AUTHORIZATION, _ATTACHMENTS_DROPPED
483
839
  if not isinstance(frame.get("authorization"), dict):
484
840
  _FRAMES_WITHOUT_AUTHORIZATION += 1
485
841
  if _FRAMES_WITHOUT_AUTHORIZATION % 100 == 1:
@@ -492,10 +848,11 @@ class SecureComsAdapter(BasePlatformAdapter):
492
848
  _TURN_AUTHZ.record(channel_id, sender_id, tools_allowed)
493
849
  if not tools_allowed and not _TOOL_VETO_ACTIVE:
494
850
  # This Hermes cannot be made to refuse tool calls, so a turn the
495
- # daemon did not grant tools never reaches it. Acked by _pump.
851
+ # daemon did not grant tools never reaches it. Acked at once by
852
+ # _receive_message.
496
853
  logger.warning("SecureComs: refused a turn without a tool grant (tool veto unavailable)")
497
854
  await self._notify_refused(channel_id, frame)
498
- return
855
+ return None
499
856
 
500
857
  source = self.build_source(
501
858
  chat_id=channel_id,
@@ -509,12 +866,32 @@ class SecureComsAdapter(BasePlatformAdapter):
509
866
  # ungranted text is escaped: Hermes strips leading @url:/@file:
510
867
  # refs before its "/" check, so "@url:x /reset" is a command too.
511
868
  text = securecoms_authz.COMMAND_ESCAPE + text
869
+ # Image/file attachments the daemon decrypted to disk ride as Hermes
870
+ # media, the same shape Hermes's own Signal adapter emits
871
+ # (gateway/platforms/signal.py): media_urls = local file paths,
872
+ # media_types = mimes, message_type from the first matching MIME
873
+ # prefix, else DOCUMENT. An image with no caption still runs a turn.
874
+ attachments, dropped = securecoms_transport.scan_inbound_attachments(frame)
875
+ if dropped:
876
+ # Content-free: a fixed line plus a process counter, never a path,
877
+ # name or mime (tests/test_logging_privacy.py bars dynamic args).
878
+ _ATTACHMENTS_DROPPED += dropped
879
+ logger.info("SecureComs: dropped inbound attachment(s) that failed validation")
880
+ media_kw: Dict[str, Any] = {}
881
+ message_type = MessageType.TEXT
882
+ if attachments:
883
+ media_kw = {
884
+ "media_urls": [p for p, _ in attachments],
885
+ "media_types": [m for _, m in attachments],
886
+ }
887
+ message_type = _message_type_for_mimes(media_kw["media_types"])
512
888
  event = MessageEvent(
513
- text=text,
514
- message_type=MessageType.TEXT,
889
+ text=text if isinstance(text, str) else "",
890
+ message_type=message_type,
515
891
  source=source,
516
892
  message_id=str(frame.get("message_id") or int(time.time() * 1000)),
517
893
  timestamp=datetime.now(),
894
+ **media_kw,
518
895
  )
519
896
  # Sender role rides as event metadata, never in the user text.
520
897
  metadata = getattr(event, "metadata", None)
@@ -532,7 +909,22 @@ class SecureComsAdapter(BasePlatformAdapter):
532
909
  prompts = [p for p in (voice_prompt_of(frame), None if tools_allowed else securecoms_authz.NO_TOOLS_TURN_NOTE) if p]
533
910
  if prompts:
534
911
  event.channel_prompt = "\n\n".join(prompts)
912
+ event._securecoms_session_was_busy = self._session_was_busy(event)
535
913
  await self.handle_message(event)
914
+ return event
915
+
916
+ def _may_look_up(self, channel_id: str) -> bool:
917
+ """Negative cache for unknown channels: True at most once per
918
+ _UNKNOWN_CHANNEL_RETRY_S per channel (bounded map, oldest evicted)."""
919
+ now = time.monotonic()
920
+ last = self._unknown_channel_lookups.get(channel_id)
921
+ if last is not None and now - last < _UNKNOWN_CHANNEL_RETRY_S:
922
+ return False
923
+ self._unknown_channel_lookups.pop(channel_id, None)
924
+ self._unknown_channel_lookups[channel_id] = now
925
+ while len(self._unknown_channel_lookups) > _UNKNOWN_CHANNEL_LOOKUPS_MAX:
926
+ self._unknown_channel_lookups.pop(next(iter(self._unknown_channel_lookups)))
927
+ return True
536
928
 
537
929
  async def _notify_refused(self, channel_id: str, frame: Dict[str, Any]) -> None:
538
930
  """One short notice per channel that this agent cannot respond there.
@@ -639,7 +1031,7 @@ class SecureComsAdapter(BasePlatformAdapter):
639
1031
  meta = self._channels.get(chat_id, {})
640
1032
  return {
641
1033
  "name": meta.get("name", chat_id),
642
- "type": "dm" if meta.get("is_dm", True) else "group",
1034
+ "type": "dm" if meta.get("is_dm") is True else "group",
643
1035
  "chat_id": chat_id,
644
1036
  }
645
1037