Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 80 additions & 12 deletions gateway/kanban_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from pathlib import Path
from typing import Any, Callable, Optional

from agent.i18n import t

# Match the logger run.py uses (logging.getLogger(__name__) where __name__ ==
# "gateway.run") so extracted log records keep their original logger name.
logger = logging.getLogger("gateway.run")
Expand Down Expand Up @@ -160,7 +162,9 @@ async def _kanban_notifier_watcher(self, interval: float = 5.0) -> None:
logger.warning("kanban notifier: kanban_db not importable; notifier disabled")
return

TERMINAL_KINDS = ("completed", "blocked", "gave_up", "crashed", "timed_out")
# "status" covers dashboard drag-drop and `_set_status_direct()`
# writes — surface those transitions to subscribers too.
TERMINAL_KINDS = ("completed", "blocked", "gave_up", "crashed", "timed_out", "status", "archived", "unblocked")
# Subscriptions are removed only when the task reaches a truly final
# status (done / archived). We used to also unsub on any terminal
# event kind (gave_up / crashed / timed_out / blocked), but that
Expand Down Expand Up @@ -250,11 +254,13 @@ def _collect():
for sub in subs:
owner_profile = sub.get("notifier_profile") or None
if owner_profile and owner_profile != notifier_profile:
logger.debug(
"kanban notifier: subscription for %s owned by profile %s; current profile %s skipping",
sub.get("task_id"), owner_profile, notifier_profile,
)
continue
_owner_adapters = getattr(self, "_profile_adapters", {}).get(owner_profile)
if not _owner_adapters:
logger.debug(
"kanban notifier: subscription for %s owned by profile %s; current profile %s has no adapter for it, skipping",
sub.get("task_id"), owner_profile, notifier_profile,
)
continue
Comment on lines 256 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Kanban notification adapter fallback leaks profile isolation in multiplexed gateway (security)

In gateway/kanban_watchers.py, the _kanban_notifier_watcher method's _collect closure resolves which adapter delivers kanban notifications. For cross-profile subscriptions, the code at lines 255-263 guards against attempting delivery when the notifier profile has no adapters for the owner profile:

owner_profile = sub.get("notifier_profile") or None
if owner_profile and owner_profile != notifier_profile:
    _owner_adapters = getattr(self, "_profile_adapters", {}).get(owner_profile)
    if not _owner_adapters:
        continue  # skip
platform = (sub.get("platform") or "").lower()  # line 264, AFTER guard

The bug: The guard only checks that _owner_adapters is truthy (has ANY adapters), not that the specific platform's adapter exists within it. If profile B has a Discord adapter connected but NOT Telegram, and a subscription wants Telegram delivery, the guard passes (profile B's adapter map is truthy), and the delivery proceeds.

Later, in the delivery loop (lines 313-320), the adapter is resolved:

adapter = None
if sub_profile:
    _profile_map = getattr(self, "_profile_adapters", {}).get(sub_profile)
    if _profile_map:
        adapter = _profile_map.get(plat)
if adapter is None:
    adapter = self.adapters.get(plat)  # FALLBACK: default profile's adapter

Since _profile_map.get(plat) returns None (profile B has no Telegram adapter), the fallback at line 320 uses the default profile's Telegram adapter. The notification — including task title, status, assignee, result summary, and any artifacts — is delivered through the wrong profile's adapter, leaking task information across profile boundaries. The subsequent wakeup injection (lines 505-521) further creates a SessionSource with the subscription's profile and calls adapter.handle_message() on the wrong adapter.

Impact: In a multiplexed gateway serving multiple profiles, kanban task notifications and results leak from one profile into another profile's messaging channels.

💡 Suggestion: Tighten the early-skip guard in _collect() to also verify the specific platform adapter exists in the owner's profile map. Move the platform string extraction above the guard, parse it into a Platform enum, and check plat in _owner_adapters. This prevents cross-profile subscriptions from proceeding to delivery when the required platform adapter isn't available in the owner profile's adapter set.

