@inline-chat/hermes-agent-adapter 1.0.1 → 1.2.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.
@@ -8,12 +8,17 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import base64
11
+ import concurrent.futures
12
+ import contextvars
13
+ import functools
11
14
  import hashlib
15
+ import inspect
12
16
  import json
13
17
  import logging
14
18
  import math
15
19
  import mimetypes
16
20
  import os
21
+ import queue
17
22
  import re
18
23
  import secrets
19
24
  import shlex
@@ -22,8 +27,11 @@ import signal
22
27
  import socket
23
28
  import subprocess
24
29
  import sys
30
+ import threading
25
31
  import time
26
- from collections import OrderedDict
32
+ import urllib.error
33
+ import urllib.request
34
+ from collections import OrderedDict, deque
27
35
  from datetime import datetime, timezone
28
36
  from pathlib import Path
29
37
  from typing import Any, Callable, Dict, List, NamedTuple, Optional
@@ -52,11 +60,14 @@ from .message_actions import (
52
60
  build_inline_agent_action_input,
53
61
  build_inline_agent_action_turn_id,
54
62
  build_inline_system_action_id,
63
+ inline_message_widget_ids,
64
+ inline_widget_call,
55
65
  parse_inline_agent_action_reply_target,
56
66
  resolve_inline_message_action_ownership,
57
67
  )
58
- from .profile import bot_state_key, inline_env, legacy_state_dir, may_adopt_legacy_state, state_dir
68
+ from .profile import bot_state_key, hermes_home, inline_env, legacy_state_dir, may_adopt_legacy_state, state_dir
59
69
  from .telemetry import capture_plugin_error
70
+ from .widgets import has_widget_markup, scan_widgets, split_widget_aware, streaming_preview, without_widgets
60
71
 
61
72
  logger = logging.getLogger(__name__)
62
73
 
@@ -64,6 +75,7 @@ _DEFAULT_SIDECAR_PORT = 8794
64
75
  _ACTION_EDIT_RETRY_DELAY_SECONDS = 0.5
65
76
  _DEFAULT_SIDECAR_BIND = "127.0.0.1"
66
77
  _MAX_MESSAGE_LENGTH = 4000
78
+ _APPROVAL_CHOICES = frozenset({"once", "session", "always", "deny"})
67
79
  _MODEL_PAGE_SIZE = 8
68
80
  _CHOICE_PICKER_MAX_CHOICES = 16
69
81
  _CHOICE_PICKER_TTL_SECONDS = 2 * 60
@@ -168,6 +180,29 @@ _INLINE_ENTITY_TYPE_NAMES = {
168
180
  14: "group_mention",
169
181
  }
170
182
 
183
+ _APPROVAL_COMMAND_LIMIT = 2000
184
+ _APPROVAL_FENCE_PATTERN = re.compile(r"(`{3,})[^\n]*\n(.*?)\n\1", re.DOTALL)
185
+ # Same headings as OpenClaw's Inline, Slack and Discord approval cards.
186
+ _APPROVAL_DECISION_HEADINGS = {
187
+ "once": "✅ Allowed once",
188
+ "session": "✅ Allowed for this session",
189
+ "always": "✅ Always allowed",
190
+ "deny": "❌ Denied",
191
+ }
192
+ _APPROVAL_EXPIRED_HEADING = "⌛ Approval expired"
193
+
194
+
195
+ def _approval_command_block(command: str) -> str:
196
+ body = command if len(command) <= _APPROVAL_COMMAND_LIMIT else f"{command[:_APPROVAL_COMMAND_LIMIT]}…"
197
+ fence = "````" if "```" in body else "```"
198
+ return f"{fence}sh\n{body}\n{fence}"
199
+
200
+
201
+ def _approval_card_text(heading: str, command: str) -> str:
202
+ """An answered or expired card: the outcome above the command it was for."""
203
+ return f"{heading}\n\n{_approval_command_block(command)}" if command else heading
204
+
205
+
171
206
  _ENV_REF_PATTERN = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
172
207
  _SIDECAR_RETRYABLE_KINDS = {"transient"}
