Skip to content

feat(gateway): add LINE Messaging API platform plugin - #23197

Merged
teknium1 merged 2 commits into
mainfrom
hermes/hermes-4a25519d
May 10, 2026
Merged

feat(gateway): add LINE Messaging API platform plugin#23197
teknium1 merged 2 commits into
mainfrom
hermes/hermes-4a25519d

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Adds LINE as a bundled platform plugin under plugins/platforms/line/ — synthesized from the strongest pieces of seven open community PRs, requiring zero core edits (the bundled-plugin scan in gateway/config.py auto-discovers Platform("line")).

Closes #6081 and supersedes seven open community PRs by merging the best of each into one plugin-form implementation.

Synthesis credits

PR Author Best piece pulled in
#18153 @leepoweii Template Buttons postback cache state machine, Markdown URL preservation, system-message cache bypass, three-allowlist gating
#8398 @yuga-hashimoto Media URL serving with allowed-roots traversal guard, send_voice / send_video, LINE_PUBLIC_URL env, macOS /tmp/private/tmp root
#16832 @jethac Config wiring style, voice/image test patterns
#21023 @perng Plugin-form skeleton (only one already on the ADDING_A_PLATFORM path), reply→push fallback at 50s TTL, loading-animation indicator, source dispatcher, dedup
#14942 @soichiyo Cloudflare-tunnel operating model (docs only)
#14988 @David-0x221Eight Text-first scope discipline
#6676 @liyoungc Push-only mode (used as the LINE_SLOW_RESPONSE_THRESHOLD=0 fallback path)

All seven contributors are credited via Co-authored-by: on the synthesis commit and AUTHOR_MAP'd in scripts/release.py.

Changes

File What
plugins/platforms/line/adapter.py LineAdapter(BasePlatformAdapter) — webhook server, signature verification, reply/push routing, postback cache state machine, media serving, send batching (~1100 LOC)
plugins/platforms/line/plugin.yaml Rich requires_env / optional_env block (irc/teams style)
plugins/platforms/line/__init__.py Plugin entry point
tests/gateway/test_line_plugin.py 73 tests across 11 categories — signature verification, source resolution, allowlist, dedup, RequestCache state machine, Markdown stripping + chunking, send routing (reply→push fallback, system bypass, 5-msg cap), register() metadata, env enablement, standalone send, postback button shape, check_requirements, validate_config, adapter init
scripts/release.py AUTHOR_MAP entries for the six previously-unmapped contributors
website/docs/user-guide/messaging/line.md Full setup guide (LINE console → tunnel → env vars → webhook URL → postback flow → env reference → troubleshooting)
website/docs/user-guide/messaging/index.md LINE row added to platform comparison table + lead paragraph
website/docs/reference/environment-variables.md LINE_* reference table
website/sidebars.ts LINE doc page added to messaging sidebar

No new runtime deps — uses aiohttp (already in [messaging]).

Design highlights

Reply token preferred, Push fallback. LINE's reply token is single-use and expires roughly 60 seconds after the inbound event. Reply is free, Push is metered. The adapter caches the reply token from each inbound message, tries Reply first, and falls back to Push when the token is absent, expired, or rejected.

Slow-LLM Template Buttons postback (the headline novel piece). When the LLM is still running past LINE_SLOW_RESPONSE_THRESHOLD (default 45s), the adapter consumes the original reply token to send a Template Buttons bubble:

🤔 Still thinking. Tap below to fetch the answer when it's ready.

[ Get answer ]