📋 Prompt for AI Agents

In gateway/kanban_watchers.py, in the _kanban_notifier_watcher method, the _collect closure around lines 255-264, restructure the cross-profile guard to also check for the specific platform adapter:

  1. Move platform = (sub.get("platform") or "").lower() (currently line 264) ABOVE the profile guard block (before line 255).

  2. In the profile guard block (lines 256-263), after verifying _owner_adapters exists, parse the platform string into a Platform enum and check it's present:

owner_profile = sub.get("notifier_profile") or None
if owner_profile and owner_profile != notifier_profile:
    _owner_adapters = getattr(self, "_profile_adapters", {}).get(owner_profile)
    if not _owner_adapters:
        logger.debug(...)
        continue
    try:
        _owner_plat = _Platform(platform)
    except ValueError:
        continue
    if _owner_plat not in _owner_adapters:
        logger.debug(
            "kanban notifier: subscription for %s owned by profile %s on %s; no adapter for that platform, skipping",
            sub.get("task_id"), owner_profile, platform,
        )
        continue

This ensures cross-profile subscriptions are only processed when the notifier profile has the SPECIFIC platform adapter for the subscription owner.

platform = (sub.get("platform") or "").lower()
if platform not in active_platforms:
logger.debug(
Expand Down Expand Up @@ -304,7 +310,14 @@ def _collect():
self._kanban_advance, sub, d["cursor"], board_slug,
)
continue
adapter = self.adapters.get(plat)
sub_profile = sub.get("notifier_profile") or ""
adapter = None
if sub_profile:
_profile_map = getattr(self, "_profile_adapters", {}).get(sub_profile)
if _profile_map:
adapter = _profile_map.get(plat)
if adapter is None:
adapter = self.adapters.get(plat)
if adapter is None:
logger.debug(
"kanban notifier: adapter %s disconnected before delivery for %s; rewinding claim",
Expand All @@ -319,6 +332,7 @@ def _collect():
)
continue
title = (task.title if task else sub["task_id"])[:120]
board_tag = f"[{board_slug}] " if board_slug else ""
for ev in d["events"]:
kind = ev.kind
# Identity prefix: attribute terminal pings to the
Expand All @@ -345,35 +359,40 @@ def _collect():
r = lines[0][:160] if lines else task.result[:160]
handoff = f"\n{r}"
msg = (
f"✔ {tag}Kanban {sub['task_id']} done"
f"✔ {board_tag}{tag}Kanban {sub['task_id']} done"
f" — {title}{handoff}"
)
elif kind == "blocked":
reason = ""
if ev.payload and ev.payload.get("reason"):
reason = f": {str(ev.payload['reason'])[:160]}"
msg = f"⏸ {tag}Kanban {sub['task_id']} blocked{reason}"
msg = f"⏸ {board_tag}{tag}Kanban {sub['task_id']} blocked{reason}"
elif kind == "gave_up":
err = ""
if ev.payload and ev.payload.get("error"):
err = f"\n{str(ev.payload['error'])[:200]}"
msg = (
f"✖ {tag}Kanban {sub['task_id']} gave up "
f"✖ {board_tag}{tag}Kanban {sub['task_id']} gave up "
f"after repeated spawn failures{err}"
)
elif kind == "crashed":
msg = (
f"✖ {tag}Kanban {sub['task_id']} worker crashed "
f"✖ {board_tag}{tag}Kanban {sub['task_id']} worker crashed "
f"(pid gone); dispatcher will retry"
)
elif kind == "timed_out":
limit = 0
if ev.payload and ev.payload.get("limit_seconds"):
limit = int(ev.payload["limit_seconds"])
msg = (
f"⏱ {tag}Kanban {sub['task_id']} timed out "
f"⏱ {board_tag}{tag}Kanban {sub['task_id']} timed out "
f"(max_runtime={limit}s); will retry"
)
elif kind == "status":
new_status = ""
if ev.payload and ev.payload.get("status"):
new_status = str(ev.payload["status"])
msg = f"🔄 {board_tag}{tag}Kanban {sub['task_id']} → {new_status}"
Comment on lines 361 to +395

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Markdown injection in kanban notification messages via unescaped user-controlled fields (security)