173
208
  _INLINE_DISPLAY_DEFAULTS = {
@@ -175,7 +210,9 @@ _INLINE_DISPLAY_DEFAULTS = {
175
210
  "tool_progress_grouping": "accumulate",
176
211
  "cleanup_progress": True,
177
212
  "streaming": False,
178
- "interim_assistant_messages": False,
213
+ # Hermes' own between-tool-calls messages: Friendly posts them as short updates and
214
+ # Detailed folds them into the progress message (see InlineAdapter.send).
215
+ "interim_assistant_messages": True,
179
216
  "show_reasoning": False,
180
217
  "tool_preview_length": 40,
181
218
  "busy_ack_detail": False,
@@ -843,6 +880,7 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
843
880
  "state_path",
844
881
  "settings_path",
845
882
  "parse_markdown",
883
+ "cron_mention_prefix",
846
884
  "require_mention",
847
885
  "strict_mention",
848
886
  "allowed_chats",
@@ -878,17 +916,578 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
878
916
  return extra
879
917
 
880
918
 
919
+ async def _wait_for_exit(proc: subprocess.Popen, timeout: float) -> bool:
920
+ """Wait for ``proc`` without blocking the loop or taking an executor worker; False on timeout."""
921
+ deadline = time.monotonic() + timeout
922
+ while proc.poll() is None:
923
+ if time.monotonic() >= deadline:
924
+ return False
925
+ await asyncio.sleep(0.05)
926
+ return True
927
+
928
+
929
+ def _token_count(tokens: int) -> str:
930
+ """"850", "439k", "1.2M": the bridge's wording for context sizes."""
931
+ if tokens < 1_000:
932
+ return str(tokens)
933
+ if tokens < 1_000_000:
934
+ return f"{tokens // 1_000}k"
935
+ tenths = tokens // 100_000
936
+ return f"{tenths // 10}M" if tenths % 10 == 0 else f"{tenths // 10}.{tenths % 10}M"
937
+
938
+
939
+ def _accepts_keyword(fn: Callable[..., Any], name: str) -> bool:
940
+ """Whether a host callable takes keyword ``name`` (host APIs grow optional keywords)."""
941
+ try:
942
+ params = inspect.signature(fn).parameters
943
+ except (TypeError, ValueError):
944
+ return False
945
+ return name in params or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
946
+
947
+
948
+ # ── Detailed progress (Output: Detailed / Friendly) ──────────────────────────────────────
949
+ # Hermes reports its work to plugins through public hooks: pre/post_tool_call,
950
+ # subagent_start/stop and pre_approval_request/post_approval_response. Each hook finds the
951
+ # Inline turn its session belongs to (gateway.session_context) and only enqueues in memory:
952
+ # pre_tool_call blocks the tool while it runs. One worker thread per adapter posts the
953
+ # events, in order, to the sidecar, which draws them with @inline-chat/agent-activity.
954
+
955
+ _ACTIVITY_POST_TIMEOUT_SECONDS = 10.0
956
+ _ACTIVITY_FLUSH_TIMEOUT_SECONDS = 30.0
957
+ # The answer waits at most this long for "Worked for" to land above it.
958
+ _ACTIVITY_FINAL_WAIT_SECONDS = 15.0
959
+ # An interim message waits at most this long for the chat's mode before it posts as is.
960
+ _ACTIVITY_MODE_WAIT_SECONDS = 5.0
961
+ _ACTIVITY_OUTPUT_LIMIT = 2_000
962
+ _ACTIVITY_DIFF_LIMIT = 8_000
963
+ _ACTIVITY_TITLE_LIMIT = 100
964
+ _ACTIVITY_PLAN_LIMIT = 50
965
+ _ACTIVITY_HOOKS = (
966
+ "pre_tool_call", "post_tool_call", "subagent_start", "subagent_stop",
967
+ "pre_approval_request", "post_approval_response",
968
+ )
969
+ # Tools whose work is visible on its own: a question card, a message the agent sent.
970
+ _ACTIVITY_SKIPPED_TOOLS = frozenset({"clarify"})
971
+ _ACTIVITY_VISIBLE_INLINE_ACTIONS = frozenset({"send_message", "send_widget"})
972
+ _ACTIVITY_COMMAND_TOOLS = frozenset({"terminal", "exec", "bash", "shell"})
973
+ _ACTIVITY_READ_TOOLS = frozenset({"read_file", "file_read"})
974
+ _ACTIVITY_SEARCH_TOOLS = frozenset({"search_files", "grep"})
975
+ _ACTIVITY_EDIT_TOOLS = frozenset({"write_file", "file_write", "file_edit", "patch", "apply_patch"})
976
+ _ACTIVITY_WEB_SEARCH_TOOLS = frozenset({"web_search", "x_search"})
977
+ _ACTIVITY_PLAN_TOOLS = frozenset({"todo", "todo_list"})
978
+ # Titles never come from a command, output or reasoning: only the tool and its explicit intent.
979
+ _ACTIVITY_TOOL_TITLES = {
980
+ "execute_code": "Ran code",
981
+ "browser": "Used the browser",
982
+ "browser_exec": "Used the browser",
983
+ "browser_cdp": "Used the browser",
984
+ "browser_dialog": "Used the browser",
985
+ "browser_vault": "Used the browser",
986
+ "web_extract": "Read a web page",
987
+ "tool_describe": "Checked available tools",
988
+ "skill_view": "Read instructions",
989
+ "skills_list": "Listed skills",
990
+ "delegate_task": "Delegated a task",
991
+ "image_generate": "Generated an image",
992
+ "video_generate": "Generated a video",
993
+ "vision_analyze": "Looked at an image",
994
+ "video_analyze": "Watched a video",
995
+ "text_to_speech": "Generated speech",
996
+ "session_search": "Searched past sessions",
997
+ "memory": "Updated memory",
998
+ "read_terminal": "Read terminal output",
999
+ "close_terminal": "Closed a terminal",
1000
+ "process_manage": "Managed a background process",
1001
+ "cronjob_manage": "Managed scheduled jobs",
1002
+ "skill_manage": "Managed skills",
1003
+ }
1004
+
1005
+ _ACTIVITY_LOCK = threading.Lock()
1006
+ # Set while the adapter answers on its own (a /threads reply, a button's fallback, a question):
1007
+ # those sends are neither Hermes' answer nor its commentary, so a turn's progress ignores them.
1008
+ _ACTIVITY_LOCAL_SEND: contextvars.ContextVar[bool] = contextvars.ContextVar("inline_activity_local_send", default=False)
1009
+ # (chat_id, thread_id) as Hermes binds them for the turn's session -> open turns, oldest first.
1010
+ _ACTIVITY_TURNS: Dict[tuple[str, str], List["_ActivityTurn"]] = {}
1011
+ _ACTIVITY_LOGGED_FAILURES: set[str] = set()
1012
+ # gateway.session_context.get_session_env, resolved off the hook path.
1013
+ _ACTIVITY_SESSION_ENV: Optional[Callable[..., Any]] = None
1014
+
1015
+
1016
+ def _is_hermes_heartbeat(content: Any) -> bool:
1017
+ """Hermes' long-running "⏳ Working — N min" notice; every locale starts it with ⏳."""
1018
+ return isinstance(content, str) and content.lstrip().startswith("⏳")
1019
+
1020
+
1021
+ def _activity_local_sends(fn: Callable[..., Any]) -> Callable[..., Any]:
1022
+ @functools.wraps(fn)
1023
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
1024
+ token = _ACTIVITY_LOCAL_SEND.set(True)
1025
+ try:
1026
+ return await fn(*args, **kwargs)
1027
+ finally:
1028
+ _ACTIVITY_LOCAL_SEND.reset(token)
1029
+
1030
+ return wrapper
1031
+
1032
+
1033
+ def _activity_log_failure(operation: str, exc: BaseException) -> None:
1034
+ """Progress never breaks a turn: log the first failure of each kind, then stay quiet."""
1035
+ if operation in _ACTIVITY_LOGGED_FAILURES:
1036
+ logger.debug("[inline] progress %s failed: %s", operation, exc)
1037
+ return
1038
+ _ACTIVITY_LOGGED_FAILURES.add(operation)
1039
+ logger.warning("[inline] progress %s failed: %s: %s; the turn goes on without it", operation, type(exc).__name__, exc)
1040
+
1041
+
1042
+ def _activity_session_env_reader() -> Optional[Callable[..., Any]]:
1043
+ global _ACTIVITY_SESSION_ENV
1044
+ if _ACTIVITY_SESSION_ENV is None:
1045
+ try:
1046
+ from gateway.session_context import get_session_env
1047
+ _ACTIVITY_SESSION_ENV = get_session_env
1048
+ except Exception:
1049
+ return None
1050
+ return _ACTIVITY_SESSION_ENV
1051
+
1052
+
1053
+ def _activity_str(value: Any) -> str:
1054
+ return value.strip() if isinstance(value, str) else ""
1055
+
1056
+
1057
+ def _activity_tail(text: str, limit: int) -> str:
1058
+ return text if len(text) <= limit else text[-limit:]
1059
+
1060
+
1061
+ def _activity_json(value: Any) -> Any:
1062
+ if isinstance(value, (dict, list)):
1063
+ return value
1064
+ if isinstance(value, str) and value[:1] in {"{", "["}:
1065
+ try:
1066
+ return json.loads(value)
1067
+ except ValueError:
1068
+ return None
1069
+ return None
1070
+
1071
+
1072
+ def _activity_plan_steps(todos: Any) -> List[Dict[str, str]]:
1073
+ steps: List[Dict[str, str]] = []
1074
+ for todo in todos if isinstance(todos, list) else []:
1075
+ if not isinstance(todo, dict):
1076
+ continue
1077
+ text = _activity_str(todo.get("content"))
1078
+ if not text:
1079
+ continue
1080
+ status = _activity_str(todo.get("status"))
1081
+ if status == "cancelled":
1082
+ steps.append({"text": f"{text} (cancelled)", "status": "completed"})
1083
+ else:
1084
+ steps.append({"text": text, "status": status if status in {"in_progress", "completed"} else "pending"})
1085
+ if len(steps) >= _ACTIVITY_PLAN_LIMIT:
1086
+ break
1087
+ return steps
1088
+
1089
+
1090
+ def _activity_tool_body(name: str, args: Any) -> Optional[Dict[str, Any]]:
1091
+ """The row a Hermes tool call starts, or None for tools that show no row."""
1092
+ args = args if isinstance(args, dict) else {}
1093
+ if name in _ACTIVITY_SKIPPED_TOOLS or name == "delegate_task":
1094
+ # Sub-agents draw their own rows through subagent_start/stop.
1095
+ return None
1096
+ if name == "inline":
1097
+ action = _activity_str(args.get("action"))
1098
+ if action in _ACTIVITY_VISIBLE_INLINE_ACTIONS:
1099
+ return None
1100
+ return {"kind": "tool", "name": "inline", "title": f"Inline: {action.replace('_', ' ')}" if action else "Used Inline"}
1101
+ if name in _ACTIVITY_COMMAND_TOOLS:
1102
+ command = _activity_str(args.get("command"))
1103
+ return {"kind": "command", "command": command} if command else {"kind": "tool", "name": name, "title": "Ran a command"}
1104
+ path = _activity_str(args.get("path")) or _activity_str(args.get("file_path"))
1105
+ if name in _ACTIVITY_READ_TOOLS and path:
1106
+ return {"kind": "read", "path": path}
1107
+ if name in _ACTIVITY_SEARCH_TOOLS:
1108
+ query = _activity_str(args.get("pattern")) or _activity_str(args.get("query"))
1109
+ body: Dict[str, Any] = {"kind": "search"}
1110
+ if query:
1111
+ body["query"] = query
1112
+ if path and path != ".":
1113
+ body["path"] = path
1114
+ return body
1115
+ if name in _ACTIVITY_EDIT_TOOLS:
1116
+ if path:
1117
+ return {"kind": "edit", "files": [{"path": path}]}
1118
+ return {"kind": "tool", "name": name, "title": "Edited files"}
1119
+ if name in _ACTIVITY_WEB_SEARCH_TOOLS:
1120
+ query = _activity_str(args.get("query"))
1121
+ return {"kind": "web", "query": query} if query else {"kind": "tool", "name": name, "title": "Searched the web"}
1122
+ if name == "web_extract" or name.startswith("browser"):
1123
+ urls = args.get("urls") if isinstance(args.get("urls"), list) else []
1124
+ url = _activity_str(args.get("url")) or next((_activity_str(u) for u in urls if _activity_str(u)), "")
1125
+ if url:
1126
+ return {"kind": "web", "url": url}
1127
+ if name in _ACTIVITY_PLAN_TOOLS:
1128
+ steps = _activity_plan_steps(args.get("todos"))
1129
+ # Reading the list (no todos) changes nothing worth a row.
1130
+ return {"kind": "plan", "steps": steps} if steps else None
1131
+ description = " ".join(_activity_str(args.get("description")).split())
1132
+ title = description or _ACTIVITY_TOOL_TITLES.get(name) or f"Used {name}"
1133
+ return {"kind": "tool", "name": name, "title": title[:_ACTIVITY_TITLE_LIMIT]}
1134
+
1135
+
1136
+ def _activity_tool_result(name: str, args: Any, body: Dict[str, Any], result: Any, status: str, error: str) -> Dict[str, Any]:
1137
+ """The row's body once the tool finished: output tails, exit codes, diffs, plans, errors."""
1138
+ args = args if isinstance(args, dict) else {}
1139
+ parsed = _activity_json(result)
1140
+ record = parsed if isinstance(parsed, dict) else {}
1141
+ kind = body.get("kind")
1142
+ if kind == "command":
1143
+ merged = dict(body)
1144
+ output = record.get("output")
1145
+ if isinstance(output, str) and output.strip():
1146
+ merged["output"] = _activity_tail(output.rstrip(), _ACTIVITY_OUTPUT_LIMIT)
1147
+ exit_code = record.get("exit_code")
1148
+ if isinstance(exit_code, int) and not isinstance(exit_code, bool):
1149
+ merged["exitCode"] = exit_code
1150
+ return merged
1151
+ if name in _ACTIVITY_EDIT_TOOLS:
1152
+ files: List[Dict[str, Any]] = []
1153
+ for key, change in (("files_created", "added"), ("files_deleted", "deleted"), ("files_modified", "modified")):
1154
+ for path in record.get(key) or []:
1155
+ if isinstance(path, str) and path.strip() and all(f["path"] != path for f in files):
1156
+ files.append({"path": path, "change": change})
1157
+ path = _activity_str(args.get("path")) or _activity_str(args.get("file_path"))
1158
+ if not files and path:
1159
+ files.append({"path": path})
1160
+ diff = record.get("diff")
1161
+ if len(files) == 1 and isinstance(diff, str) and diff.strip():
1162
+ files[0]["diff"] = _activity_tail(diff.rstrip(), _ACTIVITY_DIFF_LIMIT)
1163
+ if files:
1164
+ return {"kind": "edit", "files": files}
1165
+ return body
1166
+ if kind == "plan":
1167
+ steps = _activity_plan_steps(record.get("todos"))
1168
+ return {"kind": "plan", "steps": steps} if steps else body
1169
+ if kind == "tool" and status == "error" and error:
1170
+ return {**body, "error": error.splitlines()[0][:500]}
1171
+ return body
1172
+
1173
+
1174
+ def _activity_agent_state(status: Any) -> str:
1175
+ value = _activity_str(status).lower()
1176
+ if value in {"interrupted", "cancelled", "canceled", "stopped"}:
1177
+ return "cancelled"
1178
+ if value in {"failed", "error", "timeout"}:
1179
+ return "failed"
1180
+ return "done"
1181
+
1182
+
1183
+ def _activity_same_text(left: Optional[str], right: Optional[str]) -> bool:
1184
+ return bool(left) and bool(right) and " ".join(str(left).split()) == " ".join(str(right).split())
1185
+
1186
+
1187
+ class _ActivityTurn:
1188
+ """One Hermes turn in one Inline chat, from on_processing_start to its answer."""
1189
+
1190
+ def __init__(self, *, pump: "_ActivityPump", turn_id: str, target: Dict[str, str], key: tuple[str, str], user_id: str, home: str):
1191
+ self.pump = pump
1192
+ self.turn_id = turn_id
1193
+ self.target = target
1194
+ self.key = key
1195
+ self.user_id = user_id
1196
+ self.home = home
1197
+ # "detailed", "friendly", or "off" when the sidecar could not be reached; None until known.
1198
+ self.mode: Optional[str] = None
1199
+ self.mode_ready = threading.Event()
1200
+ self.closed = False
1201
+ # People's messages that arrived mid-turn: Hermes' busy notice replies to them, and
1202
+ # that send is not this turn's answer.
1203
+ self.later_messages: set[str] = set()
1204
+ # Worker-thread state below.
1205
+ self.rows: Dict[str, Dict[str, Any]] = {}
1206
+ # (session, tool call) -> row; None for calls that show no row.
1207
+ self.calls: Dict[tuple[str, str], Optional[str]] = {}
1208
+ self.unmatched: Dict[tuple[str, str], deque] = {}
1209
+ self.children: Dict[str, str] = {}
1210
+ self.held: Optional[str] = None
1211
+ self.next_row = 0
1212
+
1213
+
1214
+ def _activity_register(turn: _ActivityTurn) -> None:
1215
+ with _ACTIVITY_LOCK:
1216
+ _ACTIVITY_TURNS.setdefault(turn.key, []).append(turn)
1217
+
1218
+
1219
+ def _activity_unregister(turn: _ActivityTurn) -> None:
1220
+ with _ACTIVITY_LOCK:
1221
+ turns = _ACTIVITY_TURNS.get(turn.key)
1222
+ if turns and turn in turns:
1223
+ turns.remove(turn)
1224
+ if not turns:
1225
+ _ACTIVITY_TURNS.pop(turn.key, None)
1226
+
1227
+
1228
+ def _activity_turns_for(key: tuple[str, str]) -> List[_ActivityTurn]:
1229
+ with _ACTIVITY_LOCK:
1230
+ return list(_ACTIVITY_TURNS.get(key, ()))
1231
+
1232
+
1233
+ def _activity_turn_for_session() -> Optional[_ActivityTurn]:
1234
+ """The open Inline turn the calling hook's session belongs to. In memory only: no I/O."""
1235
+ get_env = _ACTIVITY_SESSION_ENV
1236
+ if get_env is None or str(get_env("HERMES_SESSION_PLATFORM", "") or "") != "inline":
1237
+ return None
1238
+ key = (str(get_env("HERMES_SESSION_CHAT_ID", "") or ""), str(get_env("HERMES_SESSION_THREAD_ID", "") or ""))
1239
+ candidates = _activity_turns_for(key)
1240
+ if len(candidates) > 1:
1241
+ # Per-user sessions share a group chat; several profiles' bots can share one too.
1242
+ user_id = str(get_env("HERMES_SESSION_USER_ID", "") or "")
1243
+ candidates = [turn for turn in candidates if turn.user_id == user_id] or candidates
1244
+ if len(candidates) > 1:
1245
+ home = str(hermes_home())
1246
+ candidates = [turn for turn in candidates if turn.home == home] or candidates
1247
+ return candidates[-1] if candidates else None
1248
+
1249
+
1250
+ def _activity_hook(kind: str) -> Callable[..., None]:
1251
+ def callback(**kwargs: Any) -> None:
1252
+ # pre_tool_call is fail-closed and blocks the tool while it runs: find the turn,
1253
+ # copy the payload and enqueue it, nothing else. Never raise, never return a directive.
1254
+ try:
1255
+ turn = _activity_turn_for_session()
1256
+ if turn is not None and not turn.closed and turn.mode not in {"friendly", "off"}:
1257
+ payload = dict(kwargs)
1258
+ if isinstance(payload.get("args"), dict):
1259
+ payload["args"] = dict(payload["args"])
1260
+ turn.pump.put(kind, turn, payload)
1261
+ except Exception as exc:
1262
+ _activity_log_failure(f"hook {kind}", exc)
1263
+ return None
1264
+
1265
+ callback.__name__ = f"inline_activity_{kind}"
1266
+ return callback
1267
+
1268
+
1269
+ def _register_activity_hooks(ctx: Any) -> None:
1270
+ register_hook = getattr(ctx, "register_hook", None)
1271
+ if not callable(register_hook):
1272
+ logger.info("[inline] this Hermes has no plugin hooks; Detailed output shows only the turn's time")
1273
+ return
1274
+ for name in _ACTIVITY_HOOKS:
1275
+ try:
1276
+ register_hook(name, _activity_hook(name))
1277
+ except Exception as exc:
1278
+ _activity_log_failure(f"register {name}", exc)
1279
+
1280
+
1281
+ class _ActivityPump:
1282
+ """Posts one adapter's progress events to its sidecar, in order, off the agent's threads."""
1283
+
1284
+ def __init__(self, post: Callable[[str, Dict[str, Any], float], Dict[str, Any]]):
1285
+ self._post = post
1286
+ self._queue: "queue.SimpleQueue[Any]" = queue.SimpleQueue()
1287
+ self._thread = threading.Thread(target=self._run, name="inline-activity", daemon=True)
1288
+ self._thread.start()
1289
+
1290
+ def put(self, kind: str, turn: _ActivityTurn, payload: Any = None) -> None:
1291
+ self._queue.put((kind, turn, payload, time.time()))
1292
+
1293
+ def flush(self, turn: _ActivityTurn) -> "concurrent.futures.Future[None]":
1294
+ """Resolves once every event queued before it has reached Inline."""
1295
+ future: "concurrent.futures.Future[None]" = concurrent.futures.Future()
1296
+ self.put("flush", turn, future)
1297
+ return future
1298
+
1299
+ def stop(self) -> None:
1300
+ self._queue.put(None)
1301
+
1302
+ def _run(self) -> None:
1303
+ while True:
1304
+ item = self._queue.get()
1305
+ if item is None:
1306
+ return
1307
+ kind, turn, payload, at = item
1308
+ try:
1309
+ self._handle(kind, turn, payload, at)
1310
+ except Exception as exc:
1311
+ _activity_log_failure(kind, exc)
1312
+ finally:
1313
+ if kind == "flush" and isinstance(payload, concurrent.futures.Future) and not payload.done():
1314
+ payload.set_result(None)
1315
+
1316
+ def _send(self, turn: _ActivityTurn, event: Dict[str, Any], at: float) -> Dict[str, Any]:
1317
+ return self._post("/activity", {
1318
+ "target": turn.target, "turnId": turn.turn_id, "event": event, "at": int(at * 1000),
1319
+ }, _ACTIVITY_POST_TIMEOUT_SECONDS)
1320
+
1321
+ def _handle(self, kind: str, turn: _ActivityTurn, payload: Any, at: float) -> None:
1322
+ if kind == "start":
1323
+ try:
1324
+ data = self._post("/activity", {
1325
+ "target": turn.target, "turnId": turn.turn_id, "event": {"type": "turn.start"},
1326
+ "at": int(at * 1000), "lazy": bool((payload or {}).get("lazy")),
1327
+ }, _ACTIVITY_POST_TIMEOUT_SECONDS)
1328
+ mode = str((data.get("result") or {}).get("mode") or "")
1329
+ turn.mode = mode if mode in {"detailed", "friendly"} else "detailed"
1330
+ except Exception:
1331
+ turn.mode = "off"
1332
+ raise
1333
+ finally:
1334
+ turn.mode_ready.set()
1335
+ return
1336
+ if turn.mode != "detailed":
1337
+ return
1338
+ if kind == "flush":
1339
+ self._post("/activity/flush", {"target": turn.target, "turnId": turn.turn_id}, _ACTIVITY_FLUSH_TIMEOUT_SECONDS)
1340
+ return
1341
+ for event in self._events(kind, turn, payload):
1342
+ self._send(turn, event, at)
1343
+
1344
+ def _events(self, kind: str, turn: _ActivityTurn, payload: Any) -> List[Dict[str, Any]]:
1345
+ if kind == "commentary":
1346
+ text = str(payload or "").strip()
1347
+ if not text:
1348
+ return []
1349
+ # Held until the next event: an interim message that turns out to be the answer
1350
+ # (Hermes then sends the same text as the final) is not drawn twice.
1351
+ events = self._release_held(turn)
1352
+ turn.held = text
1353
+ return events
1354
+ if kind == "end":
1355
+ final = (payload or {}).get("final")
1356
+ if turn.held and _activity_same_text(turn.held, final):
1357
+ turn.held = None
1358
+ return [*self._release_held(turn), {"type": "turn.end", "outcome": (payload or {}).get("outcome") or "success"}]
1359
+ events = self._release_held(turn)
1360
+ if kind == "split":
1361
+ events.append({"type": "split"})
1362
+ elif kind in {"pre_approval_request", "post_approval_response"}:
1363
+ # Smart approval is decided by a model, not a person: nothing waits.
1364
+ if _activity_str((payload or {}).get("surface")) != "smart":
1365
+ events.append({"type": "waiting", "waiting": kind == "pre_approval_request"})
1366
+ elif kind == "pre_tool_call":
1367
+ events.extend(self._tool_start(turn, payload or {}))
1368
+ elif kind == "post_tool_call":
1369
+ events.extend(self._tool_end(turn, payload or {}))
1370
+ elif kind == "subagent_start":
1371
+ events.extend(self._agent_start(turn, payload or {}))
1372
+ elif kind == "subagent_stop":
1373
+ events.extend(self._agent_stop(turn, payload or {}))
1374
+ return events
1375
+
1376
+ @staticmethod
1377
+ def _release_held(turn: _ActivityTurn) -> List[Dict[str, Any]]:
1378
+ held, turn.held = turn.held, None
1379
+ return [{"type": "commentary", "text": held}] if held else []
1380
+
1381
+ @staticmethod
1382
+ def _new_row(turn: _ActivityTurn, call_id: str) -> str:
1383
+ if call_id and f"tool:{call_id}" not in turn.rows:
1384
+ return f"tool:{call_id}"
1385
+ turn.next_row += 1
1386
+ return f"tool:#{turn.next_row}"
1387
+
1388
+ def _tool_start(self, turn: _ActivityTurn, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1389
+ name = _activity_str(payload.get("tool_name"))
1390
+ call_id = _activity_str(payload.get("tool_call_id"))
1391
+ session_id = _activity_str(payload.get("session_id"))
1392
+ body = _activity_tool_body(name, payload.get("args"))
1393
+ if body is None:
1394
+ if call_id:
1395
+ turn.calls[(session_id, call_id)] = None
1396
+ return []
1397
+ # One plan per agent: later updates redraw the same row.
1398
+ row_id = f"plan:{session_id or 'main'}" if body.get("kind") == "plan" else self._new_row(turn, call_id)
1399
+ parent = turn.children.get(session_id)
1400
+ turn.rows[row_id] = {"name": name, "args": payload.get("args"), "body": body}
1401
+ if call_id:
1402
+ turn.calls[(session_id, call_id)] = row_id
1403
+ else:
1404
+ turn.unmatched.setdefault((session_id, name), deque()).append(row_id)
1405
+ event: Dict[str, Any] = {"type": "item", "id": row_id, "body": body, "state": "running"}
1406
+ if parent:
1407
+ event["parent"] = parent
1408
+ return [event]
1409
+
1410
+ def _tool_end(self, turn: _ActivityTurn, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1411
+ name = _activity_str(payload.get("tool_name"))
1412
+ call_id = _activity_str(payload.get("tool_call_id"))
1413
+ session_id = _activity_str(payload.get("session_id"))
1414
+ status = _activity_str(payload.get("status")) or "ok"
1415
+ error = _activity_str(payload.get("error_message"))
1416
+ row_id: Optional[str] = None
1417
+ if call_id and (session_id, call_id) in turn.calls:
1418
+ row_id = turn.calls.pop((session_id, call_id))
1419
+ if row_id is None and not (name == "delegate_task" and status == "error"):
1420
+ return []
1421
+ elif not call_id:
1422
+ pending = turn.unmatched.get((session_id, name))
1423
+ row_id = pending.popleft() if pending else None
1424
+ event: Dict[str, Any] = {"type": "item", "state": "failed" if status == "error" else "done"}
1425
+ row = turn.rows.get(row_id) if row_id else None
1426
+ if row is None:
1427
+ # No start seen (or a sub-agent hand-off that failed before it began): draw it now.
1428
+ body = _activity_tool_body(name, payload.get("args"))
1429
+ if body is None and name == "delegate_task" and status == "error":
1430
+ body = {"kind": "tool", "name": name, "title": _ACTIVITY_TOOL_TITLES["delegate_task"]}
1431
+ if body is None:
1432
+ return []
1433
+ row_id = self._new_row(turn, call_id)
1434
+ row = {"name": name, "args": payload.get("args"), "body": body}
1435
+ turn.rows[row_id] = row
1436
+ parent = turn.children.get(session_id)
1437
+ if parent:
1438
+ event["parent"] = parent
1439
+ event["id"] = row_id
1440
+ event["body"] = _activity_tool_result(name, row.get("args"), row["body"], payload.get("result"), status, error)
1441
+ duration = payload.get("duration_ms")
1442
+ if isinstance(duration, (int, float)) and not isinstance(duration, bool) and duration >= 0:
1443
+ event["durationMs"] = int(duration)
1444
+ return [event]
1445
+
1446
+ @staticmethod
1447
+ def _agent_start(turn: _ActivityTurn, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1448
+ child = _activity_str(payload.get("child_subagent_id")) or _activity_str(payload.get("child_session_id"))
1449
+ if not child:
1450
+ return []
1451
+ row_id = f"agent:{child}"
1452
+ goal = _activity_str(payload.get("child_goal"))
1453
+ body: Dict[str, Any] = {"kind": "agent"}
1454
+ if goal:
1455
+ body["title"] = " ".join(goal.splitlines()[0].split())[:_ACTIVITY_TITLE_LIMIT]
1456
+ child_session = _activity_str(payload.get("child_session_id"))
1457
+ if child_session:
1458
+ turn.children[child_session] = row_id
1459
+ event: Dict[str, Any] = {"type": "item", "id": row_id, "body": body, "state": "running"}
1460
+ parent_subagent = _activity_str(payload.get("parent_subagent_id"))
1461
+ parent = f"agent:{parent_subagent}" if parent_subagent else turn.children.get(_activity_str(payload.get("parent_session_id")))
1462
+ if parent and parent != row_id:
1463
+ event["parent"] = parent
1464
+ return [event]
1465
+
1466
+ @staticmethod
1467
+ def _agent_stop(turn: _ActivityTurn, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1468
+ row_id = turn.children.get(_activity_str(payload.get("child_session_id")))
1469
+ if not row_id:
1470
+ return []
1471
+ event: Dict[str, Any] = {"type": "item", "id": row_id, "state": _activity_agent_state(payload.get("child_status"))}
1472
+ duration = payload.get("duration_ms")
1473
+ if isinstance(duration, (int, float)) and not isinstance(duration, bool) and duration > 0:
1474
+ event["durationMs"] = int(duration)
1475
+ return [event]
1476
+
1477
+
881
1478
  class InlineAdapter(BasePlatformAdapter):
882
1479
  MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
883
1480
  supports_code_blocks = True
884
1481
  splits_long_messages = True
1482
+ # Command preview on an approval card; Hermes sends a longer command whole before the card.
1483
+ _EA_CMD_BUDGET = 2000
885
1484
 
886
1485
  def __init__(self, config: PlatformConfig, *, use_ephemeral_sidecar_port: bool = False):
887
1486
  super().__init__(config, Platform("inline"))
888
1487
  extra = config.extra or {}
889
1488
 
890
1489
  if _truthy(inline_env("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")):
891
- logger.warning("[inline] experimental tool timelines are unavailable; ignoring INLINE_EXPERIMENTAL_AGENT_ACTIVITY and using ordinary typing")
1490
+ logger.warning("[inline] INLINE_EXPERIMENTAL_AGENT_ACTIVITY is ignored; progress follows the bot's Output setting (Detailed or Friendly)")
892
1491
  self._token = _config_token(config)
893
1492
  self._base_url = inline_env("INLINE_BASE_URL") or extra.get("base_url") or "https://api.inline.chat"
894
1493
  self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or inline_env("INLINE_SIDECAR_BIND"))
@@ -910,6 +1509,10 @@ class InlineAdapter(BasePlatformAdapter):
910
1509
  _configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
911
1510
  self._node_bin = _find_node_bin() or "node"
912
1511
  self._parse_markdown = _truthy(extra.get("parse_markdown") if "parse_markdown" in extra else inline_env("INLINE_PARSE_MARKDOWN"), True)
1512
+ cron_mention_prefix = extra.get("cron_mention_prefix") if "cron_mention_prefix" in extra else inline_env("INLINE_CRON_MENTION_PREFIX")
1513
+ self._cron_mention_prefix = cron_mention_prefix.strip() if isinstance(cron_mention_prefix, str) else ""
1514
+ if self._cron_mention_prefix and not self._parse_markdown:
1515
+ logger.warning("[inline] cron_mention_prefix needs parse_markdown=true; scheduled mentions are disabled")
913
1516
  self._media_max_bytes = int(
914
1517
  _normalize_positive_float(
915
1518
  extra.get("media_max_mb") if "media_max_mb" in extra else inline_env("INLINE_MEDIA_MAX_MB"),
@@ -1086,6 +1689,9 @@ class InlineAdapter(BasePlatformAdapter):
1086
1689
  self._clarify_choices: "OrderedDict[str, List[str]]" = OrderedDict()
1087
1690
  self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
1088
1691
  self._approval_sessions: "OrderedDict[str, str]" = OrderedDict()
1692
+ self._partial_sends: "OrderedDict[tuple, bool]" = OrderedDict()
1693
+ self._approval_request_ids: "OrderedDict[str, str]" = OrderedDict()
1694
+ self._approval_commands: "OrderedDict[str, str]" = OrderedDict()
1089
1695
  self._slash_sessions: "OrderedDict[str, str]" = OrderedDict()
1090
1696
  self._choice_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
1091
1697
  self._update_prompt_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
@@ -1109,6 +1715,12 @@ class InlineAdapter(BasePlatformAdapter):
1109
1715
  self._reply_thread_overrides = self._load_reply_thread_overrides()
1110
1716
  # Threads the agent made for the message it is answering: (chat, message) -> thread.
1111
1717
  self._turn_threads: "OrderedDict[tuple[str, str], str]" = OrderedDict()
1718
+ # Detailed progress: the worker that posts this adapter's turn events, and each
1719
+ # processing event's turn (on_processing_start/complete receive the same event).
1720
+ self._activity_pump: Optional[_ActivityPump] = None
1721
+ self._activity_events: "OrderedDict[int, tuple[Any, _ActivityTurn]]" = OrderedDict()
1722
+ self._activity_home = str(hermes_home())
1723
+ self._activity_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
1112
1724
  try:
1113
1725
  from . import tools as _inline_tools
1114
1726
  _inline_tools.configure_settings(self)
@@ -1369,6 +1981,7 @@ class InlineAdapter(BasePlatformAdapter):
1369
1981
  }
1370
1982
  if runner is None:
1371
1983
  return context
1984
+ context["context_usage"] = self._session_context_usage(runner, source)
1372
1985
 
1373
1986
  try:
1374
1987
  from hermes_cli.config import load_config_readonly
@@ -1489,6 +2102,22 @@ class InlineAdapter(BasePlatformAdapter):
1489
2102
  )
1490
2103
  return context
1491
2104
 
2105
+ @staticmethod
2106
+ def _session_context_usage(runner: Any, source: Any) -> Optional[List[int]]:
2107
+ """``[used, window]`` prompt tokens of the chat's resident Hermes agent, read like Hermes'
2108
+ own ``/usage``; None before its first turn or once the agent was evicted."""
2109
+ try:
2110
+ agent = runner._resident_agent_for(runner._session_key_for_source(source))
2111
+ compressor = getattr(agent, "context_compressor", None) if agent is not None else None
2112
+ used = int(getattr(compressor, "last_prompt_tokens", 0) or 0)
2113
+ window = int(getattr(compressor, "context_length", 0) or 0)
2114
+ except Exception as exc:
2115
+ _hermes_seam_unavailable(
2116
+ "GatewayRunner._resident_agent_for", "Inline agent settings omit the context window", exc,
2117
+ )
2118
+ return None
2119
+ return [used, window] if used > 0 and window > 0 else None
2120
+
1492
2121
  @staticmethod
1493
2122
  def _bot_settings_revision(context: Dict[str, Any]) -> str:
1494
2123
  visible = {
@@ -1550,6 +2179,21 @@ class InlineAdapter(BasePlatformAdapter):
1550
2179
  "options": [{**option, "disabled": False} for option in reasoning_options],
1551
2180
  }},
1552
2181
  })
2182
+ usage = context.get("context_usage")
2183
+ if usage:
2184
+ used, window = min(usage[0], usage[1]), usage[1]
2185
+ percent = used * 100 // window
2186
+ # The id carries the numbers: Inline's composer draws its context circle from
2187
+ # ``session.context:<used>:<window>``, as it does for coding-agent bridges.
2188
+ runtime_items.append({
2189
+ "id": f"session.context:{usage[0]}:{window}", "label": "Context window",
2190
+ "description": f"{_token_count(usage[0])} of {_token_count(window)} tokens used",
2191
+ "disabled": True,
2192
+ "control": {"oneofKind": "info", "info": {
2193
+ "text": f"{percent}% full",
2194
+ "tone": _INLINE_BOT_SETTINGS_TONE_WARNING if percent >= 90 else _INLINE_BOT_SETTINGS_TONE_NEUTRAL,
2195
+ }},
2196
+ })
1553
2197
  if not runtime_items:
1554
2198
  runtime_items.append({
1555
2199
  "id": "runtime-unavailable", "label": "Runtime", "disabled": False,
@@ -1880,6 +2524,7 @@ class InlineAdapter(BasePlatformAdapter):
1880
2524
  rows.append({"actions": [self._action(f"{prefix}reset", "Reset")]})
1881
2525
  return {"rows": rows}
1882
2526
 
2527
+ @_activity_local_sends
1883
2528
  async def _send_thread_status(
1884
2529
  self,
1885
2530
  *,
@@ -1896,6 +2541,7 @@ class InlineAdapter(BasePlatformAdapter):
1896
2541
  actions=self._thread_status_actions(display_chat_id=chat_id, target_chat_id=target_chat_id),
1897
2542
  )
1898
2543
 
2544
+ @_activity_local_sends
1899
2545
  async def _handle_thread_command(
1900
2546
  self,
1901
2547
  *,
@@ -1938,6 +2584,7 @@ class InlineAdapter(BasePlatformAdapter):
1938
2584
  )
1939
2585
  return True
1940
2586
 
2587
+ @_activity_local_sends
1941
2588
  async def _handle_follow_command(
1942
2589
  self,
1943
2590
  *,
@@ -1981,6 +2628,7 @@ class InlineAdapter(BasePlatformAdapter):
1981
2628
  await self.send(chat_id, body, reply_to=msg_id, metadata=metadata)
1982
2629
  return True
1983
2630
 
2631
+ @_activity_local_sends
1984
2632
  async def _handle_inline_maintenance_command(
1985
2633
  self,
1986
2634
  *,
@@ -2037,6 +2685,7 @@ class InlineAdapter(BasePlatformAdapter):
2037
2685
  return False
2038
2686
  # Re-register under the profile scope the gateway connects this adapter in.
2039
2687
  _configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
2688
+ self._activity_home = str(hermes_home())
2040
2689
  node_error = _node_version_error(self._node_bin)
2041
2690
  if node_error:
2042
2691
  self._set_fatal_error("NODE_UNSUPPORTED", node_error, retryable=False)
@@ -2110,6 +2759,7 @@ class InlineAdapter(BasePlatformAdapter):
2110
2759
  if self._bot_settings_tasks:
2111
2760
  await asyncio.gather(*self._bot_settings_tasks, return_exceptions=True)
2112
2761
  self._bot_settings_tasks.clear()
2762
+ self._activity_stop()
2113
2763
  await self._stop_sidecar()
2114
2764
  if self._http_client is not None:
2115
2765
  await self._http_client.aclose()
@@ -2170,14 +2820,34 @@ class InlineAdapter(BasePlatformAdapter):
2170
2820
  raise RuntimeError(f"Inline sidecar did not become ready within {timeout}: {last_error}")
2171
2821
 
2172
2822
  async def _supervise_sidecar(self, proc: subprocess.Popen) -> None:
2173
- if proc.stdout is None:
2823
+ """Log sidecar output. A quiet sidecar can go hours without a line, so its blocking
2824
+ read runs on a thread of its own: on the loop's shared executor every sidecar would pin
2825
+ a worker, and a gateway with more Inline bots than workers stalls every other
2826
+ ``to_thread`` job (startup included)."""
2827
+ stdout = proc.stdout
2828
+ if stdout is None:
2174
2829
  return
2175
- loop = asyncio.get_event_loop()
2176
- while True:
2830
+ loop = asyncio.get_running_loop()
2831
+ lines: "asyncio.Queue[bytes]" = asyncio.Queue()
2832
+
2833
+ def hand_off(raw: bytes) -> None:
2177
2834
  try:
2178
- line = await loop.run_in_executor(None, proc.stdout.readline)
2835
+ loop.call_soon_threadsafe(lines.put_nowait, raw)
2836
+ except RuntimeError:
2837
+ pass # the loop closed first
2838
+
2839
+ def read() -> None:
2840
+ try:
2841
+ for raw in iter(stdout.readline, b""):
2842
+ hand_off(raw)
2179
2843
  except Exception:
2180
- return
2844
+ pass
2845
+ finally:
2846
+ hand_off(b"")
2847
+
2848
+ threading.Thread(target=read, name="inline-sidecar-stdout", daemon=True).start()
2849
+ while True:
2850
+ line = await lines.get()
2181
2851
  if not line:
2182
2852
  return
2183
2853
  text = line.decode("utf-8", "replace").rstrip()
@@ -2206,9 +2876,7 @@ class InlineAdapter(BasePlatformAdapter):
2206
2876
  )
2207
2877
  except Exception:
2208
2878
  pass
2209
- try:
2210
- proc.wait(timeout=3.0)
2211
- except subprocess.TimeoutExpired:
2879
+ if not await _wait_for_exit(proc, 3.0):
2212
2880
  if sys.platform != "win32":
2213
2881
  try:
2214
2882
  os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
@@ -2216,9 +2884,7 @@ class InlineAdapter(BasePlatformAdapter):
2216
2884
  proc.terminate()
2217
2885
  else:
2218
2886
  proc.terminate()
2219
- try:
2220
- proc.wait(timeout=2.0)
2221
- except subprocess.TimeoutExpired:
2887
+ if not await _wait_for_exit(proc, 2.0):
2222
2888
  proc.kill()
2223
2889
  finally:
2224
2890
  self._sidecar_proc = None
@@ -2519,10 +3185,12 @@ class InlineAdapter(BasePlatformAdapter):
2519
3185
  self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
2520
3186
  return
2521
3187
  if kind == "message.action.invoke":
2522
- if await self._handle_action(event):
3188
+ # A widget call names its widget, never a native control, so it skips system handlers.
3189
+ widget_call = inline_widget_call(event) is not None
3190
+ if not widget_call and await self._handle_action(event):
2523
3191
  return
2524
3192
  ownership = resolve_inline_message_action_ownership(event.get("actionId"))
2525
- if ownership.owner == "system" and ownership.explicit:
3193
+ if not widget_call and ownership.owner == "system" and ownership.explicit:
2526
3194
  await self._answer_action(str(event.get("interactionId") or ""), "Action expired")
2527
3195
  logger.info("[inline] dropped unhandled system action %s", event.get("actionId") or "")
2528
3196
  return
@@ -2757,9 +3425,7 @@ class InlineAdapter(BasePlatformAdapter):
2757
3425
  await self.handle_message(MessageEvent(
2758
3426
  text=text or "[Inline message with no text]", message_type=MessageType.TEXT,
2759
3427
  source=source, raw_message=event,
2760
- message_id=build_inline_agent_action_turn_id(
2761
- agent_action.get("messageId"), agent_action.get("interactionId"),
2762
- ) if agent_action else msg_id,
3428
+ message_id=_agent_action_turn_message_id(agent_action, msg_id),
2763
3429
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2764
3430
  allow_gateway_control=not bool(agent_action),
2765
3431
  ))
@@ -2935,20 +3601,17 @@ class InlineAdapter(BasePlatformAdapter):
2935
3601
  # A bot's answer coming back needs no reply: the agent may stay silent instead of Hermes
2936
3602
  # posting its "needed a reply" warning. Hosts before reply_expected ignore this.
2937
3603
  answer_from_bot = sender_is_bot and not explicitly_mentions_me and not command_addressed_to_me
3604
+ if not edit and sender_profile.get("bot") is not True:
3605
+ # A person's message mid-turn steers the work: the progress message ends here and
3606
+ # later work starts a new one. Hermes' steer mode reports nothing itself.
3607
+ self._activity_split(session_chat_id, str(thread_id or ""), msg_id)
2938
3608
  await self.handle_message(MessageEvent(
2939
3609
  **({"reply_expected": False} if answer_from_bot and "reply_expected" in getattr(MessageEvent, "__dataclass_fields__", {}) else {}),
2940
3610
  text=text,
2941
3611
  message_type=message_type,
2942
3612
  source=source,
2943
3613
  raw_message=event,
2944
- message_id=(
2945
- build_inline_agent_action_turn_id(
2946
- agent_action.get("messageId"),
2947
- agent_action.get("interactionId"),
2948
- )
2949
- if agent_action
2950
- else msg_id
2951
- ),
3614
+ message_id=_agent_action_turn_message_id(agent_action, msg_id),
2952
3615
  platform_update_id=int(event.get("seq") or 0) if str(event.get("seq") or "").isdigit() else None,
2953
3616
  media_urls=media_urls,
2954
3617
  media_types=media_types,
@@ -2982,6 +3645,12 @@ class InlineAdapter(BasePlatformAdapter):
2982
3645
  if not target:
2983
3646
  await self._answer_action(interaction_id, "Action expired or no longer accessible.")
2984
3647
  return
3648
+ widget_call = inline_widget_call(event)
3649
+ if widget_call is None and str(event.get("actionId") or "") in inline_message_widget_ids(target):
3650
+ # Older realtime SDKs drop the call's name and payload but keep the widget ID.
3651
+ widget_call = {}
3652
+ if widget_call is not None:
3653
+ event = {**event, "widgetCall": widget_call}
2985
3654
 
2986
3655
  synthetic_message = dict(target)
2987
3656
  for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies", "sender"):
@@ -4432,32 +5101,93 @@ class InlineAdapter(BasePlatformAdapter):
4432
5101
  _, approval_id, choice = parts
4433
5102
  choice = "once" if choice == "approve" else choice
4434
5103
  session_key = self._approval_sessions.get(approval_id)
4435
- if choice not in {"once", "session", "always", "deny"}:
5104
+ if choice not in _APPROVAL_CHOICES:
4436
5105
  return False
4437
5106
  if not session_key:
4438
- await self._finish_action(event, "Approval expired", "Approval expired; the command was not run.")
5107
+ command = self._approval_commands.pop(approval_id, None) or await self._approval_command_from_message(event)
5108
+ await self._finish_action(event, "Approval expired", _approval_card_text(_APPROVAL_EXPIRED_HEADING, command))
4439
5109
  return True
4440
5110
  try:
4441
- from tools.approval import resolve_gateway_approval
4442
- count = resolve_gateway_approval(session_key, choice)
5111
+ request_id = self._approval_request_ids.get(approval_id)
5112
+ actor_id = str(event.get("actorUserId") or "").strip()
5113
+ refusal = self._approval_refusal(session_key, actor_id, request_id)
5114
+ if refusal:
5115
+ # Another member's approval: leave the card live for the requester.
5116
+ await self._answer_action(str(event.get("interactionId") or ""), refusal)
5117
+ return True
5118
+ count = self._resolve_approval(session_key, choice, request_id, actor_id)
4443
5119
  self._approval_sessions.pop(approval_id, None)
5120
+ self._approval_request_ids.pop(approval_id, None)
5121
+ command = self._approval_commands.pop(approval_id, None) or ""
4444
5122
  if not count:
4445
- await self._finish_action(event, "Approval expired", "Approval expired; the command was not run.")
5123
+ await self._finish_action(event, "Approval expired", _approval_card_text(_APPROVAL_EXPIRED_HEADING, command))
4446
5124
  return True
4447
- labels = {
4448
- "once": ("Approved once", "Approved once."),
4449
- "session": ("Approved for session", "Approved for this session."),
4450
- "always": ("Always allowed", "Always allowed."),
4451
- "deny": ("Denied", "Denied."),
5125
+ toasts = {
5126
+ "once": "Approved once",
5127
+ "session": "Approved for session",
5128
+ "always": "Always allowed",
5129
+ "deny": "Denied",
4452
5130
  }
4453
- toast, text = labels[choice]
4454
- await self._finish_action(event, toast, text)
5131
+ # Slack, Discord and OpenClaw's Inline card say who answered; a bare id is noise.
5132
+ answered_by, _, _ = _inline_sender_identity(_inline_sender_profile(event))
5133
+ heading = _APPROVAL_DECISION_HEADINGS[choice] + (f" by {answered_by}" if answered_by else "")
5134
+ await self._finish_action(event, toasts[choice], _approval_card_text(heading, command))
4455
5135
  return True
4456
5136
  except Exception as exc:
4457
5137
  self._report_error("action.approval", exc)
4458
5138
  logger.exception("[inline] approval action failed")
4459
5139
  return True
4460
5140
 
5141
+ async def _approval_command_from_message(self, event: Dict[str, Any]) -> str:
5142
+ """Recover the command from the card itself when this process no longer has it (e.g. after a restart)."""
5143
+ chat_id = str(event.get("chatId") or "")
5144
+ message_id = str(event.get("messageId") or "")
5145
+ msg = await self._fetch_message(chat_id, message_id) if chat_id and message_id else None
5146
+ if not msg:
5147
+ return ""
5148
+ text = str(msg.get("message") or "")
5149
+ units = text.encode("utf-16-le")
5150
+ for entity in self._message_entities(msg):
5151
+ if self._entity_kind(entity) != "pre":
5152
+ continue
5153
+ offset = _to_int(entity.get("offset"))
5154
+ length = _to_int(entity.get("length"))
5155
+ if offset is None or length is None or offset < 0 or length <= 0:
5156
+ continue
5157
+ return units[offset * 2 : (offset + length) * 2].decode("utf-16-le", errors="ignore").strip()
5158
+ match = _APPROVAL_FENCE_PATTERN.search(text)
5159
+ return match.group(2).strip() if match else ""
5160
+
5161
+ def _approval_refusal(self, session_key: str, actor_id: str, request_id: Optional[str]) -> Optional[str]:
5162
+ """Hermes' approver rule (``approvals.approvers``) for a tap, or None when it may answer.
5163
+ Hosts without the rule leave it to the chat access check every action already passed."""
5164
+ try:
5165
+ from gateway.approval_actor import approval_refusal
5166
+ except ImportError:
5167
+ return None
5168
+ requester = approval_refusal(session_key, actor_id, extra=self.config.extra, request_id=request_id)
5169
+ if not requester:
5170
+ return None
5171
+ try:
5172
+ from gateway.approval_actor import refusal_text
5173
+ return str(refusal_text(requester))
5174
+ except ImportError:
5175
+ return f"Only {requester} can answer this approval."
5176
+
5177
+ def _resolve_approval(self, session_key: str, choice: str, request_id: Optional[str], actor_id: str) -> int:
5178
+ """Resolve only the request the card was sent for; once it settled (timeout, /approve)
5179
+ the tap resolves nothing rather than the next one queued. A card without a request id
5180
+ (hosts before 0.22.7) keeps the oldest-pending behaviour."""
5181
+ from tools import approval as approval_mod
5182
+ resolve_card = getattr(approval_mod, "resolve_gateway_approval_card", None)
5183
+ if resolve_card is not None:
5184
+ if _accepts_keyword(resolve_card, "actor_id"):
5185
+ return resolve_card(session_key, choice, request_id, actor_id=actor_id or None, extra=self.config.extra)
5186
+ return resolve_card(session_key, choice, request_id)
5187
+ if request_id:
5188
+ return approval_mod.resolve_gateway_approval(session_key, choice, request_id=request_id)
5189
+ return approval_mod.resolve_gateway_approval(session_key, choice)
5190
+
4461
5191
  async def _handle_slash_action(self, event: Dict[str, Any]) -> bool:
4462
5192
  action_id = str(event.get("actionId") or "")
4463
5193
  parts = action_id.split(":", 2)
@@ -4851,6 +5581,7 @@ class InlineAdapter(BasePlatformAdapter):
4851
5581
  logger.warning("[inline] expensive model check failed", exc_info=True)
4852
5582
  return None
4853
5583
 
5584
+ @_activity_local_sends
4854
5585
  async def _edit_action_message(self, event: Dict[str, Any], text: str, actions: Optional[Dict[str, Any]] = None) -> SendResult:
4855
5586
  chat_id = str(event.get("chatId") or "")
4856
5587
  message_id = str(event.get("messageId") or "")
@@ -4890,8 +5621,49 @@ class InlineAdapter(BasePlatformAdapter):
4890
5621
  metadata: Optional[Dict[str, Any]] = None,
4891
5622
  actions: Optional[Dict[str, Any]] = None,
4892
5623
  ) -> SendResult:
5624
+ """Hermes' send. Within a turn, the first send not marked ``_interim_send`` is the
5625
+ answer: Detailed progress closes ("Worked for") above it first. A Detailed turn's
5626
+ interim messages (Hermes' commentary between tool calls) go into the progress message
5627
+ instead of the chat. ``_interim_send`` and every other metadata key stay off the wire."""
5628
+ turn = None if _ACTIVITY_LOCAL_SEND.get() else self._activity_turn_for_send(chat_id, metadata)
5629
+ if turn is not None:
5630
+ if (metadata or {}).get("_interim_send"):
5631
+ if self._activity_foldable(content, metadata, actions) and await self._activity_is_detailed(turn):
5632
+ if _is_hermes_heartbeat(content):
5633
+ # The progress message's live timer already says this.
5634
+ return _send_result(
5635
+ success=False,
5636
+ error="the Inline progress message shows the elapsed time",
5637
+ raw_response={"inlineActivity": "heartbeat"},
5638
+ retryable=False,
5639
+ )
5640
+ turn.pump.put("commentary", turn, str(content))
5641
+ # Not a chat message: reported undelivered so Hermes never counts it as the
5642
+ # answer (its final-reply dedup matches delivered interim text) and the answer
5643
+ # still posts. Nothing is retried; the text shows in the progress message.
5644
+ return _send_result(
5645
+ success=False,
5646
+ error="shown in the Inline progress message",
5647
+ raw_response={"inlineActivity": "commentary"},
5648
+ retryable=False,
5649
+ )
5650
+ elif not (reply_to and str(reply_to) in turn.later_messages):
5651
+ await self._activity_close_before_answer(turn, content)
5652
+ return await self._send_message(chat_id, content, reply_to=reply_to, metadata=metadata, actions=actions)
5653
+
5654
+ async def _send_message(
5655
+ self,
5656
+ chat_id: str,
5657
+ content: str,
5658
+ reply_to: Optional[str] = None,
5659
+ metadata: Optional[Dict[str, Any]] = None,
5660
+ actions: Optional[Dict[str, Any]] = None,
5661
+ ) -> SendResult:
5662
+ requested = content
5663
+ content = self._with_cron_mention(content, metadata)
4893
5664
  metadata = self._with_turn_thread(chat_id, reply_to, metadata)
4894
5665
  target = self._target_for(chat_id, metadata)
5666
+ send_options = {"sendMode": "silent"} if (metadata or {}).get("notify") is False else {}
4895
5667
  agent_action_target = parse_inline_agent_action_reply_target(reply_to)
4896
5668
  if agent_action_target:
4897
5669
  chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
@@ -4916,10 +5688,13 @@ class InlineAdapter(BasePlatformAdapter):
4916
5688
  "text": chunk,
4917
5689
  "parseMarkdown": self._parse_markdown,
4918
5690
  "replyToMsgId": previous_id,
5691
+ **send_options,
4919
5692
  })
4920
5693
  raw_responses.append(continuation.raw_response)
4921
5694
  if not continuation.success:
4922
- return continuation
5695
+ return self._partial_send_failure(
5696
+ chat_id, requested, continuation, previous_id, continuation_ids, len(chunks), raw_responses,
5697
+ )
4923
5698
  if continuation.message_id:
4924
5699
  previous_id = str(continuation.message_id)
4925
5700
  continuation_ids.append(previous_id)
@@ -4945,6 +5720,7 @@ class InlineAdapter(BasePlatformAdapter):
4945
5720
  "target": target,
4946
5721
  "text": chunk,
4947
5722
  "parseMarkdown": self._parse_markdown,
5723
+ **send_options,
4948
5724
  }
4949
5725
  if reply_to and index == 0:
4950
5726
  body["replyToMsgId"] = str(reply_to)
@@ -4952,7 +5728,12 @@ class InlineAdapter(BasePlatformAdapter):
4952
5728
  body["actions"] = actions
4953
5729
  last_result = await self._send_sidecar("/send", body)
4954
5730
  if not last_result.success:
4955
- return last_result
5731
+ if not message_ids:
5732
+ return last_result
5733
+ return self._partial_send_failure(
5734
+ chat_id, requested, last_result, message_ids[-1], message_ids[:-1], len(chunks),
5735
+ [*raw_responses, getattr(last_result, "raw_response", None)],
5736
+ )
4956
5737
  if not marked_visible:
4957
5738
  self._mark_reply_thread_visible(target)
4958
5739
  marked_visible = True
@@ -5014,7 +5795,13 @@ class InlineAdapter(BasePlatformAdapter):
5014
5795
  metadata: Optional[Dict[str, Any]] = None,
5015
5796
  ) -> SendResult:
5016
5797
  text = self.format_message(content)
5017
- if len(text) > self.MAX_MESSAGE_LENGTH:
5798
+ several_widgets = False
5799
+ if self._parse_markdown and has_widget_markup(text):
5800
+ if not finalize:
5801
+ # A streaming preview must not send a widget whose closing tag hasn't arrived.
5802
+ text = streaming_preview(text)
5803
+ several_widgets = len(scan_widgets(text).widgets) > 1
5804
+ if len(text) > self.MAX_MESSAGE_LENGTH or several_widgets:
5018
5805
  if finalize:
5019
5806
  return await self._edit_overflow_split(chat_id, message_id, content, metadata=metadata)
5020
5807
  text = self.truncate_message(text, self.MAX_MESSAGE_LENGTH)[0]
@@ -5174,6 +5961,7 @@ class InlineAdapter(BasePlatformAdapter):
5174
5961
 
5175
5962
  async def on_processing_start(self, event: MessageEvent) -> None:
5176
5963
  """Mark an inbound message while Hermes is actively processing it."""
5964
+ self._activity_begin(event)
5177
5965
  reaction_target = self._processing_reaction_target(event)
5178
5966
  if not reaction_target:
5179
5967
  return
@@ -5191,6 +5979,7 @@ class InlineAdapter(BasePlatformAdapter):
5191
5979
 
5192
5980
  async def on_processing_complete(self, event: MessageEvent, outcome: Any) -> None:
5193
5981
  """Replace the processing marker without affecting response delivery."""
5982
+ self._activity_finish(event, outcome)
5194
5983
  reaction_target = self._processing_reaction_target(event)
5195
5984
  if not reaction_target:
5196
5985
  return
@@ -5205,6 +5994,140 @@ class InlineAdapter(BasePlatformAdapter):
5205
5994
  elif outcome_name == "failure":
5206
5995
  await self._set_processing_reaction(target, message_id, "❌")
5207
5996
 
5997
+ # ── Detailed progress ───────────────────────────────────────────────────────────────
5998
+
5999
+ def _activity_post(self, path: str, body: Dict[str, Any], timeout: float) -> Dict[str, Any]:
6000
+ """The activity worker's own blocking sidecar call (it runs on its own thread)."""
6001
+ request = urllib.request.Request(
6002
+ f"{self._sidecar_base_url()}{path}",
6003
+ data=json.dumps(body).encode("utf-8"),
6004
+ headers={"content-type": "application/json; charset=utf-8", "x-hermes-sidecar-token": self._sidecar_token},
6005
+ method="POST",
6006
+ )
6007
+ try:
6008
+ # Loopback IPC: never through a configured HTTP proxy.
6009
+ with self._activity_opener.open(request, timeout=timeout) as response:
6010
+ raw = response.read().decode("utf-8")
6011
+ status = response.status
6012
+ except urllib.error.HTTPError as exc:
6013
+ raw = exc.read().decode("utf-8", errors="replace")
6014
+ status = exc.code
6015
+ data = json.loads(raw or "{}")
6016
+ if status != 200 or not data.get("ok"):
6017
+ raise InlineSidecarError(path, status, str(data.get("error") or f"Inline sidecar {path} failed"), str(data.get("errorKind") or "unknown"), data)
6018
+ return data
6019
+
6020
+ def _activity_begin(self, event: Any) -> None:
6021
+ """Opens Detailed progress for a turn; the sidecar resolves the chat's mode."""
6022
+ try:
6023
+ if self._http_client is None:
6024
+ return # Not connected (or a one-shot sender): no turns to draw.
6025
+ source = getattr(event, "source", None)
6026
+ chat_id = str(getattr(source, "chat_id", None) or "").strip()
6027
+ if not chat_id:
6028
+ return
6029
+ thread_id = str(getattr(source, "thread_id", None) or "").strip()
6030
+ if _activity_session_env_reader() is None:
6031
+ return
6032
+ if self._activity_pump is None:
6033
+ self._activity_pump = _ActivityPump(self._activity_post)
6034
+ turn = _ActivityTurn(
6035
+ pump=self._activity_pump,
6036
+ turn_id=secrets.token_hex(8),
6037
+ target=self._target_for(chat_id, {"thread_id": thread_id} if thread_id else None),
6038
+ key=(chat_id, thread_id),
6039
+ user_id=str(getattr(source, "user_id", None) or ""),
6040
+ home=self._activity_home,
6041
+ )
6042
+ is_command = getattr(event, "is_command", None)
6043
+ # A slash command usually answers without working: show "Working" only once work starts.
6044
+ lazy = bool(is_command()) if callable(is_command) else False
6045
+ self._remember(self._activity_events, id(event), (event, turn))
6046
+ _activity_register(turn)
6047
+ turn.pump.put("start", turn, {"lazy": lazy})
6048
+ except Exception as exc:
6049
+ _activity_log_failure("turn start", exc)
6050
+
6051
+ def _activity_finish(self, event: Any, outcome: Any) -> None:
6052
+ """The fallback close for a turn that never sent an answer (empty, failed, stopped)."""
6053
+ try:
6054
+ entry = self._activity_events.pop(id(event), None)
6055
+ if entry is None or entry[0] is not event:
6056
+ return
6057
+ turn = entry[1]
6058
+ if turn.closed:
6059
+ return
6060
+ name = str(getattr(outcome, "value", outcome) or "").strip().lower()
6061
+ self._activity_close(turn, {"outcome": name if name in {"success", "failure", "cancelled"} else "success"})
6062
+ except Exception as exc:
6063
+ _activity_log_failure("turn end", exc)
6064
+
6065
+ def _activity_close(self, turn: _ActivityTurn, payload: Dict[str, Any]) -> None:
6066
+ turn.closed = True
6067
+ _activity_unregister(turn)
6068
+ turn.pump.put("end", turn, payload)
6069
+
6070
+ async def _activity_close_before_answer(self, turn: _ActivityTurn, final_text: Optional[str]) -> None:
6071
+ """Closes the progress message and waits (bounded) until it reached Inline."""
6072
+ if turn.closed:
6073
+ return
6074
+ try:
6075
+ self._activity_close(turn, {"outcome": "success", "final": final_text})
6076
+ await asyncio.wait_for(asyncio.wrap_future(turn.pump.flush(turn)), timeout=_ACTIVITY_FINAL_WAIT_SECONDS)
6077
+ except Exception as exc:
6078
+ _activity_log_failure("answer ordering", exc)
6079
+
6080
+ def _activity_turn_for_send(self, chat_id: Any, metadata: Optional[Dict[str, Any]]) -> Optional[_ActivityTurn]:
6081
+ """This adapter's open turn for a Hermes send, matched as Hermes addresses the turn."""
6082
+ if self._activity_pump is None:
6083
+ return None
6084
+ thread_id = str((metadata or {}).get("thread_id") or "").strip()
6085
+ turns = [
6086
+ turn for turn in _activity_turns_for((str(chat_id or "").strip(), thread_id))
6087
+ if turn.pump is self._activity_pump and not turn.closed
6088
+ ]
6089
+ return turns[-1] if turns else None
6090
+
6091
+ @staticmethod
6092
+ def _activity_foldable(content: Any, metadata: Optional[Dict[str, Any]], actions: Optional[Dict[str, Any]]) -> bool:
6093
+ meta = metadata or {}
6094
+ if meta.get("non_conversational") or meta.get("is_approval_prompt") or actions is not None:
6095
+ return False
6096
+ text = content if isinstance(content, str) else ""
6097
+ return bool(text.strip()) and not has_widget_markup(text)
6098
+
6099
+ async def _activity_is_detailed(self, turn: _ActivityTurn) -> bool:
6100
+ if not turn.mode_ready.is_set():
6101
+ await asyncio.get_running_loop().run_in_executor(None, turn.mode_ready.wait, _ACTIVITY_MODE_WAIT_SECONDS)
6102
+ return turn.mode == "detailed"
6103
+
6104
+ def _activity_split(self, chat_id: str, thread_id: str, message_id: str) -> None:
6105
+ try:
6106
+ if self._activity_pump is None:
6107
+ return
6108
+ for turn in _activity_turns_for((str(chat_id or ""), thread_id)):
6109
+ if turn.pump is self._activity_pump and not turn.closed:
6110
+ if message_id and len(turn.later_messages) < 64:
6111
+ turn.later_messages.add(str(message_id))
6112
+ if turn.mode != "friendly":
6113
+ turn.pump.put("split", turn)
6114
+ except Exception as exc:
6115
+ _activity_log_failure("split", exc)
6116
+
6117
+ def _activity_stop(self) -> None:
6118
+ pump, self._activity_pump = self._activity_pump, None
6119
+ if pump is None:
6120
+ return
6121
+ with _ACTIVITY_LOCK:
6122
+ for key in list(_ACTIVITY_TURNS):
6123
+ kept = [turn for turn in _ACTIVITY_TURNS[key] if turn.pump is not pump]
6124
+ if kept:
6125
+ _ACTIVITY_TURNS[key] = kept
6126
+ else:
6127
+ _ACTIVITY_TURNS.pop(key, None)
6128
+ self._activity_events.clear()
6129
+ pump.stop()
6130
+
5208
6131
  async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
5209
6132
  return await self._send_attachment(chat_id, image_path, "photo", caption, reply_to, metadata=metadata)
5210
6133
 
@@ -5232,6 +6155,10 @@ class InlineAdapter(BasePlatformAdapter):
5232
6155
  return await self._send_attachment(chat_id, audio_path, "voice", caption, reply_to, metadata=metadata)
5233
6156
 
5234
6157
  async def _send_attachment(self, chat_id: str, file_path: str, kind: str, caption: Optional[str], reply_to: Optional[str], file_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
6158
+ turn = None if _ACTIVITY_LOCAL_SEND.get() else self._activity_turn_for_send(chat_id, metadata)
6159
+ if turn is not None and not (metadata or {}).get("_interim_send"):
6160
+ # An answer that is only media (or TTS) also lands below "Worked for".
6161
+ await self._activity_close_before_answer(turn, None)
5235
6162
  safe_path = self.validate_media_delivery_path(file_path)
5236
6163
  if not safe_path:
5237
6164
  return _send_result(success=False, error=f"unsafe or missing attachment path: {file_path}", error_kind="bad_format")
@@ -5281,7 +6208,12 @@ class InlineAdapter(BasePlatformAdapter):
5281
6208
  if not choices:
5282
6209
  from tools.clarify_gateway import mark_awaiting_text
5283
6210
  mark_awaiting_text(clarify_id)
5284
- return await self.send(chat_id, f"Clarify: {question}", metadata=metadata)
6211
+ # A question, not the answer: the turn's progress stays open below it.
6212
+ token = _ACTIVITY_LOCAL_SEND.set(True)
6213
+ try:
6214
+ return await self.send(chat_id, f"Clarify: {question}", metadata=metadata)
6215
+ finally:
6216
+ _ACTIVITY_LOCAL_SEND.reset(token)
5285
6217
  clean_choices = [str(c).strip() for c in choices if str(c).strip()][:10]
5286
6218
  self._remember(self._clarify_sessions, clarify_id, session_key)
5287
6219
  self._remember(self._clarify_choices, clarify_id, clean_choices)
@@ -5298,35 +6230,31 @@ class InlineAdapter(BasePlatformAdapter):
5298
6230
  "actions": {"rows": self._action_rows(actions)},
5299
6231
  })
5300
6232
 
5301
- async def send_exec_approval(
5302
- self,
5303
- chat_id: str,
5304
- command: str,
5305
- session_key: str,
5306
- description: str = "dangerous command",
5307
- metadata: Optional[Dict[str, Any]] = None,
5308
- allow_permanent: bool = True,
5309
- allow_session: bool = True,
5310
- smart_denied: bool = False,
5311
- ) -> SendResult:
6233
+ async def _send_exec_approval_prompt(self, prompt: Any) -> SendResult:
6234
+ """Draw Hermes' exec-approval prompt as an Inline button card.
6235
+
6236
+ Hermes' ``send_exec_approval`` owns the wording, a truncated command's full send and
6237
+ the Deny-only gate of newer hosts; overriding this hook is also how Hermes knows the
6238
+ adapter has native buttons. Inline only draws the offered choices and remembers which
6239
+ request the card answers, so a tap never resolves a different one."""
5312
6240
  approval_id = secrets.token_hex(6)
5313
- text = f"Command approval required\n\n```\n{command[:2000]}\n```\n\nReason: {description}"
5314
- if smart_denied:
5315
- text += "\n\nSmart DENY: owner override applies to this one operation only."
5316
- actions = [self._action(f"appr:{approval_id}:once", "Allow Once")]
5317
- if not smart_denied and allow_session:
5318
- actions.append(self._action(f"appr:{approval_id}:session", "Allow for Session"))
5319
- if allow_permanent:
5320
- actions.append(self._action(f"appr:{approval_id}:always", "Always Allow"))
5321
- actions.append(self._action(f"appr:{approval_id}:deny", "Deny"))
6241
+ actions = [
6242
+ self._action(f"appr:{approval_id}:{choice}", str(label))
6243
+ for label, choice, _style in prompt.actions
6244
+ if choice in _APPROVAL_CHOICES
6245
+ ]
5322
6246
  result = await self._send_sidecar("/send", {
5323
- "target": self._target_for(chat_id, metadata),
5324
- "text": text,
6247
+ "target": self._target_for(prompt.chat_id, prompt.metadata),
6248
+ "text": prompt.text,
5325
6249
  "parseMarkdown": self._parse_markdown,
5326
6250
  "actions": {"rows": self._action_rows(actions)},
5327
6251
  })
5328
6252
  if result.success:
5329
- self._remember(self._approval_sessions, approval_id, session_key)
6253
+ self._remember(self._approval_sessions, approval_id, prompt.session_key)
6254
+ self._remember(self._approval_commands, approval_id, str(getattr(prompt, "command", "") or ""))
6255
+ request_id = getattr(prompt, "request_id", None)
6256
+ if request_id:
6257
+ self._remember(self._approval_request_ids, approval_id, str(request_id))
5330
6258
  return result
5331
6259
 
5332
6260
  async def send_slash_confirm(self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
@@ -5642,6 +6570,81 @@ class InlineAdapter(BasePlatformAdapter):
5642
6570
  out["parent_message_id"] = parent_message_id
5643
6571
  return out
5644
6572
 
6573
+ def _with_cron_mention(self, content: str, metadata: Optional[Dict[str, Any]]) -> str:
6574
+ # Hermes identifies scheduled text sends with job_id. notify also appears
6575
+ # on ordinary replies, so it cannot identify a cron on its own. Decorate
6576
+ # before chunking to mention once, while preserving the supplied body.
6577
+ meta = metadata or {}
6578
+ prefix = self._cron_mention_prefix
6579
+ if not prefix or not self._parse_markdown or not meta.get("job_id") or meta.get("notify") is False:
6580
+ return content
6581
+ if not content or not content.strip() or content.startswith(prefix):
6582
+ return content
6583
+ return f"{prefix}\n\n{content}"
6584
+
6585
+ def truncate_message(
6586
+ self,
6587
+ content: str,
6588
+ max_length: int = _MAX_MESSAGE_LENGTH,
6589
+ len_fn: Optional[Callable[[str], int]] = None,
6590
+ ) -> List[str]:
6591
+ """Hermes splitting, except a widget block is never cut or shares a message with another."""
6592
+ if getattr(self, "_parse_markdown", True):
6593
+ def split_plain(text: str, limit: int) -> List[str]:
6594
+ pieces = self._base_truncate(text, limit, len_fn)
6595
+ # Upstream numbers multi-part output; the parts here are pieces of one reply.
6596
+ return [piece.removesuffix(f" ({i + 1}/{len(pieces)})") for i, piece in enumerate(pieces)]
6597
+
6598
+ chunks = split_widget_aware(content, max_length, split_plain, len_fn or len)
6599
+ if chunks is not None:
6600
+ return chunks
6601
+ return self._base_truncate(content, max_length, len_fn)
6602
+
6603
+ def _base_truncate(self, content: str, max_length: int, len_fn: Optional[Callable[[str], int]]) -> List[str]:
6604
+ base = super().truncate_message
6605
+ if len_fn is not None and _accepts_keyword(base, "len_fn"):
6606
+ return list(base(content, max_length, len_fn=len_fn))
6607
+ return list(base(content, max_length))
6608
+
6609
+ def _partial_send_failure(
6610
+ self, chat_id: str, requested: str, failed: SendResult, last_delivered_id: str,
6611
+ delivered_continuations: List[str], total_chunks: int, raw_responses: List[Any],
6612
+ ) -> SendResult:
6613
+ """A later chunk failed after earlier ones were delivered. ``partial_overflow`` tells Hermes
6614
+ (0.22.7+) not to resend the visible head as plain text; older hosts are stopped in
6615
+ ``_send_plain_fallback``. Either way the reply is not posted twice."""
6616
+ if getattr(BasePlatformAdapter, "_is_partial_delivery", None) is None:
6617
+ self._remember(self._partial_sends, (self._chat_key(chat_id), requested), True, limit=64)
6618
+ return _send_result(
6619
+ success=False,
6620
+ message_id=last_delivered_id,
6621
+ error=failed.error or "continuation failed",
6622
+ error_kind=getattr(failed, "error_kind", None),
6623
+ retryable=bool(getattr(failed, "retryable", False)),
6624
+ raw_response={
6625
+ "partial_overflow": True,
6626
+ "delivered_chunks": 1 + len(delivered_continuations),
6627
+ "total_chunks": total_chunks,
6628
+ "last_message_id": last_delivered_id,
6629
+ "continuation_message_ids": tuple(delivered_continuations),
6630
+ "responses": raw_responses,
6631
+ },
6632
+ continuation_message_ids=tuple(delivered_continuations),
6633
+ )
6634
+
6635
+ async def _send_plain_fallback(self, chat_id: str, content: str, *, reply_to: Optional[str], metadata: Any) -> SendResult:
6636
+ if self._partial_sends.pop((self._chat_key(chat_id), content), None):
6637
+ # Part of this reply is already in the chat: resending it would post that part twice.
6638
+ logger.warning("[inline] send failed after partial delivery; not resending it as plain text")
6639
+ return _send_result(success=False, error="send failed after partial delivery", error_kind="bad_format")
6640
+ # A widget the server rejected would fail the fallback too; keep the prose and say so.
6641
+ if getattr(self, "_parse_markdown", True) and has_widget_markup(content):
6642
+ content = without_widgets(content)
6643
+ base = getattr(super(), "_send_plain_fallback", None)
6644
+ if base is None:
6645
+ return await self.send(chat_id, content, reply_to=reply_to, metadata=metadata)
6646
+ return await base(chat_id, content, reply_to=reply_to, metadata=metadata)
6647
+
5645
6648
  def format_message(self, content: str) -> str:
5646
6649
  # parseMarkdown=false means literal input at every Inline API boundary.
5647
6650
  # Preserve that contract instead of silently rewriting the caller's text.
@@ -5679,8 +6682,9 @@ class InlineAdapter(BasePlatformAdapter):
5679
6682
  def _send_retry_is_final(self, result: SendResult) -> bool:
5680
6683
  # The request reached Inline but its result was lost. A retry or plain-text
5681
6684
  # fallback would post the message twice; Hermes treats timeouts the same way.
6685
+ # Commentary folded into a progress message is shown already: never resend it.
5682
6686
  raw = getattr(result, "raw_response", None)
5683
- return isinstance(raw, dict) and raw.get("outcomeUnknown") is True
6687
+ return isinstance(raw, dict) and (raw.get("outcomeUnknown") is True or "inlineActivity" in raw)
5684
6688
 
5685
6689
  async def _send_sidecar(self, path: str, body: Dict[str, Any]) -> SendResult:
5686
6690
  try:
@@ -5731,6 +6735,13 @@ class InlineAdapter(BasePlatformAdapter):
5731
6735
  return _sidecar_base_url(self._sidecar_bind, self._sidecar_port)
5732
6736
 
5733
6737
 
6738
+ def _agent_action_turn_message_id(agent_action: Optional[Dict[str, Any]], msg_id: str) -> str:
6739
+ """A button turn's answer replaces its card; a widget call's answer replies below the widget."""
6740
+ if not agent_action or inline_widget_call(agent_action) is not None:
6741
+ return msg_id
6742
+ return build_inline_agent_action_turn_id(agent_action.get("messageId"), agent_action.get("interactionId"))
6743
+
6744
+
5734
6745
  def _send_result(**kwargs: Any) -> SendResult:
5735
6746
  fields = getattr(SendResult, "__dataclass_fields__", {})
5736
6747
  for optional in ("error_kind", "retry_after"):
@@ -6000,3 +7011,4 @@ def register(ctx) -> None:
6000
7011
  handler_fn=_cli.dispatch,
6001
7012
  )
6002
7013
  _tools.register(ctx)
7014
+ _register_activity_hooks(ctx)