The user taps Get answer — that postback delivers a fresh reply token, which the adapter uses to send the cached answer (still free). State machine: PENDING → READY → DELIVERED, plus ERROR for cancelled runs (the orphan PENDING resolves to LINE_INTERRUPTED_TEXT after /stop so the persistent button doesn't loop). Set threshold to 0 to disable.

Template Buttons over Quick Reply chips because Quick Reply chips are dismissed by the LINE client the moment any new message arrives — Template Buttons stay tappable from chat history. Lifted directly from #18153.

Three-allowlist gating — separate LINE_ALLOWED_USERS (U-prefixed), LINE_ALLOWED_GROUPS (C-prefixed), LINE_ALLOWED_ROOMS (R-prefixed) lists with LINE_ALLOW_ALL_USERS=true dev-only escape hatch.

Media via public HTTPS URLs. LINE's Messaging API does not accept binary uploads. The adapter serves registered tempfiles from the same aiohttp app at /line/media/<token>/<filename>, with a defence-in-depth allowed-roots traversal guard (tempfile.gettempdir(), /tmp/private/tmp on macOS, HERMES_HOME). LINE_PUBLIC_URL overrides URL construction so the URLs are reachable when bind is 0.0.0.0 or behind a reverse proxy.

Webhook hardening. 1 MiB body cap, constant-time HMAC-SHA256 signature verification, webhookEventId LRU dedup, scoped lock prevents two profiles from binding the same channel.

Validation

Result
scripts/run_tests.sh tests/gateway/test_line_plugin.py 73 passed in 1.05s
Same + test_irc_adapter.py + test_plugin_platform_interface.py + test_platform_registry.py + test_config.py 193 passed, 7 skipped in 2.14s — no regressions
E2E sanity (real imports) Platform("line") discovered ✓ · LineAdapter instantiates ✓ · register() wires all hooks ✓ · signature roundtrip ✓ · postback cache state transitions ✓ · _standalone_send errors gracefully without creds ✓

Setup (one-line)

~/.hermes/.env:

LINE_CHANNEL_ACCESS_TOKEN=...
LINE_CHANNEL_SECRET=...
LINE_ALLOWED_USERS=U1234567890abcdef...
LINE_PUBLIC_URL=https://my-tunnel.example.com

~/.hermes/config.yaml:

gateway:
  platforms:
    line:
      enabled: true

Then hermes gateway. Webhook listens on :8646/line/webhook. Full setup walkthrough in website/docs/user-guide/messaging/line.md.

Notes

  • Plugin-form deliberately, per ADDING_A_PLATFORM.md and the IRC/Teams precedent — no edits to gateway/run.py, gateway/config.py, tools/send_message_tool.py, cron/scheduler.py, agent/prompt_builder.py, etc.
  • All seven original PRs should be closed with a comment pointing here and crediting their commits.
  • Native message editing is not supported (LINE has no edit-message API). Streaming responses always send fresh bubbles. This is correct LINE behavior, not a missing feature.

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR #21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR #18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR #18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR #18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR #18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR #8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR #21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR #21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR #21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (#18153, #16832, #6676, #21023, #14942,
#14988, #8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
@github-actions

github-actions Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-4a25519d vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 7998 on HEAD, 7971 on base (🆕 +27)

🆕 New issues (11):

Rule Count
unresolved-import 4
unresolved-attribute 3
invalid-method-override 3
not-subscriptable 1
First entries
plugins/platforms/line/adapter.py:962: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_message_event` has no attribute `create_source`
plugins/platforms/line/adapter.py:1578: [unresolved-import] unresolved-import: Module `hermes_cli.config` has no member `get_env_var`
plugins/platforms/line/adapter.py:1578: [unresolved-import] unresolved-import: Module `hermes_cli.config` has no member `set_env_var`
plugins/platforms/line/adapter.py:1306: [invalid-method-override] invalid-method-override: Invalid override of method `send_image_file`: Definition is incompatible with `BasePlatformAdapter.send_image_file`
tests/gateway/test_line_plugin.py:467: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method
plugins/platforms/line/adapter.py:972: [unresolved-attribute] unresolved-attribute: Class `MessageType` has no attribute `IMAGE`
plugins/platforms/line/adapter.py:1473: [unresolved-import] unresolved-import: Cannot resolve imported module `aiohttp`
tests/gateway/test_line_plugin.py:25: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/platforms/line/adapter.py:1336: [invalid-method-override] invalid-method-override: Invalid override of method `send_voice`: Definition is incompatible with `BasePlatformAdapter.send_voice`
plugins/platforms/line/adapter.py:1195: [unresolved-attribute] unresolved-attribute: Attribute `reply` is not defined on `None` in union `_LineClient | None`
plugins/platforms/line/adapter.py:1360: [invalid-method-override] invalid-method-override: Invalid override of method `send_video`: Definition is incompatible with `BasePlatformAdapter.send_video`

✅ Fixed issues: none

Unchanged: 4210 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

Comment thread tests/gateway/test_line_plugin.py Dismissed
Comment thread tests/gateway/test_line_plugin.py Dismissed
Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR #23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.
@teknium1
teknium1 merged commit 50f9fee into main May 10, 2026
4 checks passed
@teknium1
teknium1 deleted the hermes/hermes-4a25519d branch May 10, 2026 13:40
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins labels May 10, 2026
JinyuID pushed a commit to JinyuID/hermes-agent that referenced this pull request May 11, 2026
…3197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR NousResearch#21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR NousResearch#18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR NousResearch#18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR NousResearch#18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR NousResearch#8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942,
NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR NousResearch#23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
dusterbloom pushed a commit to dusterbloom/hermes-agent that referenced this pull request May 12, 2026
…3197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR NousResearch#21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR NousResearch#18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR NousResearch#18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR NousResearch#18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR NousResearch#8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942,
NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR NousResearch#23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
bot-ted added a commit to bot-ted/hermes-agent that referenced this pull request May 13, 2026
* feat(stream-retry): add upstream + timing diagnostics to drop log (#23005)

The previous PR (#22993) gave us a structured WARNING per stream drop
but the only diagnostic was 'error_type=APIError error=Network
connection lost.' — same nothing the user started with. To actually
diagnose why subagents drop streams disproportionately we need to know
WHERE the drop happened.

Adds three breadcrumbs to the agent.log WARNING:

1. Inner exception chain. openai SDK wraps httpx errors as
   APIConnectionError / APIError so the catch site only sees the
   wrapper. _flatten_exception_chain walks __cause__/__context__ up to
   4 levels deep and renders 'Outer(msg) <- Inner(msg)' so we can
   tell ConnectError vs RemoteProtocolError vs ReadError vs
   ProxyError without enabling verbose mode.

2. Upstream HTTP headers. Snapshots cf-ray, x-openrouter-provider,
   x-openrouter-model, x-openrouter-id, x-request-id, server, via,
   etc. from stream.response immediately after open (so they survive
   even when the stream dies before the first chunk). These answer
   'is one CF edge / one downstream provider responsible, or random?'

3. Per-attempt counters. bytes streamed, chunk count, elapsed time on
   the dying attempt, and time-to-first-byte. Distinguishes 'couldn't
   connect at all' (0s, 0 bytes) from 'died after 30s mid-stream'
   (very different root causes — first is auth/routing, second is
   upstream idle-kill or proxy timeout).

Plumbing:

- _stream_diag_init / _stream_diag_capture_response live on AIAgent
  and produce a per-attempt dict held on request_client_holder['diag']
  for closure access from the retry block.
- _call_chat_completions and _call_anthropic both initialize the diag
  and increment counters per chunk/event (best-effort, never raises in
  the streaming hot path).
- _log_stream_retry / _emit_stream_drop accept an optional diag and
  render the new fields. Final-exhaustion log goes through the same
  helper so it gets the same diagnostic dump.
- UI status line gains a brief 'after Xs' suffix when timing is
  available — distinguishes 'connect failed' from 'died mid-stream'
  at a glance without grepping logs.

Sample WARNING after this change:

  Stream drop mid tool-call on attempt 2/3 — retrying.
    subagent_id=sa-2-cafef00d depth=1 provider=openrouter
    base_url=https://openrouter.ai/api/v1
    error_type=APIError error=Connection error.
    chain=APIError(Connection error.) <- RemoteProtocolError(peer
      closed connection without sending complete message body)
    http_status=200 bytes=12400 chunks=47 elapsed=12.00s ttfb=0.83s
    upstream=[cf-ray=8f1a2b3c4d5e6f7g-LAX
      x-openrouter-provider=Anthropic
      x-openrouter-id=gen-abc123 server=cloudflare]

Tests: 10 covering diag init, header capture (whitelist enforced for
PII), exception-chain walking + depth cap, log content with full diag,
log content without diag (placeholders), UI elapsed-suffix on/off.

* fix(review): tell background reviewer not to capture transient env failures as skills (#23004)

Closes #6051.

Reported failure mode: agent migrated to WSL2, browser launch failed
because Playwright wasn't installed yet. Background reviewer captured
the failure as a durable skill (`browser-tool-launch-issue`) and the
agent kept refusing the browser tool for weeks after Playwright was
installed and verified working. Negative claims also propagated into
unrelated skills ("browser tools do not work", "cannot use Y from
execute_code").

Root cause: `_SKILL_REVIEW_PROMPT` and `_COMBINED_REVIEW_PROMPT` both
lean hard on "be active, save things, a pass that does nothing is a
missed learning opportunity." Neither distinguished durable knowledge
from transient environment state. The reviewer was doing what it was
told.

Fix at the write site — both prompts now carry a "Do NOT capture"
section calling out:
  • Environment-dependent failures (missing binaries, fresh-install
    errors, post-migration path mismatches, 'command not found',
    unconfigured credentials, uninstalled packages)
  • Negative claims about tools or features ("X does not work")
    that harden into self-cited refusals
  • Session-specific transient errors that resolved before the
    conversation ended
  • One-off task narratives ("summarize today's market", "analyze
    this PR") — also addresses the #12812 / #4538 family

Plus a positive-reframing line: when a tool fails because of setup
state, capture the FIX (install command, config step, env var)
under an existing setup/troubleshooting skill — never "this tool
doesn't work" as a standalone constraint.

Targeted tests: 24/24 passing in tests/run_agent/test_review_prompt_class_first.py
(2 new + all existing review-prompt assertions). Substring-based
checks so future prompt edits don't false-fail.

* feat(codex): add gpt-5.3-codex-spark model

* fix(model-metadata): restore gpt-5.3-codex-spark fallback context

* fix(model-metadata): set codex-spark fallback context to 128k

* fix: surface Codex CLI-only models

* chore: add codex-spark salvage contributors to AUTHOR_MAP

Maps olegwn@gmail.com → nederev (PR #18286) and vesper@askclaw.dev →
askclaw-vesper (PR #19530) so the contributor attribution check passes
when their commits land via this salvage.

* docs(codex-spark): document ChatGPT Pro entitlement gating

PR #12994 stripped gpt-5.3-codex-spark on the assumption that it was
unsupported. It's actually research-preview, ChatGPT-Pro-only, exposed
via the Codex OAuth backend at chatgpt.com/backend-api/codex/models —
not via the public OpenAI API.

Add explanatory comments in:
  - DEFAULT_CODEX_MODELS / _FORWARD_COMPAT_TEMPLATE_MODELS (codex_models.py)
  - _CODEX_OAUTH_CONTEXT_FALLBACK (model_metadata.py)
  - list_authenticated_providers' live-discovery branch (model_switch.py)

so future maintainers don't strip the entry again. Also documents the
intentional asymmetry that Spark stays out of the "openai" provider
catalog (it isn't on the public API) and why the supported_in_api
filter is *not* applied for the openai-codex route.

* test(codex-spark): add live-API regression and make picker test deterministic

Two follow-ups from self-review:

1. Add unit test for _fetch_models_from_api covering the live HTTP path.
   The salvaged PR #19530 dropped the supported_in_api:false filter in
   both _fetch_models_from_api and _read_cache_models, but only the
   cache path had a regression test. This adds the symmetric live-fetch
   test (mocked httpx) so a future drive-by change to the HTTP path
   can't silently re-introduce the filter.

2. Pin test_codex_picker_uses_live_codex_catalog to the cache fallback.
   The test wrote a fake JWT and a CODEX_HOME cache, but provider_model_ids
   ('openai-codex') still issued a real 10s HTTP probe to
   chatgpt.com/backend-api/codex/models before falling back to the cache.
   That made the test slow and non-deterministic in restricted/CI
   networks. Patch _fetch_models_from_api to return [] so we go straight
   to the cache path the test actually means to exercise.

* fix(codex-spark): defensive 128k entry in DEFAULT_CONTEXT_LENGTHS + clarify validation test docstring

Two follow-ups from self-review:

1. Add gpt-5.3-codex-spark to DEFAULT_CONTEXT_LENGTHS at 128k. The
   primary resolution path for Spark goes through provider='openai-codex'
   → _CODEX_OAUTH_CONTEXT_FALLBACK (already correct). But if any future
   code path resolves Spark's context with a different provider (custom
   proxy, generic fallthrough), the longest-substring-first lookup in
   step 8 would match 'gpt-5' and report 400k, which is wrong by ~3x.
   Adding the explicit override is a cheap defensive correctness fix
   matching how gpt-5.4-mini and gpt-5.4-nano already shadow the generic
   gpt-5 entry.

2. Update test_openai_codex_model_validation_fallback.py docstring. The
   bug it was originally written for (gpt-5.3-codex-spark missing from
   listing) is now resolved by this PR's catalog restoration. The test
   still validly exercises the soft-accept code path for any future
   entitlement-gated Codex slug that ships before Hermes catalogs it,
   but the framing was stale — clarified.

* feat(kanban): add orchestrator board tools

* fix(kanban): parse include_archived explicitly

* fix(kanban): parse triage flag explicitly

* fix(kanban): restrict board routing tools to orchestrators

Adapted from PR #20568 commit ce3518578 (Eric Litovsky / @kallidean).
Adds two-tier gating for the kanban tool surface so dispatcher-spawned
workers see only task-lifecycle tools (show/complete/block/heartbeat/
comment/create/link) while orchestrator profiles with `toolsets: [kanban]`
also see board-routing tools (kanban_list, kanban_unblock).

Workers shouldn't be enumerating or unblocking the board — they should
close their own task via the lifecycle tools. Hiding board-routing tools
from worker schemas keeps the worker focused and the toolset-isolation
contract honest.

Plus inherited from the same upstream commit:
- 50/200 row bound on kanban_list with `truncated` + `next_limit` metadata.
- Belt-and-suspenders runtime guard `_require_orchestrator_tool()` inside
  the orchestrator handlers in case a stale registration ever routes a
  worker to one of them.
- Tests for the new gate, the stricter bound, and the fact that even a
  worker with `toolsets: [kanban]` in config still doesn't see board
  routing.

Co-authored-by: Eric Litovsky <elitovsky@zenproject.net>

* chore: AUTHOR_MAP entry for kallidean (#20568)

* docs(user-stories): add 4 entries from @emmagine79 thread (#23204)

Captain Awesome's May 10 thread on hermes + Discord with GPT-5.5 / DeepSeek v4:
- life-changing umbrella tweet
- Google-me -> SSH-deploy landing page to VPS
- cron jobs triaging tech news into Discord channels by urgency
- PM paperclip agent running morning + evening standups for ADHD

* docs(web-search): explain auxiliary-model summarization for web_extract (#23211)

web_extract runs returned page content through the web_extract auxiliary
model when pages exceed 5 000 chars (single-pass up to 500k, chunked up
to 2M, refused above that). The user-guide page didn't mention this —
users were surprised that long-page extracts produced summaries instead
of raw markdown, and that those summaries cost main-model tokens by
default.

Adds:
- size-driven behavior table (under 5k / 5k–500k / 500k–2M / over 2M)
- which auxiliary task does the work (auxiliary.web_extract)
- how to route summaries to a cheap model regardless of main
- escape hatch: browser_navigate when you need raw content
- troubleshooting entry for summarization timeouts

* feat(gateway): add LINE Messaging API platform plugin (#23197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR #21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR #18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR #18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR #18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR #18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR #8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR #21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR #21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR #21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (#18153, #16832, #6676, #21023, #14942,
#14988, #8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR #23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* feat(curator): hint at `hermes curator pin` in the rename block (#23212)

Surfaces the pin command at the moment users care about it: when a
consolidation just landed against their skill library and they're
looking at the umbrella name in the curator output. Previously `hermes
curator pin` existed but had no discovery surface — users only learned
it existed by reading docs or stumbling onto `hermes curator --help`.

The hint:

    archived 3 skill(s):
      • docx-extraction → document-tools
      • pdf-extraction → document-tools
      • old-stale — pruned (stale)
    full report: hermes curator status
    keep an umbrella stable: hermes curator pin document-tools

Gated on having at least one consolidation that produced an umbrella.
Pruned-only runs (nothing surviving to pin) skip the hint. When
multiple umbrellas were produced, picks alphabetically first as a
concrete example rather than listing them all.

3 new tests in tests/agent/test_curator_classification.py covering:
consolidation produces hint with real umbrella name, pruned-only run
omits it, multi-umbrella picks one example.

* fix(security): require dashboard auth for plugin API routes

Remove the blanket /api/plugins/* exemption from auth_middleware so
plugin API routes (e.g. Kanban dashboard) require the same session
token as all other /api/ endpoints.

Fixes #19533

* test(security): broaden plugin API auth coverage + correct stale docstring

Follow-up to the previous commit's middleware fix.

- plugins/kanban/dashboard/plugin_api.py: rewrite the "Security note"
  docstring. The previous text said "/api/plugins/ is unauthenticated by
  design" — that's now actively wrong and dangerously misleading. New
  text explains that plugin routes flow through the same session-token
  middleware as core API routes and that --host 0.0.0.0 is safe to use
  on a LAN as a result.

- tests/hermes_cli/test_web_server.py: extend TestPluginAPIAuth to cover
  the surfaces the original PR didn't pin:
  * test_plugin_route_allows_auth now exercises a real plugin path
    (/api/plugins/example/hello) instead of accepting 200 OR 404 from
    a maybe-loaded kanban plugin — the assertion was effectively vacuous.
  * test_plugin_patch_requires_auth + test_plugin_delete_requires_auth
    cover non-GET mutation methods in case a future regression
    whitelists them by accident.
  * test_non_kanban_plugin_route_requires_auth proves the fix is
    plugin-agnostic, not kanban-specific (hits hermes-achievements +
    a non-existent plugin namespace; both 401 before route resolution).
  * test_plugin_websocket_unaffected_by_http_middleware locks in that
    the HTTP middleware change didn't accidentally start gating WS
    upgrades — kanban /events still uses its own ?token= check.
  Plus a cosmetic blank-line cleanup.

* feat(plugins): run any LLM call from inside a plugin via ctx.llm (#23194)

* feat(plugins): host-owned LLM access via ctx.llm

Plugins can now ask the host to run a one-shot chat or structured
completion against the user's active model and auth, without ever
seeing an OAuth token or API key. Closes the gap where plugins that
needed bounded structured inference (receipts, CRM extraction,
support classification) had to either bring their own provider keys
or register a tool the agent had to call.

New surface on PluginContext:
- ctx.llm.complete(messages, ...)
- ctx.llm.complete_structured(instructions, input, json_schema, ...)
- async siblings ctx.llm.acomplete / acomplete_structured

Backed by the existing auxiliary_client.call_llm pipeline — every
provider, fallback chain, vision routing, and timeout policy Hermes
already supports applies automatically.

Trust gate (fail-closed by default):
- plugins.entries.<id>.llm.allow_model_override
- plugins.entries.<id>.llm.allowed_models (allowlist; '*' = any)
- plugins.entries.<id>.llm.allow_agent_id_override
- plugins.entries.<id>.llm.allow_profile_override

Embedded model@profile shorthand goes through the same gate as
explicit profile=, so it can't bypass the auth-profile policy.
Conflicting explicit and embedded profiles fail closed.

Also lands:
- plugins/plugin-llm-example/ — reference plugin that registers
  /receipt-extract, demonstrating image+text structured input,
  jsonschema validation, and the trust-gate config.
- website/docs/developer-guide/plugin-llm-access.md — full API docs.
- 45 unit tests covering trust gates, JSON parsing, schema
  validation, image encoding, async surface, and config loading.

Validation:
- 2628 tests pass in tests/agent/
- E2E: bundled plugin loaded with isolated HERMES_HOME, slash
  command produced parsed JSON via stubbed call_llm
- response_format extra_body wired correctly for both json_object
  and json_schema modes

* docs(plugin-llm): rewrite quickstart and framing

The quickstart now uses a meeting-notes-to-tasks example instead of
a receipt extractor, and the page leads with hook-time / gateway
pre-filter / scheduled-job framing rather than the OpenClaw
KB/support/CRM/finance/migration enumeration that the original
upstream PR used. Receipt example moved to a separate worked
example link so the docs page itself doesn't echo any of the
upstream framing.

Also clarifies where ctx.llm fits in the broader plugin surface
(table comparing register_tool / register_platform / register_hook
/ etc.) and what makes this lane different from auxiliary_client
internals.

No code change.

* docs(plugin-llm): reframe as any LLM call, not just structured output

The original draft leaned heavily on complete_structured() and made
the chat lane (complete() / acomplete()) feel like a footnote.
Restructure so:

- The page title and description say 'any LLM call.'
- The lead shows BOTH a plain chat call (error rewriter) AND a
  structured call (triage scorer) up top.
- Quick start has two complete plugin examples — /tldr (chat) and
  /paste-to-tasks (structured).
- New 'When to use which' table for choosing complete() vs
  complete_structured() vs the async siblings.
- Trust-gate sections explicitly note 'all four methods,' and the
  request-shaping list calls out chat-only fields (messages) and
  structured-only fields (instructions, input, json_schema)
  alongside each other.
- The 'Where this fits' section now says 'for any reason,
  structured or not.'

The receipt-extractor reference plugin still exists under
plugins/plugin-llm-example/ — but the docs page no longer treats
it as the canonical surface example. It's now described as 'a third
worked example, this time with image input.'

No code change.

* feat(plugin-llm): split provider/model into independent explicit kwargs

The first cut accepted a single 'provider/model' slug on every method
and split it internally. That looked clean but broke under live test:
the model-override path tried to use the slug's vendor prefix as a
literal Hermes provider id, which silently switched the user off
their aggregator (e.g. plugin asks for 'openai/gpt-4o-mini' on a user
who routes through OpenRouter — host attempted to call the 'openai'
provider directly, failed because OPENAI_API_KEY wasn't set).

New shape mirrors the host's main config:

  ctx.llm.complete(
      messages=[...],
      provider='openrouter',         # gated, optional
      model='openai/gpt-4o-mini',    # gated, optional
      profile='work',                # gated, optional
      ...
  )

Each is independently gated by its own allow_*_override flag.
Granting model-override does NOT auto-grant provider-override.
Allowlists are now per-axis (allowed_providers, allowed_models)
matched literally against whatever string the plugin sends.

Dropped 'model@profile' embedded-suffix shorthand entirely. Hermes
doesn't use that pattern anywhere else; profile= is its own kwarg.

Live E2E (against real OpenRouter via Teknium's config) confirms:
- zero-config call works
- default-deny blocks each override with a helpful error
- model-only override stays on user's active provider (the bug)
- provider+model override switches cleanly
- allowlist refuses non-listed entries
- structured output round-trip parses + schema-validates

Tests: 49 cases (up from 45); all green. Docs updated to match the
new shape, including a 'most plugins never need this section' callout
on the trust-gate config block.

* fix+cleanup(plugin-llm): real attribution, hook-mode coverage, move example out of core

Three integration fixes for the ctx.llm surface:

1. Attribution bug — result.provider and result.model now reflect
   what call_llm actually used, not placeholder fallbacks ('auto',
   'default'). New _resolve_attribution() helper:

     - explicit overrides win (what the call targeted)
     - response.model wins for the recorded model (provider
       canonicalisation: 'gpt-4o' → 'gpt-4o-2024-08-06' etc.)
     - falls back to _read_main_provider() / _read_main_model()
       when no override is set, so audit logs reflect the user's
       active main provider/model
     - 'auto' / 'default' only when EVERYTHING is empty

   Live verified: zero-config call now records
   provider='openrouter', model='anthropic/claude-4.7-opus-20260416'
   instead of provider='auto', model='default'.

2. Hook-mode coverage — TestHookMode confirms ctx.llm.complete
   works from inside a registered post_tool_call callback. The
   docs page promised hook integration; now there's a test that
   exercises the lazy-import path through the real invoke_hook
   machinery. Two cases: traceback-rewrite hook with conditional
   ctx.llm.complete, and minimal hook regression for the
   sync-hook + sync-llm path.

3. Reference plugin moved out of core. plugins/plugin-llm-example/
   is gone from hermes-agent — it now lives in the new
   NousResearch/hermes-example-plugins companion repo. The docs
   page links there. Hermes' bundled plugins should be plugins
   users actually run; reference / docs-companion plugins live
   externally.

Test count: 56 (up from 49). Wider sweep on tests/hermes_cli/
+ tests/gateway/ + tests/tools/ + tests/agent/ shows 16770
passing; the 12 failures are all pre-existing on origin/main
(verified by stashing this branch's changes and re-running) —
kanban-boards, delegate-task, gateway-restart, tts-routing —
none touch the plugin_llm surface.

* chore(plugins): move all example plugins to companion repo

Reference / docs-companion plugins now live exclusively in
NousResearch/hermes-example-plugins, not bundled with the core repo:

- example-dashboard
- strike-freedom-cockpit

A new fourth example, plugin-llm-async-example, was added to that
repo demonstrating ctx.llm's async surface (acomplete()) with
asyncio.gather() — registers /translate <lang>: <text> which fires
forward translation + sentiment classifier in parallel, then a
back-translation for QA. Live-tested at 2.5s for three real
provider round-trips (would be ~5-6s sequential).

Docs updated:
- developer-guide/plugin-llm-access.md links both sync and async
  examples in the Reference section
- user-guide/features/extending-the-dashboard.md repoints both demo
  sections to the companion repo with corrected install paths
- user-guide/features/built-in-plugins.md drops the two demo rows
- AGENTS.md notes that example plugins live in the companion repo

Net: hermes-agent's plugins/ directory now contains only plugins
users actually run (memory providers, dashboard tabs that ship real
features, the disk-cleanup hook, platform adapters). All four
demo / reference plugins live externally where they can be cloned
on demand instead of inflating the core install.

* fix(kanban): use sys.executable -m hermes for dispatcher spawn

In NixOS container mode, hermes is installed at a store path with no
symlink on PATH (e.g. /data/current-package/bin/hermes). The kanban
dispatcher spawns workers via _default_spawn() using a bare 'hermes'
subprocess call, which fails with 'hermes executable not found on PATH'
in container mode.

Fix by calling sys.executable -m hermes instead, which is guaranteed
to resolve to the same Python interpreter running the dispatcher.

* fix(kanban): correct dispatcher spawn module name + PATH-first lookup

Follow-up to the previous commit's contributor cherry-pick.

The cherry-picked change replaced the bare ``["hermes", ...]`` spawn with
``[sys.executable, "-m", "hermes", ...]``. The intent was right (avoid
PATH dependence — cron, systemd User= services, launchd jobs, and other
detached dispatcher invocations routinely run with a stripped $PATH that
doesn't include the venv's bin/, breaking the bare-shim spawn) but the
module name is wrong: there is no top-level ``hermes`` package. The
console-script entry point in pyproject.toml is
``hermes = "hermes_cli.main:main"``, and ``python -m hermes`` fails with
``No module named hermes``. The cherry-picked form would have replaced a
sometimes-broken spawn with an always-broken one.

This commit:

- Adds ``_resolve_hermes_argv()``, mirroring ``gateway.run._resolve_hermes_bin``.
  Tries ``shutil.which("hermes")`` first (preferred — keeps existing ``ps``
  output and log lines familiar in the common case) and falls back to
  ``[sys.executable, "-m", "hermes_cli.main"]`` when the shim is not on
  PATH. The fallback goes through the running interpreter so it's
  PATH-independent. Kept as a local helper rather than imported from
  gateway because ``hermes_cli`` sits below ``gateway`` in the dependency
  order.
- Switches the dispatcher's ``cmd`` list to use ``*_resolve_hermes_argv()``.
- Adds three regression tests:
  * ``test_resolve_hermes_argv_prefers_path_shim`` — pins the PATH-first
    branch so a future refactor doesn't silently flip the order.
  * ``test_resolve_hermes_argv_falls_back_to_module_form_when_no_path_shim`` —
    pins the correct module name (``hermes_cli.main``, NOT ``hermes``).
    Direct regression guard for the form that shipped in the original PR.
  * ``test_resolve_hermes_argv_module_actually_runs`` — runs the fallback
    invocation as a real subprocess and asserts ``--version`` works, so
    losing ``hermes_cli.main``'s ``__main__`` handling can't slip past the
    string-match test.

Verified end-to-end: with the shim on PATH the resolver returns
``[/.../hermes]`` and ``--version`` works; with the shim removed the
resolver returns ``[python, -m, hermes_cli.main]`` and ``--version``
still works; the original PR's ``python -m hermes`` invocation fails as
expected (``No module named hermes``).

* feat(i18n): localize all gateway commands + web dashboard, add 8 new locales (16 total) (#22914)

* feat(i18n): localize /model command output

Reported by @tianma8888: when Chinese users run /model, the labels
("Provider:", "Context:", "_session only_", etc.) are still English.
This routes the static prose through the existing i18n catalog so it
follows display.language / HERMES_LANGUAGE.

Changes:
- locales/{en,zh,ja,de,es,fr,tr,uk}.yaml: add 17 keys under
  gateway.model.* covering switched/provider/context/max_output/cost/
  capabilities/prompt_caching/warning/saved_global/session_only_hint/
  current_label/current_tag/more_models_suffix/usage_*.
- gateway/run.py _handle_model_command: replace hardcoded f-strings in
  the picker callback, the text-list fallback, and the direct-switch
  confirmation block with t("gateway.model.<key>", ...).

What stays English:
- model IDs, provider slugs, capability strings, cost figures, and the
  "[Note: model was just switched...]" prepended to the model's next
  prompt (LLM-facing, not user-facing).
- The two slightly-different session-only hints unify on a single key
  with the em-dash phrasing.

Validation: tests/agent/test_i18n.py 27/27 passing (parity contract
holds), tests/gateway/ -k 'model or i18n' 74/74 passing.

* feat(i18n): localize all gateway slash command outputs

Expands the i18n catalog from 7 strings to 234 keys across 35 gateway
slash command handlers, so non-English users see localized output for
\`/profile\`, \`/status\`, \`/help\`, \`/personality\`, \`/voice\`, \`/reset\`,
\`/agents\`, \`/restart\`, \`/commands\`, \`/goal\`, \`/retry\`, \`/undo\`,
\`/sethome\`, \`/title\`, \`/yolo\`, \`/background\`, \`/approve\`, \`/deny\`,
\`/insights\`, \`/debug\`, \`/rollback\`, \`/reasoning\`, \`/fast\`,
\`/verbose\`, \`/footer\`, \`/compress\`, \`/topic\`, \`/kanban\`,
\`/resume\`, \`/branch\`, \`/usage\`, \`/reload-mcp\`, \`/reload-skills\`,
\`/update\`, \`/stop\` (plus the \`/model\` block already added in the
previous commit).

Reported by @tianma8888 — Chinese users want command output prose in
their language, not just the labels we already had.

Translations are hand-written for all 8 supported locales (en, zh, ja,
de, es, fr, tr, uk), matching each catalog's existing style: full-width
punctuation in zh, em-dashes in zh/ja/uk, French spaced colons,
German noun capitalization, etc.

What stays English (unchanged):
- Identifiers/values: model IDs, file paths, profile names, session IDs,
  command flag names like --global, URLs, config keys.
- Backtick code spans: \`/foo\`, \`config.yaml\`.
- Log messages (logger.info/warning/error).
- LLM-facing system notes prepended to next prompt (e.g. [Note: model
  was just switched...]).
- Strings produced by external modules (gateway_help_lines,
  format_gateway, manual_compression_feedback) — those have their
  own surfaces.

New shared keys for cross-handler boilerplate:
- gateway.shared.session_db_unavailable (5 call sites: branch, title,
  resume, topic, _disable_telegram_topic_mode_for_chat)
- gateway.shared.session_not_found (1 site)
- gateway.shared.warn_passthrough (2 sites in /title's f"⚠️ {e}" pattern)

YAML gotcha fixed: \`yolo.on\` and \`yolo.off\` were originally written
unquoted, which YAML 1.1 parses as boolean True/False keys. Renamed to
\`yolo.enabled\` / \`yolo.disabled\` for both safety and clarity.

Test fix: tests/agent/test_i18n.py::test_t_missing_key_in_non_english_falls_back_to_english
now resets the catalog cache on teardown, so the fake "foo: English Foo"
locale doesn't poison the module-level cache for subsequent tests in
the same xdist worker. (Without this, every gateway slash command test
that shares a worker with the i18n suite would see the fake catalog.)

Validation:
- tests/agent/test_i18n.py: 27/27 (parity contract — every key in every
  locale, matching placeholder tokens).
- tests/gateway/: 5077 passed, 0 failed (full gateway suite).
- 180 t() call sites added across 35 handlers; 1872 catalog entries
  total (234 keys × 8 locales).

* feat(i18n): add 8 new locales — af, ko, it, ga, zh-hant, pt, ru, hu

Expands the static-message catalog from 8 → 16 languages, each with full
270-key parity against the English source-of-truth.  Every locale now
covers the same surface PR #22914 added: approval prompts plus all 35
gateway slash command outputs.

New locales:
- af  Afrikaans      (community ask in #21961 by @GodsBoy; PRs #21962, #21970)
- ko  Korean         (PRs #20297 by @tmdgusya, #22285 by @project820)
- it  Italian        (PR #20371 by @leprincep35700)
- ga  Irish/Gaeilge  (PR #20962 by @ryanmcc09-dot)
- zh-hant Traditional Chinese (PRs #20523 by @jackey8616, #13140 by @anomixer)
- pt  Portuguese     (PRs #20443 by @pedroborges, #15737 by @carloshenriquecarniatto, #22063 by @Magaav)
- ru  Russian        (PR #22770 by @DrMaks22)
- hu  Hungarian      (PR #22336 by @lunasec007)

Each locale uses native-quality translations matching the existing tone
and conventions of the older 8 locales:
- zh-hant uses 繁體 characters with TW/HK technical vocabulary (軟體
  not 软件, 連線 not 连接, 設定 not 设置, 訊息 not 消息, 工作階段 not 会话, 程式
  not 程序, 預設 not 默认, 伺服器 not 服务器), full-width punctuation 「:()」.
- ko uses formal 합니다체 (습니다/합니다) register throughout.
- pt uses European Portuguese as baseline with neutral PT/BR vocabulary
  where possible.
- ga uses standard An Caighdeán Oifigiúil; English loanwords retained
  for tech terms without good Irish equivalents (gateway, API, JSON).
- All preserve {placeholder} tokens, backtick code spans, slash commands,
  brand names (Hermes, MCP, TTS, YOLO, OpenAI, Telegram, etc.), and emoji.

Aliases added in agent/i18n.py:
- af-za, Afrikaans → af
- ko-kr, Korean, 한국어 → ko
- it-it, italiano → it
- ga-ie, Irish, Gaeilge → ga
- zh-tw, zh-hk, zh-mo, traditional-chinese → zh-hant (note: zh-tw used to
  alias to zh; now aliases to its own zh-hant catalog)
- zh-cn, zh-hans, zh-sg → zh (unchanged from before)
- pt-pt, pt-br, brazilian, portuguese → pt
- ru-ru, Russian, русский → ru
- hu-hu, Magyar → hu

The zh-tw alias re-routing is intentional: previously typing 'zh-TW' got
the Simplified Chinese catalog (wrong vocabulary for Taiwan/HK users).
Now those users get the proper Traditional Chinese catalog.

Validation:
- tests/agent/test_i18n.py: 43/43 (parity contract holds for all 16
  languages × 270 keys = 4320 catalog entries, with matching placeholder
  tokens).
- E2E alias resolution verified for all 19 alias inputs (Afrikaans, ko-KR,
  한국어, italiano, Gaeilge, zh-TW, zh-HK, traditional-chinese, pt-BR,
  brazilian, Magyar, etc.).
- tests/gateway/: 5198 passed (3 pre-existing TTS routing failures
  unrelated to i18n).

Credit to all contributors whose PRs surfaced these language requests.
Their original PRs may now be closed as superseded with credit.

* feat(dashboard-i18n): add 14 web dashboard locales matching the static catalog

Brings the React dashboard (web/src/) up to the same 16-language
coverage the static catalog already has after the previous commits in
this PR. The Translations interface is TypeScript-typed, so every new
locale must provide every key — tsc -b is the parity guard.

Languages added (each is a complete 429-line locale file):
- af  Afrikaans
- ja  Japanese        (PR #22513 by @snuffxxx surfaced this)
- de  German          (PR #21749 by @mag1art)
- es  Spanish         (PR #21749)
- fr  French          (PRs #21749, #10310 by @foXaCe)
- tr  Turkish
- uk  Ukrainian
- ko  Korean          (PRs #21749, #18894 by @ovstng, #22285 by @project820)
- it  Italian
- ga  Irish (Gaeilge)
- zh-hant Traditional Chinese (PR #13140 by @anomixer)
- pt  Portuguese      (PRs #22063 by @Magaav, #22182 by @wesleysimplicio, #15737 by @carloshenriquecarniatto)
- ru  Russian         (PRs #21749, #22770 by @DrMaks22)
- hu  Hungarian       (PR #22336 by @lunasec007)

Each translation covers all 15 namespaces with full key parity vs en.ts,
preserves every {placeholder} token verbatim, keeps identifiers
untranslated (brand names, file paths, cron expressions, code spans),
translates the language.switchTo tooltip into the target language, and
matches existing tone conventions (zh-hant uses TW/HK vocab; ja uses
formal desu/masu; ko uses formal seumnida register; ga uses An
Caighdean Oifigiuil with English loanwords for tech vocab without good
Irish equivalents).

Plumbing:
- web/src/i18n/types.ts: Locale union expanded to all 16 codes.
- web/src/i18n/context.tsx: imports all 16 catalogs; exports
  LOCALE_META (endonym + flag per locale); isLocale() type guard.
- web/src/i18n/index.ts: re-export LOCALE_META.
- web/src/components/LanguageSwitcher.tsx: replaced two-state EN-ZH
  toggle with a click-to-open dropdown listing all 16 languages.

Note: zh-hant.ts exports zhHant (camelCase) since hyphen is invalid in
a JS identifier; the canonical 'zh-hant' string keys it in TRANSLATIONS.

Validation:
- npx tsc -b: 0 errors. Every locale satisfies Translations.
- npm run build (tsc + vite production): green, 2062 modules.
- Each locale file is exactly 429 lines.

Out of scope: plugin dashboards (kanban/achievements ship as prebuilt
bundles with no source in repo); Docusaurus docs (separate surface);
TUI (no i18n yet).

* feat(plugin-i18n): localize achievements + kanban plugin dashboards across all 16 locales

Brings the two shipped plugin dashboards (hermes-achievements, kanban)
under the same i18n umbrella as the core dashboard PR #22914 just
established.  Both bundles now read user-facing strings from the host's
i18n catalog via SDK.useI18n() instead of hardcoded English.

## Approach

Plugin dashboards ship as prebuilt IIFE bundles in
plugins/<name>/dashboard/dist/index.js — no build step, no source in
repo (upstream-authored, vendored as compiled JS).  Earlier contributor
PRs (#22594, #22595, #18747) tried direct edits but didn't actually
wire the bundles to read translations.

This change does the wiring properly:

1.  Each bundle gets a useI18n shim at IIFE scope:
        const useI18n = SDK.useI18n
          || function () { return { t: { kanban: null }, locale: "en" }; };
    Older host SDKs without useI18n still load the bundle and render
    English fallbacks.

2.  A small tx(t, path, fallback, vars) helper resolves dotted keys
    under the plugin's namespace (t.kanban.* or t.achievements.*) and
    interpolates {placeholder} tokens.

3.  Every React component starts with const { t } = useI18n() and
    each user-visible string is wrapped in tx(t, "key", "English fallback").
    Helpers called outside React components (window.prompt callers,
    constants used during init) take t as a parameter.

4.  Top-level constants that were English dictionaries (COLUMN_LABEL,
    COLUMN_HELP, DESTRUCTIVE_TRANSITIONS, DIAGNOSTIC_EVENT_LABELS in
    kanban) become getColumnLabel(t, status)-style functions backed by
    FALLBACK_* dictionaries.

## Translations added

Two new top-level namespaces added to the dashboard's TypeScript-typed
Translations interface:

- achievements: ~70 keys covering the hero, scan banner, achievement
  card, share dialog, stats, filters, and empty states.
- kanban: ~145 keys covering the board, columns (with nested
  columnLabels and columnHelp sub-dicts), card detail panel,
  bulk-actions toolbar, dependency editor, board switcher, and
  diagnostic callouts.

Each key is provided across all 16 supported locales:
en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu.

Total new translation entries: ~3,440 (215 keys × 16 locales).

## What stays English (deliberate)

- API paths, CSS class names, data-* attributes, JSON keys, regex
  strings, URLs, file paths (~/.hermes/kanban.db, boards/_archived/).
- State identifier strings used as lookup keys (triage / todo / ready /
  running / blocked / done / archived) — labels translate, key strings
  don't.
- The PNG share-card text rendered to canvas in the achievements
  ShareDialog (HERMES AGENT watermark, UNLOCKED stamp, tier names) —
  these become part of a globally-shared image and stay English.
- localStorage keys (hermes.kanban.selectedBoard).
- Brand names (Kanban, Hermes, WebSocket, Nous Research).

## Contributor credit

PR #22594 by @02356abc and PR #22595 by @02356abc supplied the
en + zh kanban namespace skeleton (145 keys); used as the en source-
of-truth in this commit and translated to the other 14 locales.

PR #18747 by @laolaoshiren first surfaced the achievements
localization request.

## Validation

- npx tsc -b: 0 errors. All 16 locale .ts files satisfy the
  Translations type with full key parity.
- npm run build (tsc + vite production build): green, 2062 modules,
  1.56MB JS / 95KB CSS, ~2.5s build.
- node --check on both plugin bundles: parse cleanly.
- 126 tx() call sites in kanban, 46 in achievements.

## Out of scope

- TUI (ui-tui/) has no i18n infrastructure yet.
- Docusaurus docs (website/i18n/) — already had zh-Hans; expanding
  is a separate translation workstream (Thai / Korean / Hindi PRs).

* fix(kanban): guard task_age against corrupt created_at values like '%s'

task_age() crashed with ValueError when created_at contained the
literal format string '%s' instead of a Unix timestamp, taking down
the entire GET /board endpoint with a 500.

- Add _safe_int() helper that returns None on non-numeric values
- Refactor task_age() to use _safe_int instead of bare int() casts
- Wrap task_age() call in _task_dict with try/except fallback so one
  corrupt row never kills the whole board endpoint

* test(kanban): cover task_age safe-int guards + AUTHOR_MAP entry

Follow-up to the previous commit's safe-int task_age fix.

The original PR shipped without test coverage. This commit adds:

- test_safe_int_accepts_int_and_int_string — sanity for the well-typed
  path so the helper itself can't quietly start swallowing valid values.
- test_safe_int_returns_none_on_corrupt_inputs — the failure modes
  (None, '%s', 'abc', '', '1.5', random objects). Covers both the
  ValueError and TypeError catch branches.
- test_task_age_handles_corrupt_created_at — the headline regression:
  a task with created_at='%s' used to raise ValueError and turn
  GET /api/plugins/kanban/board into a 500.
- test_task_age_handles_corrupt_started_and_completed — confirms the
  safe-int treatment is consistent across all three timestamp fields.
- test_task_age_well_formed_task — regression that the safe path
  doesn't change observable output for normal data.
- test_task_dict_survives_corrupt_created_at — defense in depth.
  Writes a corrupt row directly via SQL, reads it back through the
  ORM, and confirms task_age + the surrounding plugin_api guard
  degrade gracefully instead of crashing.

Also adds the AUTHOR_MAP entry for the contributor's GitHub-noreply
email so release notes credit @baocin (the commit was authored locally
as `aoi <aoi@hino.local>` — re-attributed during salvage to the
github noreply form).

* fix(kanban): preserve assignee casing in dashboard

* test(kanban-dashboard): pin assignee-casing static-asset regressions + AUTHOR_MAP

Follow-up to the previous commit's casing fix.

The original PR shipped the dist edits without test coverage. The
contributor's reasoning (UI-only attributes in a pre-built JS bundle,
nothing meaningful to unit-test) is fair, but a static-asset assertion
catches the most likely regression vector — a future rebuild of the
dist bundle that loses the attributes — at near-zero cost.

Adds two regression tests in tests/plugins/test_kanban_dashboard_plugin.py:

- test_dashboard_assignee_inputs_preserve_casing — reads dist/index.js
  and asserts autoCapitalize="none", autoCorrect="off", spellCheck=false,
  and textTransform="none" each appear at least twice (one per assignee
  input — inline triage/lane create + task-edit panel).
- test_dashboard_lane_head_preserves_assignee_casing — reads dist/style.css
  and asserts the .hermes-kanban-lane-head rule body does NOT contain
  text-transform: uppercase. Locates the rule by marker so unrelated CSS
  churn nearby doesn't flake the test.

Both follow the same shape as the existing test_dashboard_requests_default_board_explicitly
static-asset guard from PR #22940's salvage.

Also adds the AUTHOR_MAP entry for princepal9120's GitHub-noreply email
so release notes credit the right account.

* perf(browser): route browser_console eval through supervisor's persistent CDP WS (180x faster) (#23226)

Adds CDPSupervisor.evaluate_runtime() and wires it into _browser_eval as a
fast path when a supervisor is alive for the current task_id. Replaces the
~180ms agent-browser subprocess fork+exec+Node-startup hop with a ~1ms
Runtime.evaluate over the supervisor's already-connected WebSocket.

Falls through to the existing agent-browser CLI path when no supervisor is
running (e.g. backends without CDP, or before the first browser_navigate
attaches one), so behaviour is unchanged where it can't apply.

JS-side exceptions surface directly without falling through to the
subprocess (the subprocess would just re-raise the same error, slower);
supervisor-side failures (loop down, no session) fall through cleanly.

Benchmark — 30 iterations of `1 + 1` against headless Chrome:
  supervisor WS              mean=  0.96ms  median=  0.91ms
  agent-browser subprocess   mean=179.35ms  median=167.73ms
  → 187x speedup mean

Tests: 14 unit tests (mocked supervisor + response-shape coverage), 5
real-Chrome e2e tests in test_browser_supervisor.py (gated on Chrome
being installed). Browser test suite: 355 passed, 1 skipped.

* fix(kanban-dashboard): tone down completed-run metadata panel (#19548)

Hand-rebased onto current main from PR #19980; the original branch was stale
against main (~6 unrelated dashboard fixes had landed since), so applying
the PR's dist files directly would have silently reverted them.

The run-history panel in the task drawer rendered each completed run's
`metadata` field as a `<code class="hermes-kanban-run-meta">` containing
`JSON.stringify(r.metadata)` — a single unindented monoline. With
`white-space: pre-wrap` and a monospace font, a writer task's metadata
(changed_files paths, source URLs, generated-artifact details) wrapped
into a tall block of code-ish text that filled the parent run row. The
container's faint `--color-foreground 3%` background then made the whole
thing read like a crash dump even though the run completed normally.

Restyle and label, no interactivity changes:

- Wrap the meta payload in a `.hermes-kanban-run-meta-block` sub-block
  with an explicit `Metadata` label (small, uppercase, muted) so the
  panel reads as auxiliary detail at a glance.
- Pretty-print the JSON (`indent=2`) so the structure is scannable
  instead of a wall of monoline text.
- Cap `.hermes-kanban-run-meta` at `max-height: 8.5rem; overflow: auto`
  so a verbose blob scrolls inside its own pane rather than swamping
  the run row.
- Sub-block uses a thin `border-left` rule and `background: transparent`
  — distinct from the destructive-tinted treatment used by crashed /
  timed_out / blocked / spawn_failed runs higher in the same file.

Tests: two new static-asset assertions in
`tests/plugins/test_kanban_dashboard_plugin.py` lock in the rendered
shape (the plugin ships built-only, no src/).

* feat(kanban-dashboard): native <details> collapse + skip empty metadata

Two follow-up improvements to Tranquil-Flow's metadata-panel restyle.
Both stay within the parent PR's "tone down the panel" scope.

1. Native <details>/<summary> collapse for verbose metadata.

   The parent PR consciously deferred this ("adding native expand/collapse
   would be the next step but requires UX agreement"). The default they
   asked for is straightforward: collapsed when the rendered JSON exceeds
   300 chars (the threshold where the max-height: 8.5rem cap actually
   starts mattering), expanded otherwise. <details>/<summary> is the right
   primitive — zero JS, browser-handled state, accessible by default
   (keyboard-navigable, screen-reader announces the disclosure state),
   and survives any react-state churn for free.

   The OS-default disclosure marker is suppressed (list-style: none +
   ::-webkit-details-marker hidden) and replaced with a CSS ::before
   chevron that rotates 90deg on the [open] attribute, so the look is
   consistent across Firefox/WebKit/Blink without the double-marker
   that would otherwise appear on the platforms that still render the
   default triangle.

2. Skip rendering when metadata is an empty object.

   `r.metadata && ...` truthy-checks, but `{}` is truthy in JS — so a
   completed task with no actual metadata would render a "Metadata"
   labeled disclosure block containing literal `{}`. Adds an
   Object.keys(r.metadata).length > 0 guard so empty payloads render
   nothing instead of an empty disclosure stub.

Tests: three new static-asset assertions covering the <details> shape,
the empty-object skip, and the suppress-default-marker + animated-chevron
CSS — all in `tests/plugins/test_kanban_dashboard_plugin.py`.

* fix(kanban): reject toolset names in task skills

* feat(kanban): aggregate all toolset-name typos in skills before raising

Follow-up to the previous commit's toolset-vs-skill validation.

The contributor's fix raises ValueError on the first toolset name found
in the skills list. That works for one mistake, but agents that confuse
skills with toolsets usually pass several at once
(`skills=["web", "browser", "terminal"]`) — and serial-correcting one
per failure round-trip wastes tokens. Collect all toolset-shaped
entries first, then raise once with the full list.

The error message is also slightly clearer:

    'web', 'browser', 'terminal' are toolset names, not skill name(s).
    Put toolsets in the assignee profile's `toolsets:` config instead of
    per-task skills. Skills are named skill bundles (e.g. `kanban-worker`,
    `blogwatcher`); toolsets are runtime capabilities (e.g. `web`,
    `browser`, `terminal`).

vs. the previous "the assignee profile's toolsets" — explicitly naming
the YAML key (`toolsets:`) and giving concrete examples in both
categories closes the conceptual gap that produced the bug to begin
with.

Adds one regression test (test_create_task_skills_lists_all_toolset_typos)
covering the multi-name aggregation path. The single-typo test from
the original PR still passes (the loose `match="toolset name"` matches
both singular and plural forms).

* feat(gateway): shutdown forensics — non-blocking diag, per-phase timing, stale-unit warning (#23285)

When the gateway received SIGTERM, the shutdown_signal_handler ran a
synchronous 'ps aux' (3s timeout) inside the asyncio event loop, then
asyncio.create_task(runner.stop()).  On a busy host that ate 1-3s of
the teardown budget before draining could even start, and the resulting
log line was a multi-line ps dump that didn't tell us who sent the
signal.  The shutdown path itself logged 'Stopping gateway...' and then
nothing until 'Gateway stopped' — when systemd SIGKILLed mid-drain,
there was no way to see which phase wedged.

Changes:
- New gateway/shutdown_forensics.py:
  * snapshot_shutdown_context(sig) — sub-millisecond /proc-only capture
    of signal name, parent pid+name+cmdline, INVOCATION_ID (systemd
    marker), loadavg_1m, TracerPid, takeover/planned-stop marker
    presence + whether-it-names-self.  Pure stdlib, never raises.
  * spawn_async_diagnostic(log_path, sig) — detached subprocess with
    its own 'timeout 5s', start_new_session=True, writes ps auxf +
    pstree + dmesg to ~/.hermes/logs/gateway-shutdown-diag.log.
    Returns immediately, can't block the event loop or the cgroup
    teardown.
  * check_systemd_timing_alignment(drain_timeout) — reads
    /proc/self/cgroup for our unit, asks systemctl show for
    TimeoutStopUSec, returns mismatch info when the unit's stop
    timeout is smaller than restart_drain_timeout + 30s headroom
    (the case where systemd SIGKILLs mid-drain).
  * _parse_systemd_duration_to_us — covers '90s', '1min 30s',
    '500ms', '1h' style values from systemctl show.
  * format_context_for_log — single scannable key=value line, parent
    cmdline last.
- gateway/run.py shutdown_signal_handler:
  * Replaces synchronous ps aux + ad-hoc 'hermes-related lines' filter
    with snapshot + detached spawn.
  * Always logs 'Shutdown context: signal=... parent_pid=...
    parent_cmdline=...' regardless of planned/unexpected so we can
    correlate signal source even on planned restarts.
- gateway/run.py _stop_impl:
  * Per-phase '+X.XXs' timing for notify_active_sessions, drain
    (with drain_seconds, active_at_start, active_now, timed_out),
    post-interrupt tool kill, each adapter disconnect (Xs),
    all adapters disconnected, final-cleanup tool kill, SessionDB
    close, total teardown.
- gateway/run.py start():
  * Stale-unit warning at startup when the running systemd unit's
    TimeoutStopSec is smaller than the configured drain timeout.
    Points the user at 'hermes gateway service install --replace'
    to regenerate, or at shortening agent.restart_drain_timeout.

Tests: 30 new in tests/gateway/test_shutdown_forensics.py — snapshot
speed bound, signal name resolution, marker detection self-vs-other,
async diag spawn doesn't block caller, systemd duration parser, and
alignment check returns None outside systemd.  Wider tests/gateway/
suite: 5258 passing, 3 pre-existing TTS-routing failures unchanged
on main.

* fix(kanban): cap dispatch by running workers

* docs(kanban): document max_spawn as live concurrency cap (not per-tick budget)

Follow-up to the previous commit's behavior fix.

Adds a paragraph to dispatch_once's docstring making the concurrency-cap
semantic explicit, and an inline comment near the running_count query
explaining why we do the count (so a future reader doesn't refactor it
back to per-tick semantics thinking it's redundant). Both call out the
unbounded-accumulation failure mode that motivated the fix, since
nothing in the codebase or skills currently documents what max_spawn
is supposed to mean.

The semantic is per-board: each kanban board has its own SQLite file,
so the running-count COUNT(*) is naturally scoped to the board the
dispatcher tick is processing.

* chore: AUTHOR_MAP entry for guglielmofonda (#21505)

* fix(xai): drop models being retired May 15, 2026 from pickers (#23291)

xAI is retiring grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning},
grok-4-1-fast{,-reasoning,-non-reasoning}, and grok-code-fast-1 on
May 15, 2026 at 12:00 PT. Remove them from the static fallbacks so the
`hermes model` picker, gateway /model picker, and setup wizard stop
auto-suggesting models that will be dead in days.

- _XAI_STATIC_FALLBACK in hermes_cli/models.py now lists only grok-4.20-*
  and grok-4.3 (the live replacements).
- copilot lists in hermes_cli/models.py and hermes_cli/setup.py drop
  grok-code-fast-1 (Copilot proxies it through xAI, so the upstream
  retirement breaks it there too).

Old configs that already reference retired IDs keep working until xAI
flips the switch — context-length lookups in agent/model_metadata.py and
the cache-affinity-header logic in provider_profiles still recognise the
old names. The cleanup here is purely about not advertising them to new
users.

Closes #23278.

Source: https://docs.x.ai/developers/migration/may-15-retirement

* feat(gateway): per-platform admin/user split for slash commands (salvage of #4443) (#23373)

* feat(gateway): per-platform admin/user split for slash commands

Adds an opt-in two-list access control on top of the existing per-platform
`allow_from` allowlists, scoped to slash commands only:

  - allow_admin_from         — full slash command access
  - user_allowed_commands    — what non-admins may run
  - group_allow_admin_from   — same, group/channel scope
  - group_user_allowed_commands

When `allow_admin_from` is unset for a scope, gating is disabled and every
allowed user keeps full access (backward compat). Plain chat is unaffected.
`/help` and `/whoami` are always reachable so users can see what they
can run.

Gate runs at the slash command dispatch site in gateway/run.py and uses
`is_gateway_known_command()`, so it covers built-in AND plugin-registered
commands through the live registry without per-feature wiring.

Adds `/whoami` showing platform, scope, tier, and runnable commands.

Salvage of PR #4443's permission tier work, scoped down. The full tier
system, tool filtering, audit log, usage tracking, rate limiting,
`/promote` flow, and persistent SQLite stores are not included here —
those can be re-expanded later if needed.

Co-authored-by: ReqX <mike@grossmann.at>

* fix(gateway): close running-agent fast-path bypass + add coverage and central docs

The slash command access gate was only applied at the cold dispatch site
(line ~5921). When an agent was already running, the running-agent
fast-path block (line ~5574) dispatched /restart, /stop, /new, /steer,
/model, /approve, /deny, /agents, /background, /kanban, /goal, /yolo,
/verbose, /footer, /help, /commands, /profile, /update directly
without going through the gate — letting non-admins bypass gating just
because an agent happens to be busy.

Refactored the gate into _check_slash_access() and called from BOTH
paths. /status remains intentionally pre-gate so users can always see
session state.

Also added 18 more dispatch tests covering:
  - Running-agent fast-path: blocks non-admin, allows admin, /status
    always works
  - Alias canonicalization (gate uses canonical name, not user alias)
  - Unknown / unregistered commands pass through (don't false-positive)
  - DM admin scope-locked when group has its own admin list
  - Multi-platform isolation (Discord gated, Telegram unrestricted)

Docs: added Slash Command Access Control section to the central
messaging index page + /whoami row in the chat commands table.

Co-authored-by: ReqX <mike@grossmann.at>

---------

Co-authored-by: ReqX <mike@grossmann.at>

* refactor(kanban-orchestrator): drop hardcoded specialist roster, add Step-0 profile discovery

The skill enumerated 8 specialist profile names (researcher, analyst,
writer, reviewer, backend-eng, frontend-eng, ops, pm) as "the standard
roster" and told orchestrators to "assume these exist." Almost no real
Hermes setup matches that fleet — single-profile setups, Docker-worker
setups, and curated-team setups all violate it — so following the skill
literally produced cards assigned to non-existent profiles, which the
dispatcher silently failed to spawn (no autocorrect, no fallback, just
sits in `ready` forever).

Changes:

- Drop the standard-specialist-roster table.
- Add a "Profiles are user-configured — not a fixed roster" section at
  the top with a Step 0 that prescribes `hermes profile list` (or asking
  the user) before fanning out. Cache the result in working memory.
- Rewrite the worked task-graph example with placeholder names
  (<profile-A>, <profile-B>, <profile-C>) so the structure is still
  teachable but doesn't invite copy-paste of role names that may not
  exist.
- Reframe the "If no specialist fits" anti-temptation rule: don't
  invent profile names; ask the user.
- Add a "Inventing profile names that doesn't exist" entry to Pitfalls.
- Bump skill version 2.0.0 → 3.0.0 (semantic break: previous behavior
  promised a roster the skill no longer enumerates).
- Update website/docs/user-guide/features/kanban.md to drop the
  matching "(researcher, writer, analyst, backend-eng, reviewer, ops)"
  line and explain the discovery prompt instead.
- Re-run website/scripts/generate-skill-docs.py to refresh the
  auto-generated skill page + catalog.

Closes #21131 in spirit — addresses the same hardcoded-names footgun
@yehuosi flagged, with a different shape than their PR (delete the
roster rather than replace each name with placeholder, since the
roster table was the load-bearing footgun and the worked example is
salvageable with placeholder profile names).

Co-authored-by: yehuosi <yehuosi@users.noreply.github.com>

* feat(session): add /handoff command for cross-platform session transfer

Adds /handoff <platform> CLI command that queues the current session for
resume on the configured home channel of any messaging platform.

CLI side:
- /handoff telegram — marks session in shared DB, sends summary to
  the Telegram home channel via send_message
- /handoff discord — same for Discord
- Supports telegram, discord, slack, whatsapp, signal, matrix

Gateway side:
- On new session creation, checks for pending handoffs for the
  incoming message's platform
- If found, loads the CLI session's full conversation history and
  injects it into the context prompt as a handoff transcript
- Agent continues the conversation seamlessly

Files:
- hermes_state.py: handoff_pending, handoff_platform columns + helpers
- cli.py: _handle_handoff_command dispatch + handler
- hermes_cli/commands.py: CommandDef entry
- gateway/run.py: handoff detection in _handle_message_with_agent
- tests/hermes_cli/test_session_handoff.py: 8 tests

* feat(session): make /handoff actually transfer the session live

Builds on @kshitijk4poor's CLI handoff stub. The original PR's flow
deferred everything to whenever a real user happened to message the
target platform; this rewrites it so the gateway picks up handoffs
immediately and the destination chat just starts working.

State machine on sessions table replaces the boolean flag:
  None -> 'pending' -> 'running' -> ('completed' | 'failed')
plus handoff_error for failure reasons. CLI request_handoff /
get_handoff_state / list_pending_handoffs / claim_handoff /
complete_handoff / fail_handoff helpers wrap the transitions.

CLI side (cli.py): /handoff <platform> validates the platform's home
channel via load_gateway_config, refuses if the agent is mid-turn,
flips the row to 'pending', and poll-blocks (60s) on terminal state.
On 'completed' it prints the /resume hint and exits the CLI like
/quit. On 'failed' or timeout it surfaces the reason and the CLI
session stays intact.

Gateway side (gateway/run.py): new _handoff_watcher background task
scans state.db every 2s, atomically claims pending rows, and runs
_process_handoff for each. _process_handoff:

  1. Resolves the platform's home channel.
  2. Asks the adapter for a fresh thread via the new
     create_handoff_thread(parent_chat_id, name) capability so the
     handed-off conversation gets its own scrollback. Adapters that
     don't support threads (or fail) return None and the watcher
     falls back to the home channel directly.
  3. Constructs a SessionSource keyed as 'thread' when a thread was
     created, 'dm' otherwise, then session_store.switch_session
     re-binds the destination key to the CLI session_id. The full
     role-aware transcript replays via load_transcript on the next
     turn (no flat-text injection into context_prompt).
  4. For…
jsboige pushed a commit to jsboige/hermes-agent that referenced this pull request May 14, 2026
…3197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR NousResearch#21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR NousResearch#18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR NousResearch#18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR NousResearch#18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR NousResearch#8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942,
NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR NousResearch#23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
AlexFoxD pushed a commit to AlexFoxD/hermes-agent that referenced this pull request May 21, 2026
…3197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR NousResearch#21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR NousResearch#18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR NousResearch#18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR NousResearch#18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR NousResearch#8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942,
NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR NousResearch#23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…3197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR NousResearch#21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR NousResearch#18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR NousResearch#18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR NousResearch#18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR NousResearch#8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942,
NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR NousResearch#23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
Seven74AI pushed a commit to Seven74AI/hermes-agent that referenced this pull request Jun 13, 2026
…3197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR NousResearch#21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR NousResearch#18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR NousResearch#18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR NousResearch#18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR NousResearch#8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942,
NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR NousResearch#23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
T02200059 pushed a commit to T02200059/hermes-agent that referenced this pull request Jun 18, 2026
…3197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR NousResearch#21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR NousResearch#18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR NousResearch#18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR NousResearch#18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR NousResearch#8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942,
NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR NousResearch#23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
linxule pushed a commit to linxule/hermes-agent that referenced this pull request Jun 28, 2026
Bundled platform plugin following the LINE precedent (NousResearch#23197) and the
more recent SimpleX precedent (NousResearch#26232). KimiClaw is Moonshot AI's
agentic bot platform on kimi.com (launched Feb 2026). Distinct from
the Moonshot LLM provider (kimi-for-coding profile in
plugins/model-providers/kimi-coding/, aliased in
hermes_cli/providers.py) — this PR adds a separate chat platform and
does not touch that integration.

The adapter bridges two channels under one bot identity:
- DM via Zed ACP over WebSocket (sentinel session im:kimi:main)
- Group rooms via Connect RPC Subscribe server-stream over HTTP/1.1
  long-poll (chat_id room:<uuid>); outbound replies via unary
  Connect RPC SendMessage

Deployment model: Moonshot's intended bot deployment uses the
official OpenClaw runtime (claw-install.sh, V2026.4.5+) to own the
wire connection. This plugin instead speaks the wire protocol
directly from Python while sending the OpenClaw-shaped runtime
metadata headers kimi.com gates group-room participation on
("OpenClaw 3.13 or above"). Identity layers are honest:
User-Agent is hermes-kimi-adapter/1.0, X-Kimi-Claw-ID is
prefixed hermes-kimi-, X-Kimi-OpenClaw-Skills is suppressed by
default, and X-Kimi-OpenClaw-Version is set to the documented
group-gate floor (2026.3.13) — not a "real install" value. All
five OpenClaw-shaped headers are overridable via config.extra.
See plugins/platforms/kimiclaw/adapter.py:23-28 (module docstring)
and :103-112 (_GROUP_GATE_DEFAULTS) for the rationale.

Production wear: the same adapter code has run on one Raspberry Pi
gateway under daily user traffic since 2026-04-27. The adapter
previously shipped (and continues to ship) as an external plugin
at linxule/hermes-kimi-plugin; this PR upstreams it at version
1.0.0 — a stable snapshot intentionally decoupled from the external
repo's bleeding-edge stream.

Files:
- plugins/platforms/kimiclaw/{__init__,adapter,plugin}.{py,yaml}
- tests/gateway/test_kimiclaw_plugin.py
- website/docs/user-guide/messaging/kimiclaw.md (setup + deployment
  model + known limitations)
- website/docs/reference/environment-variables.md (+ KIMI_* table)
- website/docs/user-guide/messaging/index.md (capability row)
- website/sidebars.ts (sidebar entry)
- cli-config.yaml.example (kimiclaw: bridge block)
- pyproject.toml + uv.lock (declares websockets==15.0.1 in
  [messaging] extra for the DM ACP socket)
- scripts/release.py (AUTHOR_MAP attribution entry)

Internal class names retained as KimiAdapter / check_kimi_requirements
— implementation detail; matches the QQAdapter precedent. Env-var
names retained as KIMI_* because credentials are issued by
kimi.com itself.

265 tests passing (pytest tests/gateway/test_kimiclaw_plugin.py).
Includes regression coverage for the tool_only DM-inflight closure
path (test_3b_4, test_3b_5), bug-fixes for 429 retryability and
upload-path exception handling in the standalone send code path,
and the registry-gate (_check_for_registry) requiring KIMI_BOT_TOKEN
before auto-enable so messaging-equipped installs don't light up
KimiClaw without credentials.
choguun added a commit to choguun/real-estate-ai-agent that referenced this pull request Jul 3, 2026
…rms, real-adapter bones

Driven by review of NousResearch/hermes-agent#23197 (1638 LOC LINE
plugin) against our mocks-first FastAPI implementation. Doc at
docs/line-integration-gap-analysis.md walks the full diff and
explains what's worth porting for our Thai real-estate AI agent.

This PR lands the four highest-leverage fixes:

1. **Webhook body cap (1 MiB).** Memory-exhaustion guard
   rejecting oversized payloads with 413 BEFORE the signature
   check. Constant from app.adapters.line.base so both adapters
   and the router share it.

2. **Outbound Markdown stripper** (app.adapters.line.base).
   LINE can't render Markdown reliably across iOS/Android/web/
   macOS clients. strip_markdown() removes ATX headings,
   bold/italic (*/_/__), inline code + code fences, leading list
   bullets, and blockquote markers while leaving bare URLs
   untouched. Used by future real wiring; tested on the mock.

3. **LINE 5-message / 4500-char chunker**
   (split_for_line()). Per LINE Messaging API docs each
   bubble caps at 5000 chars and each Reply/Push call caps at
   5 message objects. Naive splitter: paragraph boundaries first,
   then Thai 。/Western .  sentence boundaries, hard
   cut as last resort. Capped at LINE_MAX_MESSAGES_PER_CALL (5).
   Tested with 11 cases including the Thai sentence terminator.

4. **LineRealAdapter: bot user-id cache + reply-token cache +
   self-message filter stub.** When the real HTTP wiring ships,
   these are the bones it will use:
     - bot_user_id: str | None  set at __init__ (auto-fetched
       from GET /v2/bot/info when wiring lands)
     - _reply_tokens: dict[chat_id, (token, expires_at)]  with
       set_reply_token() + consume_reply_token() (Reply
       tokens are single-use; ~60s TTL; expire-test covered)
     - send_reply() short-circuits with
       skipped='self-message' when line_user_id ==
       bot_user_id — prevents the inbound-outbound echo loop
       that hits any production bot without this filter.
   send_reply() still raises NotImplementedError for real
   sends; the doc-string in the method lists the exact
   strip_markdown → split_for_line → consume_reply_token →
   Reply-or-Push order the eventual wiring will follow.

## Tests

- backend/tests/test_line_helpers.py — 28 new tests
  (TestStripMarkdown × 11, TestSplitForLine × 8,
  TestWebhookBodyCap × 1, TestLineRealAdapterStructure × 8)
- backend/tests/test_line_webhook.py — 2 new tests
  (oversized → 413, exactly-at-cap → 400 to prove  boundary)

## Verified

- pytest: 168/168 (was 138; +30 new) — coverage 92.88% ≥ 80% ✅
- RUN_REAL_ADAPTER_TESTS=1 pytest tests/test_real_swap.py: 6/6 ✅
  (real adapter isinstance checks now also cover the new
  set_reply_token / consume_reply_token surface)
- ruff + mypy strict: clean
- Frontend unchanged (lib/api.ts: 36/36 vitest still passing)

## Out of scope (logged in docs/line-integration-gap-analysis.md)

- Three-allowlist gating: N/A for single-tenant MVP
- Media inbound (image/audio/video/file/sticker/location): defer
  until listing creation needs inbound photos
- Media SEND with HTTPS serving: defer; we have
  /api/upload-image as the upload path
- Slow-LLM postback button (their headline feature): N/A, we
  don't run an async-streaming LLM
- Loading indicator / typing animation: defer
- accountLink / memberJoined / things: defer (no LIFF, no IoT)
- unsend event handling: 1-migration scope, defer to follow-up
choguun added a commit to choguun/real-estate-ai-agent that referenced this pull request Jul 3, 2026
…rms, real-adapter bones

Driven by review of NousResearch/hermes-agent#23197 (1638 LOC LINE
plugin) against our mocks-first FastAPI implementation. Doc at
docs/line-integration-gap-analysis.md walks the full diff and
explains what's worth porting for our Thai real-estate AI agent.

This PR lands the four highest-leverage fixes:

1. **Webhook body cap (1 MiB).** Memory-exhaustion guard
   rejecting oversized payloads with 413 BEFORE the signature
   check. Constant from app.adapters.line.base so both adapters
   and the router share it.

2. **Outbound Markdown stripper** (app.adapters.line.base).
   LINE can't render Markdown reliably across iOS/Android/web/
   macOS clients. strip_markdown() removes ATX headings,
   bold/italic (*/_/__), inline code + code fences, leading list
   bullets, and blockquote markers while leaving bare URLs
   untouched. Used by future real wiring; tested on the mock.

3. **LINE 5-message / 4500-char chunker**
   (split_for_line()). Per LINE Messaging API docs each
   bubble caps at 5000 chars and each Reply/Push call caps at
   5 message objects. Naive splitter: paragraph boundaries first,
   then Thai 。/Western .  sentence boundaries, hard
   cut as last resort. Capped at LINE_MAX_MESSAGES_PER_CALL (5).
   Tested with 11 cases including the Thai sentence terminator.

4. **LineRealAdapter: bot user-id cache + reply-token cache +
   self-message filter stub.** When the real HTTP wiring ships,
   these are the bones it will use:
     - bot_user_id: str | None  set at __init__ (auto-fetched
       from GET /v2/bot/info when wiring lands)
     - _reply_tokens: dict[chat_id, (token, expires_at)]  with
       set_reply_token() + consume_reply_token() (Reply
       tokens are single-use; ~60s TTL; expire-test covered)
     - send_reply() short-circuits with
       skipped='self-message' when line_user_id ==
       bot_user_id — prevents the inbound-outbound echo loop
       that hits any production bot without this filter.
   send_reply() still raises NotImplementedError for real
   sends; the doc-string in the method lists the exact
   strip_markdown → split_for_line → consume_reply_token →
   Reply-or-Push order the eventual wiring will follow.

## Tests

- backend/tests/test_line_helpers.py — 28 new tests
  (TestStripMarkdown × 11, TestSplitForLine × 8,
  TestWebhookBodyCap × 1, TestLineRealAdapterStructure × 8)
- backend/tests/test_line_webhook.py — 2 new tests
  (oversized → 413, exactly-at-cap → 400 to prove  boundary)

## Verified

- pytest: 168/168 (was 138; +30 new) — coverage 92.88% ≥ 80% ✅
- RUN_REAL_ADAPTER_TESTS=1 pytest tests/test_real_swap.py: 6/6 ✅
  (real adapter isinstance checks now also cover the new
  set_reply_token / consume_reply_token surface)
- ruff + mypy strict: clean
- Frontend unchanged (lib/api.ts: 36/36 vitest still passing)

## Out of scope (logged in docs/line-integration-gap-analysis.md)

- Three-allowlist gating: N/A for single-tenant MVP
- Media inbound (image/audio/video/file/sticker/location): defer
  until listing creation needs inbound photos
- Media SEND with HTTPS serving: defer; we have
  /api/upload-image as the upload path
- Slow-LLM postback button (their headline feature): N/A, we
  don't run an async-streaming LLM
- Loading indicator / typing animation: defer
- accountLink / memberJoined / things: defer (no LIFF, no IoT)
- unsend event handling: 1-migration scope, defer to follow-up
choguun added a commit to choguun/real-estate-ai-agent that referenced this pull request Jul 3, 2026
…orms + real-adapter bones (#2)

* spec: month-1 mvp — mocks-first real-estate AI agent

Full AIDLC spec for Month-1 MVP. Scope:

- Next.js 15 + FastAPI + Supabase + LINE + Claude/Gemini adapters
- Every external integration behind a mock/real adapter pair
- Single agent only (no teams), Thai property UX, PDPA-safe defaults
- 12 acceptance criteria + 20 ST-NNN test scenarios

Layout: backend/app/{domain,adapters,routers,services}/ +
web/app/(marketing|auth|app)/. Adapters live behind Protocol classes
so USE_MOCKS=false swaps mock→real adapters with no router change.

Open questions parked for plan phase.

* feat(T-001): repo scaffold + FastAPI + Next.js shells + /health + CI

Backend (Python 3.11 / FastAPI 0.115 / pydantic v2):
- pyproject.toml with ruff+mypy(strict)+pytest config
- requirements.txt (fastapi, uvicorn, pydantic-settings, jwt, bcrypt, httpx, ...)
- app/main.py factory + CORS + lifespan
- app/config.py (pydantic-settings) — env-driven, mock-first defaults
- app/routers/health.py — GET /health → {"status":"ok"}
- tests/conftest.py + tests/test_health.py (ST-001)
- .env.example

Frontend (Next.js 15 / React 19 / Tailwind v3 / shadcn config):
- package.json, tsconfig.json, next.config.mjs, tailwind.config.ts, postcss
- app/globals.css + design tokens (shadcn variables)
- app/layout.tsx + app/page.tsx (landing shows backend health badge)
- app/api/health/route.ts (proxy → backend)
- lib/api.ts (typed fetch + ApiError + token cache)
- lib/utils.ts (cn helper)
- vitest.config + setup + 1 passing unit test
- components.json (shadcn config), .eslintrc.json, .env.example

CI (GitHub Actions):
- Backend matrix: ruff check, format check, mypy strict, pytest
- Frontend matrix: lint, typecheck, vitest

Toolchains locally verified:
- uvicorn app.main:app → 200 OK on /health and /
- pytest -v: 3/3 passed
- npm run lint: clean
- npm run typecheck: clean
- npm test: 2/2 passed

* feat(T-002): mock Supabase adapter + migration runner + factory

Backend (Python):
- app/adapters/supabase/base.py — SupabaseAdapter Protocol (query, count, insert, update, delete, get_by_id)
- app/adapters/supabase/_schema.py — Schema/Table/Column dataclasses; DEFAULT_SCHEMA declares all 10 tables (users, teams, properties, leads, messages, appointments, generated_listings, contracts, user_settings, audit_logs) with PG types, NOT NULL, defaults callable (UUID mint, NOW(), role='agent', status='draft', etc.)
- app/adapters/supabase/mock.py — MockSupabaseAdapter: in-memory, insertion-ordered, NOT NULL validation, auto-id, updated_at re-stamp on update, reset() helper
- app/adapters/supabase/real.py — RealSupabaseAdapter: stub, raises NotImplementedError, implements the Protocol so isinstance() succeeds (ST-019-shaped)
- app/adapters/supabase/_factory.py — get_db() selects adapter by Settings(use_real_supabase)
- app/adapters/supabase/__init__.py — public re-exports
- app/adapters/__init__.py
- app/deps.py — DBDep = Annotated[SupabaseAdapter, Depends(get_db_dep)]
- migrations/001_init.sql — canonical Postgres DDL mirroring DB.md
- migrations/__init__.py

Tests (pytest, 22 new tests, total 25/25 passing):
- Schema presence — all 10 expected tables exist (covers AC-01 partial)
- SQL ↔ mock schema parity (table names must match, test fails on drift)
- Round-trip CRUD on users, properties, leads
- Defaults: user.role='agent', property.status='draft', lead.source='line', lead.status='new'
- Auto-id is canonical UUID (36 chars, 4 hyphens)
- NOT NULL enforcement
- update of unknown id → None
- delete of unknown id → False
- count() with and without filters
- Stable insertion-order query (no order_by)
- order_by + desc sorting
- limit + offset pagination
- snapshot stability across fresh adapters
- factory: mock by default, real when USE_REAL_SUPABASE=true

Verified locally:
- pytest -q → 25/25 in 0.03s
- coverage: --cov=app = 91% (real.py only low because stubs are intentional)
- ruff check app/ tests/ → all checks passed
- ruff format app/ tests/ → 18 files left unchanged
- mypy app/ (strict + pydantic plugin) → success, no issues
- uvicorn /health → 200 {"status":"ok"} (no regression)

* chore(aidlc): mark T-002 done; next is T-003 (auth)

* feat(T-003): auth — signup / login / LIFF / /me + auth pages

Backend (Python, FastAPI, Pydantic v2, bcrypt, PyJWT):
- app/domain/user.py — User + SignupIn + LoginIn + LiffIn + AuthResponse DTOs
- app/services/auth.py — AuthService (signup/login/liff_login/user_from_token),
  hash_password / verify_password (bcrypt), create_access_token / decode_token
  (HS256, jwt_ttl configurable), typed AuthError subclasses (DuplicateEmail 409,
  InvalidCredentials 401, UserNotFound 404) with HTTP mapping
- app/routers/auth.py — POST /api/auth/{signup,login,liff}, GET /api/auth/me
- app/deps.py — get_db_dep already; new AuthServiceDep wired in router
- app/main.py — register auth_router
- app/adapters/supabase/_factory.py — process-singleton mock with thread
  safety; one bug found and fixed during E2E (per-request mock lost state)
- app/adapters/supabase/{_schema.py, ../../migrations/001_init.sql} —
  added password_hash TEXT to users (both mirror each other)
- tests/test_auth.py — 15 tests
  ST-002: signup OK + duplicate → 409
  ST-003: login OK + wrong pwd → 401 + unknown email → 401
  ST-004: LIFF OK + reuse same user + placeholder email
  /me: valid token → 200, no header → 401, malformed → 401, wrong scheme → 401

Frontend (Next.js 15, React 19, Tailwind, ts):
- lib/api.ts — extended: apiGet/apiPost, clearAuthToken, getAuthToken, User type
- lib/auth.ts — login/signup/liffLogin/fetchMe/describeAuthError
- app/(auth)/layout.tsx — card-style auth layout
- app/(auth)/login/page.tsx — email+password + green LINE button
- app/(auth)/signup/page.tsx — full_name+email+password
- app/(app)/dashboard/page.tsx — placeholder that loads /me (real dashboard
  lands in T-011)
- __tests__/auth.test.ts — 7 tests covering wrappers + error mapping

Verified locally:
- pytest: 40/40 (15 new from T-003)
- coverage on app/: 94% (target was 80%)
- ruff + mypy strict: clean
- frontend: lint, typecheck, vitest 9/9
- next build: compiled + 7 routes generated
- end-to-end via curl: signup → JWT → /me returns user across requests

* feat(T-004): properties CRUD + scoped list page

Backend:
- app/domain/property.py — PropertyType + PropertyStatus enums; PropertyCreate
  (required), PropertyUpdate (all-optional including status), Property (response)
- app/deps.py — CurrentUserIdDep (bearer token → user id without DB hit) +
  SettingsDep
- app/routers/properties.py — list/create/get/patch/archive scoped to user_id;
  cross-user reads / writes / archives return 404 (not 403) to avoid id probing
- app/main.py — register properties_router
- tests/test_properties.py — 16 tests:
  auth gate (401 without token)
  ST-005: round-trip create + defaults (status='draft', foreign_quota=False)
  list scopes to caller, excludes archived by default
  get / patch / archive flow
  ?status= filter and ?include_archived=true flag
  archive is idempotent
  cross-user isolation: 404 on read, patch, archive; not in list
  payload validation: invalid property_type, negative price, extra fields (422)
  minimal payload accepted

Frontend (Next.js 15):
- lib/types.ts — Property + PropertyCreateInput + PropertyUpdateInput +
  formatTHB (Intl) + propertyTypeLabel (en/th)
- lib/api.ts — added apiPatch/apiDelete/apiPostNoBody
- lib/properties.ts — listProperties/getProperty/createProperty/updateProperty/archiveProperty
- app/(app)/layout.tsx — auth-gated chrome with nav
- app/(app)/properties/page.tsx — client component, redirects if no token,
  loading/error/empty/list states
- components/properties/PropertyCard.tsx — Thai labels + THB format + status pill
- components/properties/PropertyCard.test.tsx — 5 RTL tests (Thai text, fallback,
  status)
- vitest.config.ts — added @vitejs/plugin-react for JSX automatic runtime

Verified locally:
- pytest: 56/56 ✅ (16 new from T-004)
- coverage on app/: 94%
- ruff + mypy strict: clean
- next lint + tsc + build: clean, 8 routes generated
- vitest: 14/14 ✅ (5 new component tests)
- end-to-end via curl: signup → create condo + house → list 2 → archive one → list 1
- 401 without token

* feat(T-005): mock storage adapter + image upload endpoint + new property form

Backend (Python):
- app/adapters/storage/base.py — StorageAdapter Protocol + StoredObject
  DTO + is_allowed_image() allow-list (jpeg/png/webp/gif)
- app/adapters/storage/local_mock.py — disk-backed mock; writes to
  ${var_dir}/uploads/{uuid}{ext}; path-traversal defence (rejects
  '/' / '\\' / '.');
  max 10 MiB per file
- app/adapters/storage/supabase_real.py — stub for MVP (NotImplementedError)
- app/adapters/storage/_factory.py — picks by Settings.use_real_supabase
- app/adapters/storage/__init__.py — public re-exports
- app/deps.py — StorageDep; SettingsDep now reads settings from
  request.app.state (no longer global cache; tests override cleanly)
- app/main.py — register storage_router; app.state.settings set in factory
- app/routers/storage.py —
    POST /api/upload-image (multipart, auth-gated, 415 on bad type,
                              400 on empty/too-big)
    GET  /static/{key} (no auth, 404 on missing/path traversal)
- app/config.py — public_base_url setting (default http://localhost:8000)
- .env.example — PUBLIC_BASE_URL line
- tests/test_storage.py — 14 tests:
    Direct adapter: writes to disk, rejects empty, get round-trip,
    path-traversal blocked, delete idempotent
    HTTP: ST-015 upload returns URL with key; uploaded file served via
    /static/{key}; 401 without auth; 415 on bad extension or MIME;
    accept jpg; 404 on /static/no-such

Frontend (Next.js 15):
- lib/api.ts — apiUpload for FormData (browser sets content-type
  with boundary; Content-Type not forced to JSON for FormData)
- lib/uploads.ts — uploadImage + uploadImages wrappers
- components/forms/ImageUploader.tsx — multi-file picker, generates
  preview thumbnails via URL.createObjectURL, remove button, accepts
  image/* with allow-list
- components/forms/PropertyForm.tsx — client component, all
  property fields (Thai labels, ตร.ม. units, BTS/MRT, foreign quota),
  upload-on-submit flow, error/loading/redirect-to-detail
- app/(app)/properties/new/page.tsx — entry point

Verified locally:
- pytest: 70/70 ✅ (14 new from T-005)
- coverage on app/: 94%
- ruff + mypy strict: clean
- next lint + typecheck + build: clean, 9 routes incl. /properties/new
- vitest: 14/14 ✅
- end-to-end via curl on backend: signup → upload PNG → GET /static/{key}
  returns 200 with PNG bytes

* feat(T-006): mock AI adapter + /api/generate-listing + ListingPreview

Backend (Python):
- app/domain/listing.py — Platform enum (4), PropertySummary,
  ListingRequest (forbid extras), GeneratedContent response
- app/adapters/ai/base.py — AiAdapter Protocol + FallbackToNext +
  BadRequest (the two error categories the service distinguishes)
- app/adapters/ai/anthropic_mock.py — deterministic Thai templates
  per (property_type, platform); DDProperty/Livinginsider/Facebook/General;
  includes ตร.ม., ห้องนอน, ห้องน้ำ, ชั้น, โควต้าต่างชาติ; Facebook
  emits 6 hashtags; General is bilingual.
- app/adapters/ai/gemini_mock.py — different model name + tone;
  used as the fallback chain's secondary
- app/adapters/ai/anthropic_real.py, gemini_real.py — stubs raise
  FallbackToNext('not wired in MVP')
- app/adapters/ai/_factory.py — build_ai_chain([primary, secondary])
- app/services/listing_generator.py — orchestrates the chain; raises
  BadRequest immediately; tries next adapter on FallbackToNext OR any
  transient exception; surfaces RuntimeError if all fail
- app/deps.py — AIChainDep
- app/routers/ai.py — POST /api/generate-listing; auth required;
  filters by platforms (default = all 4); 400/422 on bad payload
- app/main.py — register ai_router
- tests/test_ai_generator.py — 13 tests:
    ST-006: condo DDProperty contains คอนโด/ตร.ม./ห้องนอน/BTS Asok
    condo Facebook has ≥ 5 hashtags
    all 4 platforms returned
    ST-007: house General mentions บ้านเดี่ยว or 'house'
    house DDProperty mentions 200 ตร.ม.
    p99 latency < 2s (10 × 4 platforms)
    fallback when primary raises FallbackToNext
    4xx surfaces immediately (no fallback chain)
    fallback when primary raises arbitrary exception
    HTTP: returns 4 platforms, accepts platforms filter, 401 without auth,
    rejects unknown platform (422)

Frontend (Next.js 15):
- lib/types.ts — added Platform/PLATFORM_LABELS/GeneratedContent/
  PropertySummaryForAi
- lib/listings.ts — generateListing() wrapper
- components/forms/ListingPreview.tsx — 4 platform tabs + copy button +
  model badge
- components/forms/ListingPreview.test.tsx — 6 tests
- components/forms/PropertyForm.tsx — added '✨ Generate' button +
  previews ListingPreview when present + generation error handling

Verified locally:
- pytest: 83/83 ✅ (13 new from T-006)
- coverage on app/: 94%
- ruff + mypy strict: clean
- next lint + typecheck + build: clean, 9 routes
- vitest: 20/20 ✅ (6 new from T-006)
- curl E2E: signup → generate 4 platforms → all return Thai text,
  Facebook has 6 hashtags

* feat(T-007): generated listings persistence + property detail page

Backend (Python):
- app/domain/listing.py — GeneratedListingCreate + GeneratedListingUpdate
  + GeneratedListing (DB row); strict validation (extra='forbid', title ≥1)
- app/routers/listings.py — POST /api/listings, GET
  /api/listings?property_id=..., PATCH /api/listings/{id}, DELETE
  /api/listings/{id} (204). Cross-user returns 404 (not 403).
- app/main.py — register listings_router
- tests/test_listings.py — 12 tests covering round-trip,
  no-op PATCH, delete idempotency, auth gate, cross-user isolation,
  validation

Frontend (Next.js 15):
- lib/types.ts — SavedListing (extends GeneratedContent with id/created_at
  etc.) + SaveListingInput + UpdateListingInput. Widened property_type
  to string to match backend's permissive PropertySummary.
- lib/listings.ts — added listListingsForProperty, saveListing,
  updateListing, deleteListing
- components/forms/ListingEditor.tsx — tab-style editor per platform
  variant: editable title/description/hashtags/SEO; Save button tracks
  dirty state, shows 'Saved at HH:MM:SS' feedback, falls back to
  'Re-save' after first save
- components/forms/ListingEditor.test.tsx — 6 tests (rendering,
  platform label, dirty/save state)
- components/forms/PropertyForm.tsx — auto-save generated listings on
  property submission (sequential, errors logged not blocking)
- app/(app)/properties/[id]/page.tsx — detail page: property header
  (title, status pill, district/province, key fields, image strip),
  listings grid with one editor per platform variant, generation from
  detail (lazy import saveListing). Has loading/error/empty states.

Verified locally:
- pytest: 95/95 ✅ (12 new from T-007)
- coverage on app/: 94%
- ruff + mypy strict: clean
- next lint + typecheck + build: clean, 11 routes incl.
  /properties/[id] (dynamic)
- vitest: 26/26 ✅ (6 new from T-007)
- curl E2E: signup → property → 2 listings (POST 201) → list 2 →
  PATCH (updated title) → DELETE (204) → 401 without token

* feat(T-008): mock LINE adapter + signed webhook (HMAC-SHA256)

Backend (Python):
- app/adapters/line/base.py — LineAdapter Protocol + sign_line_webhook /
  verify_line_webhook helpers using hmac.compare_digest (constant-time).
  SIGNATURE_HEADER = 'X-Line-Signature'.
- app/adapters/line/mock.py — LineMockAdapter in-memory; sign() helper
  for tests
- app/adapters/line/real.py — LineRealAdapter stub (no HTTP yet;
  same sign/verify surface so the rest of the app is unaffected)
- app/adapters/line/_factory.py — get_line_adapter (mock when
  use_real_line=false)
- app/adapters/line/__init__.py — public re-exports
- app/routers/line_webhook.py — POST /webhook/line: reads RAW body
  bytes first, then signature, then verifies BEFORE JSON parsing.
  Verified + ack returns {'ok': true, 'received': N}. Failed
  verification returns 401 with NO DB writes. JSON parse fails → 400.
- app/main.py — register line_webhook_router
- tests/test_line_webhook.py — 11 tests:
    ST-009 valid signature → 200
    ST-010 invalid signature → 401
    missing signature → 401
    empty signature → 401
    body tampered after signing → 401
    signature from different secret → 401
    invalid JSON after signature passes → 400 (not 401)
    no DB writes on unverified request
    helper round-trip
    None signature rejected
    same signed payload twice → 200, 200 (idempotency in T-009)

Verified locally:
- pytest: 107/107 ✅ (12 new from T-008)
- coverage on app/: 94%
- ruff + mypy strict: clean
- python urllib smoke: valid → 200, invalid → 401, missing → 401

Security property: signature is verified against the raw request bytes
(hmac.compare_digest) BEFORE JSON parsing — a body-tampering attacker
cannot smuggle events past verification.

* feat(T-009): LINE → Lead + Message pipeline (idempotent)

Backend (Python):
- app/domain/lead.py — Lead DTO with from_row helper
- app/domain/message.py — Message DTO with from_row helper
- app/services/lead_pipeline.py — LeadPipeline service:
    • idempotency via event_id scan over messages.raw_data
    • find-or-create lead by line_user_id
    • insert inbound Message + raw_data (full event)
    • bump lead.updated_at on contact
    • never crashes on malformed payloads; returns ProcessResult
      with reason in {ok, replay, no_event_id, no_source, non_message}
- app/routers/line_webhook.py — now wires verified events through
  LeadPipeline. Agent lookup happens ONLY when events are non-empty.
    • uses settings.line_default_agent_id (env var) if set
    • falls back to first active user in mock mode (helpful for dev)
    • 503 only when events present + no agent config + no users
- app/config.py — added line_default_agent_id field
- .env.example — added LINE_DEFAULT_AGENT_ID
- tests/test_line_webhook.py — 8 new tests (T-008's 12 still pass):
    ST-011 replay of same event_id is ignored
    ST-012 two events from same user → one Lead, two Messages
    well-formed event creates lead + message
    non-message (follow) event ignored
    missing source → no_source
    missing event_id → no_event_id
    empty events array → 200 with received=0
    no-agent scenario → 503 (with events)
- Added autouse _isolate fixture to reset mock singleton between tests

Verified locally:
- pytest: 114/114 ✅
- coverage on app/: 94%
- ruff + mypy strict: clean
- python urllib E2E: signup → 2 events same LINE user → 1 lead, 2
  messages; replay same body → 0 processed (all reason='replay')

Behavioural properties:
- empty events → 200 (no agent needed)
- verified + has events + no agent → 503
- verified + has events + has agent (env or first user) → process
- duplicate event_id → skip with reason='replay'
- non-message / missing event_id / missing source → skip, do not crash

* feat(T-010): leads list/chat UI + outbound reply via mock LINE

Backend (Python):
- app/domain/lead.py — LeadStatus enum + LeadUpdate DTO (strict)
- app/adapters/line/base.py — added send_reply() to Protocol
- app/adapters/line/mock.py — added send_reply() records line_user_id +
  text + sent_at; keeps sent_replies list for tests
- app/adapters/line/real.py — send_reply() stub raises NotImplementedError
- app/deps.py — added LineDep + get_line_dep
- app/routers/leads.py — GET /api/leads (status filter, limit, ordered
  by updated_at desc), GET /api/leads/{id} (lead + messages ascending),
  PATCH /api/leads/{id} (status enum validated, extras forbid)
- app/routers/messages.py — POST /api/leads/{id}/messages
    • validates lead.user_id matches caller (404 on cross-user)
    • rejects leads without line_user_id (400)
    • calls line.send_reply() then inserts outbound Message with
      is_ai_generated=false; bumps lead.updated_at
- app/routers/line_webhook.py — switched to DBDep for test injectability
- app/main.py — register leads_router + messages_router
- tests/test_leads.py — 14 tests:
    list: scoped to caller / status filter / 401 without auth
    get: returns messages created-order / cross-user 404 / unknown 404
    patch: fields update / unknown status 422 / extras forbid 422
    reply: inserts outbound + calls line.send_reply + cross-user 404
           + 400 without line_user_id + 401 without auth + both directions

Frontend (Next.js 15):
- lib/types.ts — Lead, Message, LeadWithMessages; Thai LEAD_STATUS_LABELS
- lib/leads.ts — listLeads(opts), getLead, updateLead
- lib/messages.ts — sendReply(leadId, text)
- components/chat/MessageList.tsx — inbound (left card border) vs
  outbound (right emerald bubble), timestamp, agent/lead label
- components/chat/ComposeBox.tsx — controlled textarea + send button,
  disabled when empty, surfaces ApiError detail
- app/(app)/leads/page.tsx — list with status-pill filter row,
  empty state, lead row links to detail; Thai status badges
- app/(app)/leads/[id]/page.tsx — header (name, line_user_id, status,
  budget/interest/notes), MessageList + ComposeBox

Verified locally:
- pytest: 128/128 ✅ (14 new from T-010)
- coverage on app/: 94%
- ruff + mypy strict: clean
- next lint + typecheck + build: clean, 11 routes incl. /leads + /leads/[id]
- vitest: 26/26 ✅
- python urllib E2E: signup → webhook (2 leads) → list → get with
  messages → reply (201) → directions = ['inbound','outbound'] →
  PATCH status (200)

* feat(T-011): dashboard endpoint + agent home page

Backend (Python):
- app/routers/dashboard.py — GET /api/dashboard
    • new_leads_count: count of leads with status='new' for caller
    • recent_inbound: last 20 inbound messages, each enriched with
      lead preview (id, name, line_user_id)
    • recent_properties: last 5 non-archived properties, newest first
- app/main.py — register dashboard_router
- tests/test_dashboard.py — 8 tests (ST-013 + shape + auth + isolation):
    three blocks always present; new leads counted;
    lead preview attached; newest properties first; archived excluded;
    401 without auth; cross-user isolation (B sees no A's data);
    inbound capped at 20

Frontend (Next.js 15):
- lib/types.ts — DashboardData + DashboardInboundMessage + DashboardLeadPreview
- lib/dashboard.ts — getDashboard()
- components/dashboard/NewLeadsCounter.tsx — large emerald number,
  dims to muted-foreground when 0, links to /leads, Thai caption
- components/dashboard/RecentMessages.tsx — last 20 inbound items with
  truncate, time-ago label, link to /leads/[id]; Thai empty state
- components/dashboard/RecentProperties.tsx — reuses PropertyCard;
  empty state with CTA
- app/(app)/dashboard/page.tsx — replaces the placeholder; greeting
  + sign-out + the three sections; auto-refresh every 5s via setInterval;
  redirects to /login on 401

Verified locally:
- pytest: 136/136 ✅ (8 new from T-011)
- coverage on app/: 94%
- ruff + mypy strict: clean
- next lint + typecheck + build: clean, 11 routes incl. /dashboard (3.74 kB)
- vitest: 26/26 ✅
- python urllib E2E: empty dashboard → 2 leads + 3 inbound + 1
  active property (archived filtered out), newest first

* chore(aidlc): mark T-012 done — Month-1 MVP shipped

* feat(T-012): Playwright E2E + real_swap tests + docs + coverage gate

Backend:
- tests/test_real_swap.py — 6 tests gated by RUN_REAL_ADAPTER_TESTS=1
  • isinstance(RealSupabaseAdapter, SupabaseAdapter)
  • isinstance(RealAnthropicAdapter, AiAdapter)
  • isinstance(RealGeminiAdapter, AiAdapter)
  • isinstance(RealLineAdapter, LineAdapter)
  • isinstance(SupabaseStorageAdapter, StorageAdapter)
  • sign_line_webhook round-trip
- pyproject.toml — addopts gets --cov-fail-under=80
  (current coverage: 92.88% — passes the gate)

Frontend:
- playwright.config.ts — fullyParallel off, single worker, webServer
  starts backend in CI, baseURL configurable for frontend
- tests/e2e/happy-path.spec.ts — full happy path: UI signup →
  /dashboard → API creates property + 4 listings → UI logs in
  fresh → /properties/[id] → 4 editors visible → edit one + save
  → 'Saved at HH:MM:SS' feedback → sign out
- package.json — @playwright/test devDep + test:e2e script
- tsconfig.json — exclude playwright.config.ts + tests/e2e/
- vitest.config.ts — exclude tests/e2e/ from collection

Docs (shipped in docs/):
- architecture.md — layers, request lifecycles, state model,
  what is intentionally NOT here
- adapters.md — the 4-pair contract, mock→real switch matrix,
  per-adapter behaviour tables
- runbook.md — quick start, where things live, adding features,
  debugging recipes, production rollout, incident oncall

README rewritten with quick-start + doc map.

Verified locally:
- pytest: 136/136 (real_swap +1 pass + 5 skip without flag; all 6
  pass with RUN_REAL_ADAPTER_TESTS=1)
- coverage gate: 92.88% ≥ 80% ✅
- ruff + mypy strict: clean
- vitest: 26/26 ✅ (e2e excluded)
- next lint + typecheck + build: clean; e2e excluded from build

* fix(review): Tier-1 fixes from sub-agent codebase review

Driven by findings from 4 parallel review sub-agents (backend,
frontend, docs, adapter contract). 10 real bugs surfaced across all
three tiers; this commit resolves every Tier-1 finding.

## Backend

- **auth.py:_map_auth_error** — Drop UserNotFound from the union. The
  mapper is called by signup/login/liff which never raise
  UserNotFound; /me handles it explicitly with 404. Previously the
  same exception would map to two different status codes depending on
  caller — classic foot-gun.
- **auth.py:get_auth_service** — Switch from Depends(get_settings)
  (lru_cached global) to SettingsDep (per-request app.state). Tests
  passing Settings(...) to create_app() will now actually see their
  isolated Settings — no more latent test-isolation leak.
- **line_webhook.py** — Switch from request.app.state.settings inside
  the handler body to SettingsDep parameter. Same rationale.
- **dashboard.py:get_dashboard** — Replace N+1 db.get_by_id(leads, ...)
  loop with a single db.query(leads, ...) + dict lookup. Defense-in-
  depth: re-check user_id when enriching so a future cross-user
  message insert doesn't leak the lead preview fields.
- **line/base.py:LineAdapter protocol** — add send_reply() to the
  Protocol so it's discoverable; mocks/reals that lack it will fail
  isinstance() instead of TypeError'ing at runtime. Drops the
  '# type: ignore[attr-defined]' hack in messages.py.
- **ai/_factory.py, line/_factory.py, storage/_factory.py,
  supabase/_factory.py** — use_mocks=True is now the master switch,
  short-circuiting to <Mock> regardless of any USE_REAL_* flag.
  Documents the 'mocks win' semantics in module docstrings.
- **tests/adapters/test_mock_supabase.py** — updated
  test_factory_returns_real_when_flag_set to explicitly set
  use_mocks=False (master switch now requires it). Added
  test_factory_master_switch_overrides_real_flag to lock the
  semantics in.
- **coverage on routers/dashboard.py** jumped to 100% (new branches
  exercised).

## Frontend

- **components/forms/ImageUploader.tsx** — fix URL.createObjectURL leak:
  useEffect cleanup now runs on previews state change, not just
  unmount. Long editing sessions were leaking blob refs on every
  re-selection.
- **components/forms/ListingEditor.tsx** — fix dirty-after-save bug:
  track a lastSaved snapshot (not initial); on save, bump the
  snapshot so the next edit round trips correctly. Also handles
  parent re-fetch via useEffect-on-initial.
- **app/(app)/layout.tsx** — hoist auth gate from per-page into the
  layout itself. Closes the deep-link foot-gun: /properties/new and
  /properties/[id] now redirect to /login if no token is in
  localStorage, instead of rendering an empty form that fails on
  Save.
- **lib/types.ts** — add team_id (string | null) to Property and Lead
  so the frontend types match backend DTOs. extra='ignore' had been
  silently dropping them, breaking any future UI needing the field.
- **components/properties/PropertyCard.test.tsx** — added
  team_id: null to baseProperty fixture.

## Verified

- pytest: 138/138 ✅ (was 137; new master-switch test in
  test_mock_supabase)
- coverage: 92.42% ≥ 80% ✅
- ruff + mypy strict: clean
- vitest: 26/26 ✅
- next build: 11 routes, e2e excluded ✅

* fix(review-t2): Tier-2 review fixes (frontend behavior + missing tests)

Driven by the second wave of findings from the 4 sub-agent reviews.
Tier-3 doc cleanup lands in a separate commit.

## Backend

- app/routers/auth.py — TODO(security) comment above /api/auth/login
  flagging the missing rate limiter. Post-MVP follow-up; not part of
  Month-1 scope.
- app/adapters/supabase/_schema.py — promote _now() to now_iso(),
  the public helper. _now kept as a module-internal alias so the
  table definitions don't churn.
- app/adapters/supabase/mock.py — use the shared now_iso() instead of
  a duplicate _now() definition. Single source of truth — a future
  timezone-aware change reaches both places.

## Frontend behavior

- app/(app)/properties/[id]/page.tsx — drop the dead dynamic
  'await import("@/lib/listings")' (already imported at the top of
  the file). Also: the Generate / Regenerate button was previously
  disabled once any listings existed (no re-generate possible). Now
  only disabled while generating; shows '🔄 Regenerate' once saved.
- components/forms/ListingEditor.tsx — TypeScript now narrows
  'initial.description' correctly (extra guard for nullable).
- web/lib/api.ts — drop the module-level cachedToken mutable.
  readToken() now reads localStorage on every call. The cache was
  an architectural leftover that caused StrictMode double-render
  races and one-component-clears-another bugs. Cost: ~10 µs per
  fetch — invisible.
- web/lib/api.ts + lib/{auth,dashboard}.ts — apiGet/apiPost/apiPatch
  accept a {signal?: AbortSignal} option so call sites can abort.
- app/(app)/dashboard/page.tsx — Polling loop now uses AbortController
  per tick so a slow backend doesn't cause overlapping in-flight
  fetches. Also catches + swallows DOMException('AbortError') so
  aborts don't surface as 'Failed to load dashboard' errors.
- app/(app)/leads/page.tsx — filter row uses role='group' +
  aria-pressed instead of the misleading role='tablist' + aria-selected
  pattern (these are filter chips, not tabs).

## Missing tests (the bulk of T2)

- components/forms/ImageUploader.test.tsx — 4 tests: empty state,
  onFilesChange fires with images only (filters out non-images),
  revokeObjectURL is called on remove. jsdom doesn't ship URL.createObjectURL
  / revokeObjectURL; we polyfill via Object.defineProperty.
- components/forms/ComposeBox.test.tsx — 4 tests: disabled-empty,
  send happy path + textarea clears, two error surfaces (real ApiError
  → backend detail; non-ApiError → 'Send failed' fallback).
- __tests__/dashboard.test.tsx — 2 tests (ST-014): all three
  sections render with full payload; counter dims when new_leads_count=0.
- components/dashboard/RecentProperties.tsx — added
  data-testid='recent-properties' on the <ul> so the dashboard test
  can assert on it.

## Verified

- pytest: 138/138 ✅ (still 92% coverage on app/)
- ruff + mypy strict: clean
- vitest: 36/36 ✅ (was 26; +10 new tests across 3 files)
- next lint + tsc + build: clean, 11 routes

* docs(review-t3): accuracy fixes — docs match shipped code

Third commit from the 4-sub-agent code review. Doc-only changes that
bring README.md / docs/ / .aidlc/ in line with the code that's been
landing in T-001..T-012 + Tier-1/2 review fixes.

## .aidlc/state.md
- Updated final stats: 19 commits, +20,716 lines, 132 files (was 17
  commits, +19,451 lines, 124 files).
- Test counts: 138 passed, 36 vitest, 92.29% coverage (was 137/26,
  92.88%).
- Added a 'Tier-1 + Tier-2 review fixes applied' note pointing to the
  two review commits.

## docs/runbook.md
- Quick-start now shows the right numbers: 138 passed / 142 collected,
  92.29% coverage, 36 vitest (was '~136 tests', 26 vitest).
- 'Where things live' adds a row for property/listing fields (cross-
  cutting backend + frontend edit).
- 'Coverage gate' section cites the actual 92.29% (was the wrong
  94%) and names the gate command verbatim.

## docs/architecture.md
- Layer diagram:
    * Drops (marketing) (the route group never existed; landing
      lives at app/page.tsx).
    * lib/ now lists all 10 files (was 8).
    * Routers/services/deps blocks all match the actual structure.
    * 'USE_MOCKS is the master switch' called out (was just
      'flips in via env flags').
    * Contract file layout corrected: '<real>.py' with a note that
      naming isn't uniform (supabase/line say 'real.py'; AI & Storage
      are per-provider; factories are uniform regardless).

## docs/adapters.md
- USE_MOCKS doc rewritten as 'master switch — when true, mocks win
  even if individual USE_REAL_* flags are set' (now matches the
  post-Tier-1 factory semantics).
- T-009 reference ('Required for T-009's webhook') generalised to
  'Required for the LINE webhook (real LINE is multi-tenant)'.
- Contract layout schema in the docs matches the actual filename
  pattern (with the same uniformity note as architecture.md).
- 'tests/test_real_swap.py' wording drops the dead 'sign_ai_request'
  helper reference (the helper doesn't exist — was a copy-paste).
- The signing helpers used in the test now correctly named:
  sign_line_webhook / verify_line_webhook.

## README.md
- Quick-start test count corrected: 'npm test # vitest — 36 tests'.
- Deploy row in tech-stack table references 'runbook + rollout
  checklist in docs/runbook.md'.

## .aidlc/spec.md
- Removed the spec-time fictional file references that never landed:
    * (marketing)/page.tsx     → app/page.tsx (landing at root)
    * web/vercel.json           → not shipped (config deferred to deploy)
    * backend/Dockerfile        → not shipped
    * backend/railway.toml      → not shipped
      (replaced with a one-line 'Dockerfile + railway.toml deferred
      to deploy phase' note)
- router error-mapping promise fixed: was
  'app/main.py::register_exception_handlers' (does not exist); now
  'app/routers/<name>.py::_map_*_error helpers; per-router' (matches
  actual implementation).

## Verification

- backend pytest: 138/138 ✅ (92.29% coverage, gate ≥ 80%)
- ruff + mypy strict: clean
- frontend vitest: 36/36 ✅
- frontend next lint + tsc + build: clean
- grep 'marketing|register_exception_handlers|Dockerfile|railway.toml|vercel.json|sign_ai_request' docs/ .aidlc/ README.md:
  → only intentional parenthetical mention of the deferred
    Dockerfile/railway.toml remains.

* chore(state): bump stats after Tier-3 docs cleanup

* feat(line): hermes-agent#23197 takeaways — body cap, outbound transforms, real-adapter bones

Driven by review of NousResearch/hermes-agent#23197 (1638 LOC LINE
plugin) against our mocks-first FastAPI implementation. Doc at
docs/line-integration-gap-analysis.md walks the full diff and
explains what's worth porting for our Thai real-estate AI agent.

This PR lands the four highest-leverage fixes:

1. **Webhook body cap (1 MiB).** Memory-exhaustion guard
   rejecting oversized payloads with 413 BEFORE the signature
   check. Constant from app.adapters.line.base so both adapters
   and the router share it.

2. **Outbound Markdown stripper** (app.adapters.line.base).
   LINE can't render Markdown reliably across iOS/Android/web/
   macOS clients. strip_markdown() removes ATX headings,
   bold/italic (*/_/__), inline code + code fences, leading list
   bullets, and blockquote markers while leaving bare URLs
   untouched. Used by future real wiring; tested on the mock.

3. **LINE 5-message / 4500-char chunker**
   (split_for_line()). Per LINE Messaging API docs each
   bubble caps at 5000 chars and each Reply/Push call caps at
   5 message objects. Naive splitter: paragraph boundaries first,
   then Thai 。/Western .  sentence boundaries, hard
   cut as last resort. Capped at LINE_MAX_MESSAGES_PER_CALL (5).
   Tested with 11 cases including the Thai sentence terminator.

4. **LineRealAdapter: bot user-id cache + reply-token cache +
   self-message filter stub.** When the real HTTP wiring ships,
   these are the bones it will use:
     - bot_user_id: str | None  set at __init__ (auto-fetched
       from GET /v2/bot/info when wiring lands)
     - _reply_tokens: dict[chat_id, (token, expires_at)]  with
       set_reply_token() + consume_reply_token() (Reply
       tokens are single-use; ~60s TTL; expire-test covered)
     - send_reply() short-circuits with
       skipped='self-message' when line_user_id ==
       bot_user_id — prevents the inbound-outbound echo loop
       that hits any production bot without this filter.
   send_reply() still raises NotImplementedError for real
   sends; the doc-string in the method lists the exact
   strip_markdown → split_for_line → consume_reply_token →
   Reply-or-Push order the eventual wiring will follow.

## Tests

- backend/tests/test_line_helpers.py — 28 new tests
  (TestStripMarkdown × 11, TestSplitForLine × 8,
  TestWebhookBodyCap × 1, TestLineRealAdapterStructure × 8)
- backend/tests/test_line_webhook.py — 2 new tests
  (oversized → 413, exactly-at-cap → 400 to prove  boundary)

## Verified

- pytest: 168/168 (was 138; +30 new) — coverage 92.88% ≥ 80% ✅
- RUN_REAL_ADAPTER_TESTS=1 pytest tests/test_real_swap.py: 6/6 ✅
  (real adapter isinstance checks now also cover the new
  set_reply_token / consume_reply_token surface)
- ruff + mypy strict: clean
- Frontend unchanged (lib/api.ts: 36/36 vitest still passing)

## Out of scope (logged in docs/line-integration-gap-analysis.md)

- Three-allowlist gating: N/A for single-tenant MVP
- Media inbound (image/audio/video/file/sticker/location): defer
  until listing creation needs inbound photos
- Media SEND with HTTPS serving: defer; we have
  /api/upload-image as the upload path
- Slow-LLM postback button (their headline feature): N/A, we
  don't run an async-streaming LLM
- Loading indicator / typing animation: defer
- accountLink / memberJoined / things: defer (no LIFF, no IoT)
- unsend event handling: 1-migration scope, defer to follow-up

* fix(line): P0 review fixes — protocol bones, mock↔real parity, doc lies

Applies all 4 P0 findings from the PR #2 review. C1+C2 are the load-bearing
ones; C3+C4 are doc/comment accuracy.

## C1 — Mock applies the same outbound transforms as the real adapter

Previously LineMockAdapter.send_reply recorded text verbatim.
When real wiring lands and the real adapter strips **bold, headings,
code fences, etc.** + chunks at the 5-message cap, mock and real
diverge on what LINE would actually receive.

The mock now mirrors the real adapter's outbound path:

  strip_markdown(text) \u2192 split_for_line(cleaned) \u2192 record each chunk
  with chunk_index 0..N-1.

Tests in test_line_helpers.py::TestLineMockAdapterOutboundParity
verify that **bold / *italic* are stripped, long text is chunked, and
the chunk count respects LINE_MAX_MESSAGES_PER_CALL=5.

Reply-token routing: tokens are single-use. The mock now consumes the
token on first send_reply so a second send to the same chat falls
back to push (matching real behavior). Tests verify mode='reply'
on the first call and mode='push' on the second.

## C2 \u2014 Reply-token cache + bot_user_id live on the Protocol

Previously the bones sat only on LineRealAdapter and the webhook
couldn't reach them without an isinstance() check \u2014 breaking the
4-adapter contract ("no router imports a concrete class by name").

Both bot_user_id and set_reply_token(chat_id, token) are now
on the LineAdapter Protocol. Both the mock and the real adapter
implement them. The webhook takes a LineDep and calls
line.set_reply_token(chat_id, reply_token) on every inbound
message event with a replyToken field.

The router has a new _cache_reply_token helper that:

  - Reads replyToken from the event
  - Resolves chat_id from source.userId / groupId / roomId
  - Silently skips when neither is set (defensive against malformed events)

Tests in test_line_webhook.py cover:

  - Inbound message with replyToken \u2192 cache populated
  - Inbound follow/unfollow/etc. \u2192 cache stays empty (no replyToken)
  - set_reply_token consumes on first send_reply (Reply mode)
  - Second send_reply without re-caching falls back to Push (mode='push')

The TTL constant REPLY_TOKEN_TTL_SECONDS = 60 was moved from
real.py to base.py so the mock and real share it.

## C3 \u2014 Doc lies

Two doc lies fixed:

- TL;DR table at the top of docs/line-integration-gap-analysis.md
  marked unsend event handling as **"Fix in this PR"** \u2014 it
  wasn't. Now correctly marked **"Defer (1-migration scope; not in
  this PR)"**.

- The "TL;DR for the PR description" block listed unsend as one
  of the 4 things shipped \u2014 it wasn't. Now lists the actual 4
  (body-cap, transforms, self-message + reply-token, LineDep wiring).

The "Verdict + suggested action" section (line 239) already said
"defer" correctly; only the two stale bits are now consistent with it.

## C4 \u2014 aiohttp comment was a copy-paste from hermes-agent

backend/app/routers/line_webhook.py:33 had a comment claiming
"aiohttp's client_max_size doesn't apply in all body modes". This
project uses FastAPI + Starlette + uvicorn, NOT aiohttp \u2014 the
reference was a copy-paste from the source PR.

Rewrote the comment to reference Starlette's Request.body() and
mention uvicorn's h11_max_incomplete_event_size for nginx-fronted
deployments. The 1 MiB cap (line 41) is now correctly described as
memory-exhaustion defence for Starlette's unbounded buffer.

## Verified

- pytest: 184/184 (was 168; +16 new) \u2014 coverage 92.99% \u2265 80% \u2705
- RUN_REAL_ADAPTER_TESTS=1: 6/6 \u2705 (Protocol conformance still
  holds after bones promotion; isinstance(LineRealAdapter,
  LineAdapter) + isinstance(LineMockAdapter, LineAdapter) both
  return True)
- ruff + mypy strict: clean

## Out of scope

- strip_markdown leaks content from code spans (Backend warning) \u2014
  noted, follow-up
- _MD_ITALIC_STAR matches arithmetic (Backend warning) \u2014 noted,
  follow-up
- self-message filter default-off (Backend warning) \u2014 real wiring
  must set bot_user_id; documented
- chat_id / line_user_id reconciliation \u2014 currently same in DMs;
  groups deferred to when we add group support
- unsend event handling \u2014 1-migration, deferred (correctly noted in
  C3 fix)

* fix(line): deduplicate REPLY_TOKEN_TTL_SECONDS

The P0 fix on the previous commit added REPLY_TOKEN_TTL_SECONDS to
app.adapters.line.base, but left the same constant re-defined in
app.adapters.line.real (line 25-27). Behavior was correct (both
values are 60) but the dedup wasn't actually a dedup — real.py
imported nothing from base for the constant.

Removes the local definition in real.py and adds the constant to
the existing import block from base. No behavior change.
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…3197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR NousResearch#21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR NousResearch#18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR NousResearch#18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR NousResearch#18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR NousResearch#8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942,
NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR NousResearch#23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: LINE Messaging Platform Adapter

3 participants