The _kanban_notifier_watcher method builds notification messages using f-strings that embed user-controlled values without Markdown escaping:

  • title (line 334): extracted from task.title — a user-controlled field set via the kanban dashboard PATCH API.
  • reason (line 368): from ev.payload['reason'] — the block reason set by a kanban worker or user.
  • error (line 373): from ev.payload['error'] — error text from task execution.
  • new_status (line 394): from ev.payload['status'] — status changes from dashboard drag-drop.

These values are interpolated into f-strings at lines 361-395 and sent to messaging platforms via adapter.send() (line 406). Platforms like Telegram, Discord, and Slack render Markdown in messages. An attacker who can influence the task title (e.g., by PATCHing a task through the kanban dashboard API) could inject Markdown formatting — bold, italic, strikethrough, or clickable links — into notification messages delivered to other users subscribed to the task.

Example attack: Setting a task title to *URGENT* click [here](https://evil.com) to approve renders as formatted bold text with a clickable phishing link in the notification delivered to all subscribers.

Data flow: plugin_api.py PATCH /tasks/:id → kanban DB → kanban_watchers.py:281 get_task()line 334 title extractionlines 361-395 f-string interpolationline 406 adapter.send().

💡 Suggestion: Escape Markdown-special characters in user-controlled values (title, reason, error, new_status) before interpolating them into notification messages. Apply Markdown escaping appropriate for each target platform via the adapter's message formatting utilities, or use a shared Markdown-escaping helper. At minimum, escape the characters *_[]()~>#+-=|{}.!` to prevent formatting injection.

📋 Prompt for AI Agents

In gateway/kanban_watchers.py, around lines 361-395, before interpolating user-controlled values into notification f-strings, apply Markdown escaping. For each value that originates from user input (title, reason, error, new_status), pass it through a Markdown escaping function. For Telegram (MarkdownV2), escape _*[]()~>#+-=|{}.!with a preceding backslash. For other platforms, apply their appropriate escaping. A simple approach: define a helper_escape_md(s: str) -> strthat escapes the common Markdown special characters, and wrap each user-value before interpolation:title=_escape_md(title), reason=_escape_md(reason)`, etc.

else:
continue
Comment on lines 167 to 397

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Missing message handlers for 'archived' and 'unblocked' event kinds in kanban notification dispatch (bug)

The TERMINAL_KINDS tuple at line 167 was expanded to include 'archived' and 'unblocked' event kinds, causing claim_unseen_events_for_sub (line 271) to fetch these events from the kanban event queue and atomically advance the cursor past them. However, the per-event message formatting block (lines 343-397) handles only six kinds: 'completed', 'blocked', 'gave_up', 'crashed', 'timed_out', and 'status'. Both 'archived' and 'unblocked' fall through to the else: continue at lines 396-397, producing no notification message and making no adapter.send() call.

After the loop completes (via the for...else at line 467, which triggers since no break occurred), the cursor is advanced at lines 471-472, permanently marking these events as delivered. The subscriber never learns about the task archive or unblock. This is a mismatch between the event kinds the notifier claims and the kinds it knows how to format.

Evidence from independent scanners: both the correctness scanner and the domain-kanban-workflow scanner independently identified this gap.

💡 Suggestion: Add message formatting handlers for 'archived' and 'unblocked' event kinds before the else: continue at line 396. For example: elif kind == 'archived': msg = f'📦 {board_tag}{tag}Kanban {sub["task_id"]} archived' and elif kind == 'unblocked': msg = f'▶ {board_tag}{tag}Kanban {sub["task_id"]} unblocked'. Alternatively, if notification for these kinds is not intended, remove them from TERMINAL_KINDS at line 167 to avoid silently consuming events.

📋 Prompt for AI Agents

In gateway/kanban_watchers.py, in the message dispatch loop around lines 391-396, add two new elif branches before the else: continue:

elif kind == "archived":
    msg = f"📦 {board_tag}{tag}Kanban {sub['task_id']} archived"
elif kind == "unblocked":
    msg = f"▶ {board_tag}{tag}Kanban {sub['task_id']} unblocked"

If these event kinds should NOT produce user-visible notifications, instead remove 'archived' and 'unblocked' from the TERMINAL_KINDS tuple at line 167. The current state of claiming the events without delivering any notification is the worst of both options.

metadata: dict[str, Any] = {}
Expand Down Expand Up @@ -460,6 +479,55 @@ def _collect():
# same state. See the longer comment on TERMINAL_KINDS
# above for the failure mode this prevents.
task_terminal = task and task.status in {"done", "archived"}
_WAKE_KINDS = ("completed", "gave_up", "crashed", "timed_out", "blocked")
_wake_kinds = {ev.kind for ev in d["events"] if ev.kind in _WAKE_KINDS}
if _wake_kinds:
try:
_session_key = getattr(task, "session_id", None) or ""
if _session_key:
_title = (task.title if task else sub["task_id"])[:120]
_assignee = task.assignee if task else ""
_parts = []
if "completed" in _wake_kinds: _parts.append(t("gateway.kanban.wake.completed"))
if "gave_up" in _wake_kinds: _parts.append(t("gateway.kanban.wake.gave_up"))
if "crashed" in _wake_kinds: _parts.append(t("gateway.kanban.wake.crashed"))
if "timed_out" in _wake_kinds: _parts.append(t("gateway.kanban.wake.timed_out"))
if "blocked" in _wake_kinds: _parts.append(t("gateway.kanban.wake.blocked"))
_status = t("gateway.kanban.wake.status_joiner").join(_parts) or t("gateway.kanban.wake.status_default")
_synth = t(
"gateway.kanban.wake.message",
task_id=sub["task_id"],
status=_status,
title=_title,
assignee=_assignee,
board=board_slug,
)
from gateway.session import SessionSource
from gateway.platforms.base import MessageEvent, MessageType
_source = SessionSource(
platform=plat,
chat_id=sub["chat_id"],
chat_type="group",
thread_id=sub.get("thread_id") or None,
user_id=sub.get("user_id"),
profile=sub_profile or None,
)
_synth_event = MessageEvent(
text=_synth,
message_type=MessageType.TEXT,
source=_source,
internal=True,
)
await adapter.handle_message(_synth_event)
logger.info(
"kanban notifier: woke agent for %s on %s/%s profile=%s events=%s",
sub["task_id"], platform_str, sub["chat_id"], sub_profile or "default", _wake_kinds,
)
except Exception as _wk_err:
logger.debug(
"kanban notifier: wakeup injection failed for %s: %s",
sub["task_id"], _wk_err,
)
if task_terminal:
await asyncio.to_thread(
self._kanban_unsub, sub, board_slug,
Expand Down
1 change: 1 addition & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -14134,6 +14134,7 @@ def _set_session_env(self, context: SessionContext) -> list:
user_name=str(context.source.user_name) if context.source.user_name else "",
session_key=context.session_key,
message_id=str(context.source.message_id) if context.source.message_id else "",
profile=getattr(context.source, "profile", "") or "",
async_delivery=_async_delivery,
)

Expand Down
6 changes: 6 additions & 0 deletions gateway/session_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def session_context_engaged() -> bool:
# private-chat topic (those lanes route only with thread id + reply anchor).
_SESSION_MESSAGE_ID: ContextVar = ContextVar("HERMES_SESSION_MESSAGE_ID", default=_UNSET)

_SESSION_PROFILE: ContextVar = ContextVar("HERMES_SESSION_PROFILE", default=_UNSET)

# Whether the current session's delivery channel can route an ASYNC completion
# back to the agent AFTER the current turn ends (i.e. wake a fresh turn).
#
Expand Down Expand Up @@ -122,6 +124,7 @@ def session_context_engaged() -> bool:
"HERMES_SESSION_KEY": _SESSION_KEY,
"HERMES_SESSION_ID": _SESSION_ID,
"HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID,
"HERMES_SESSION_PROFILE": _SESSION_PROFILE,
"HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM,
"HERMES_CRON_AUTO_DELIVER_CHAT_ID": _CRON_AUTO_DELIVER_CHAT_ID,
"HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID,
Expand Down Expand Up @@ -154,6 +157,7 @@ def set_session_vars(
session_key: str = "",
session_id: str = "",
message_id: str = "",
profile: str = "",
cwd: str = "",
async_delivery: bool = True,
) -> list:
Expand Down Expand Up @@ -188,6 +192,7 @@ def set_session_vars(
_SESSION_KEY.set(session_key),
_SESSION_ID.set(session_id),
_SESSION_MESSAGE_ID.set(message_id),
_SESSION_PROFILE.set(profile),
_SESSION_ASYNC_DELIVERY.set(bool(async_delivery)),
]
try:
Expand Down Expand Up @@ -221,6 +226,7 @@ def clear_session_vars(tokens: list) -> None:
_SESSION_KEY,
_SESSION_ID,
_SESSION_MESSAGE_ID,
_SESSION_PROFILE,
):
var.set("")
# Reset async-delivery capability to the "never set" sentinel rather than a
Expand Down
9 changes: 9 additions & 0 deletions locales/af.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ gateway:
subscribed_suffix: "(ingeteken — jy sal in kennis gestel word wanneer {task_id} voltooi of vasval)"
truncated_suffix: "… (afgekap; gebruik `hermes kanban …` in jou terminale vir volle uitvoer)"
no_output: "(geen uitvoer)"
wake:
completed: "completed"
gave_up: "gave up (retries exhausted)"
crashed: "crashed (worker exited); dispatcher will retry"
timed_out: "timed out; dispatcher will retry"
blocked: "blocked; needs attention"
status_default: "status changed"
status_joiner: ", "
message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step."

personality:
none_configured: "Geen persoonlikhede opgestel in `{path}/config.yaml` nie"
Expand Down
9 changes: 9 additions & 0 deletions locales/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ gateway:
subscribed_suffix: "(abonniert — Sie werden benachrichtigt, wenn {task_id} abgeschlossen oder blockiert wird)"
truncated_suffix: "… (gekürzt; verwenden Sie `hermes kanban …` im Terminal für die vollständige Ausgabe)"
no_output: "(keine Ausgabe)"
wake:
completed: "completed"
gave_up: "gave up (retries exhausted)"
crashed: "crashed (worker exited); dispatcher will retry"
timed_out: "timed out; dispatcher will retry"
blocked: "blocked; needs attention"
status_default: "status changed"
status_joiner: ", "
message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step."

personality:
none_configured: "Keine Persönlichkeiten in `{path}/config.yaml` konfiguriert"
Expand Down
9 changes: 9 additions & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ gateway:
subscribed_suffix: "(subscribed — you'll be notified when {task_id} completes or blocks)"
truncated_suffix: "… (truncated; use `hermes kanban …` in your terminal for full output)"
no_output: "(no output)"
wake:
completed: "completed"
gave_up: "gave up (retries exhausted)"
crashed: "crashed (worker exited); dispatcher will retry"
timed_out: "timed out; dispatcher will retry"
blocked: "blocked; needs attention"
status_default: "status changed"
status_joiner: ", "
message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step."

personality:
none_configured: "No personalities configured in `{path}/config.yaml`"
Expand Down
9 changes: 9 additions & 0 deletions locales/es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ gateway:
subscribed_suffix: "(suscrito — recibirás una notificación cuando {task_id} termine o se bloquee)"
truncated_suffix: "… (truncado; usa `hermes kanban …` en tu terminal para la salida completa)"
no_output: "(sin salida)"
wake:
completed: "completed"
gave_up: "gave up (retries exhausted)"
crashed: "crashed (worker exited); dispatcher will retry"
timed_out: "timed out; dispatcher will retry"
blocked: "blocked; needs attention"
status_default: "status changed"
status_joiner: ", "
message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step."

personality:
none_configured: "No hay personalidades configuradas en `{path}/config.yaml`"
Expand Down
9 changes: 9 additions & 0 deletions locales/fr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ gateway:
subscribed_suffix: "(abonné — vous serez notifié lorsque {task_id} se terminera ou sera bloqué)"
truncated_suffix: "… (tronqué ; utilisez `hermes kanban …` dans votre terminal pour la sortie complète)"
no_output: "(aucune sortie)"
wake:
completed: "completed"
gave_up: "gave up (retries exhausted)"
crashed: "crashed (worker exited); dispatcher will retry"
timed_out: "timed out; dispatcher will retry"
blocked: "blocked; needs attention"
status_default: "status changed"
status_joiner: ", "
message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step."

personality:
none_configured: "Aucune personnalité configurée dans `{path}/config.yaml`"
Expand Down
9 changes: 9 additions & 0 deletions locales/ga.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ gateway:
subscribed_suffix: "(síntiúsaithe — cuirfear in iúl duit nuair a chríochnóidh nó a stopfaidh {task_id})"
truncated_suffix: "… (giorraithe; úsáid `hermes kanban …` i do theirminéal le haghaidh aschur iomláin)"
no_output: "(gan aschur)"
wake:
completed: "completed"
gave_up: "gave up (retries exhausted)"
crashed: "crashed (worker exited); dispatcher will retry"
timed_out: "timed out; dispatcher will retry"
blocked: "blocked; needs attention"
status_default: "status changed"
status_joiner: ", "
message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step."

personality:
none_configured: "Níl aon phearsantachtaí cumraithe in `{path}/config.yaml`"
Expand Down
9 changes: 9 additions & 0 deletions locales/hu.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ gateway:
subscribed_suffix: "(feliratkozva — értesítést kapsz, ha a {task_id} befejeződik vagy elakad)"
truncated_suffix: "… (csonkítva; használd a `hermes kanban …` parancsot a terminálban a teljes kimenethez)"
no_output: "(nincs kimenet)"
wake:
completed: "completed"
gave_up: "gave up (retries exhausted)"
crashed: "crashed (worker exited); dispatcher will retry"
timed_out: "timed out; dispatcher will retry"
blocked: "blocked; needs attention"
status_default: "status changed"
status_joiner: ", "
message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step."

personality:
none_configured: "Nincs személyiség beállítva itt: `{path}/config.yaml`"
Expand Down
9 changes: 9 additions & 0 deletions locales/it.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ gateway:
subscribed_suffix: "(iscritto — riceverai notifica quando {task_id} verrà completato o si bloccherà)"
truncated_suffix: "… (troncato; usa `hermes kanban …` nel terminale per l'output completo)"
no_output: "(nessun output)"
wake:
completed: "completed"
gave_up: "gave up (retries exhausted)"
crashed: "crashed (worker exited); dispatcher will retry"
timed_out: "timed out; dispatcher will retry"
blocked: "blocked; needs attention"
status_default: "status changed"
status_joiner: ", "
message: "[kanban] Task {task_id} {status}.\nTitle: {title}\nAssignee: @{assignee}\nBoard: {board}\n\nCheck the result or decide the next step."

personality:
none_configured: "Nessuna personalità configurata in `{path}/config.yaml`"
Expand Down
Loading
Loading