Repository: agentscope-ai/CoPaw (python)

Issue:
[Feature]: 无法重启copaw

## Summary

[One or two sentences: what do you want and why?]

## Component(s) Affected

- [ ] Core / Backend (app, agents, config, providers, utils, local_models)
- [ ] Console (frontend web UI)
- [ ] Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.)
- [ ] Skills
- [ ] CLI
- [ ] Documentation (website)
- [ ] Tests
- [ ] CI/CD
- [ ] Scripts / Deploy

## Problem / Motivation
永远无法重启，因为根本没有restart、stop的命令。也无法kill。就像一个僵尸一样无法杀死。我本来想要修改监听端口，结果根本没有办法停止的方法。

[What problem does this solve? Who benefits?]

## Proposed Solution

[Describe the feature or change you have in mind. Be as specific as possible.]

## Alternatives Considered

[Any other approaches or workarounds you thought about.]

## Additional Context

[Screenshots, examples, links to docs or similar features elsewhere.]

## Willing to Contribute

- [ ] I am willing to open a PR for this feature (after discussion).

[Feature]: 在discord和其他im中，支持通过斜杠命令来停止当前agent的行为

## Summary
希望在im中支持通过如[/stop]命令，来让正在执行操作的agent停止，防止它走偏。


## Component(s) Affected

- [x] Core / Backend (app, agents, config, providers, utils, local_models)
- [ ] Console (frontend web UI)
- [x] Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.)
- [ ] Skills
- [ ] CLI
- [ ] Documentation (website)
- [ ] Tests
- [ ] CI/CD
- [ ] Scripts / Deploy

## Problem / Motivation

[What problem does this solve? Who benefits?]
场景：可以在发现 agent 中间执行错误操作的时候，及时阻止它，避免更危险的后果
## Proposed Solution

[Describe the feature or change you have in mind. Be as specific as possible.]

## Alternatives Considered

[Any other approaches or workarounds you thought about.]

## Additional Context

[Screenshots, examples, links to docs or similar features elsewhere.]

## Willing to Contribute

- [x] I am willing to open a PR for this feature (after discussion).

[Question]: 有对接成功的吗？为什么我安装CoPaw跟一个废物一样

## Question or topic

[我安装了CoPaw，大模型和技能都安装好了为啥它啥也做不了？]

## Context

[1.让它帮我配置一下163邮箱，我给它了授权码和邮箱地址，然后一直死循环，要吐了。
2.按照官方文档配置了QQ对接，然后不知道怎么操作，这些文档是一个为用户写的吗？怎么感觉。。。
]

## Tried so far

[Optional: what you already tried or read (docs, issues, etc.).]

---

**Note:** For general questions or ideas, [GitHub Discussions](https://github.com/agentscope-ai/CoPaw/discussions) may get more visibility. Use this template when the answer might lead to a bug report, feature request, or doc change.

CoPaw处理复杂问题时中断，未完成即返回

## 问题描述

CoPaw 在处理复杂问题时，尚未处理完成就中断了。例如：定时任务「每日新闻热点」，一直没有返回新闻热点结果。

## 复现场景

1. 设置定时任务执行「每日新闻热点」
2. 任务开始执行但未完成
3. 返回结果不完整或根本没有返回

## 可能原因

1. 超时设置过短
2. 长时间运行的任务缺少心跳保活机制
3. 流式响应中断处理不当
4. 资源限制导致任务被终止

## 期望行为

复杂任务应能完整执行并返回结果，或提供明确的超时/失败提示。


Patch:
diff --git a/src/copaw/agents/command_handler.py b/src/copaw/agents/command_handler.py
index d87fcc14b..516031a9b 100644
--- a/src/copaw/agents/command_handler.py
+++ b/src/copaw/agents/command_handler.py
@@ -16,10 +16,14 @@
 logger = logging.getLogger(__name__)
 
 
-class CommandHandler:
-    """Handler for agent system commands."""
+class ConversationCommandHandlerMixin:
+    """Mixin for conversation (system) commands: /compact, /new, /clear, etc.
 
-    # Supported system commands
+    Expects self to have: agent_name, memory, formatter, memory_manager,
+    _enable_memory_manager.
+    """
+
+    # Supported conversation commands (unchanged set)
     SYSTEM_COMMANDS = frozenset(
         {
             "compact",
@@ -31,6 +35,23 @@ class CommandHandler:
         },
     )
 
+    def is_conversation_command(self, query: str | None) -> bool:
+        """Check if the query is a conversation system command.
+
+        Args:
+            query: User query string
+
+        Returns:
+            True if query is a system command
+        """
+        if not isinstance(query, str) or not query.startswith("/"):
+            return False
+        return query.strip().lstrip("/") in self.SYSTEM_COMMANDS
+
+
+class CommandHandler(ConversationCommandHandlerMixin):
+    """Handler for system commands (uses ConversationCommandHandlerMixin)."""
+
     def __init__(
         self,
         agent_name: str,
@@ -52,17 +73,8 @@ def __init__(
         self._enable_memory_manager = enable_memory_manager
 
     def is_command(self, query: str | None) -> bool:
-        """Check if the query is a system command.
-
-        Args:
-            query: User query string
-
-        Returns:
-            True if query is a system command
-        """
-        if not isinstance(query, str) or not query.startswith("/"):
-            return False
-        return query.strip().lstrip("/") in self.SYSTEM_COMMANDS
+        """Check if the query is a system command (alias for mixin)."""
+        return self.is_conversation_command(query)
 
     async def _make_system_msg(self, text: str) -> Msg:
         """Create a system response message.
@@ -195,8 +207,8 @@ async def _process_await_summary(self, _messages: list[Msg]) -> Msg:
             f"- All tasks have finished",
         )
 
-    async def handle_command(self, query: str) -> Msg:
-        """Process system commands.
+    async def handle_conversation_command(self, query: str) -> Msg:
+        """Process conversation system commands.
 
         Args:
             query: Command string (e.g., "/compact", "/new")
@@ -218,3 +230,7 @@ async def handle_command(self, query: str) -> Msg:
         if handler is None:
             raise RuntimeError(f"Unknown command: {query}")
         return await handler(messages)
+
+    async def handle_command(self, query: str) -> Msg:
+        """Process system commands (alias for handle_conversation_command)."""
+        return await self.handle_conversation_command(query)
diff --git a/src/copaw/app/_app.py b/src/copaw/app/_app.py
index 7850fe752..5b100340e 100644
--- a/src/copaw/app/_app.py
+++ b/src/copaw/app/_app.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 # pylint: disable=redefined-outer-name,unused-argument
+import asyncio
 import mimetypes
 import os
 from contextlib import asynccontextmanager
@@ -18,9 +19,9 @@
     ConfigWatcher,
 )
 from ..config.utils import get_jobs_path, get_chats_path, get_config_path
-from ..constant import DOCS_ENABLED, LOG_LEVEL_ENV, CORS_ORIGINS
+from ..constant import DOCS_ENABLED, LOG_LEVEL_ENV, CORS_ORIGINS, WORKING_DIR
 from ..__version__ import __version__
-from ..utils.logging import setup_logger
+from ..utils.logging import setup_logger, add_copaw_file_handler
 from .channels import ChannelManager  # pylint: disable=no-name-in-module
 from .channels.utils import make_process_from_runner
 from .mcp import MCPClientManager, MCPConfigWatcher  # MCP hot-reload support
@@ -56,7 +57,10 @@
 
 
 @asynccontextmanager
-async def lifespan(app: FastAPI):  # pylint: disable=too-many-statements
+async def lifespan(
+    app: FastAPI,
+):  # pylint: disable=too-many-statements,too-many-branches
+    add_copaw_file_handler(WORKING_DIR / "copaw.log")
     await runner.start()
 
     # --- MCP client manager init (independent module, hot-reloadable) ---
@@ -126,29 +130,297 @@ async def lifespan(app: FastAPI):  # pylint: disable=too-many-statements
     app.state.mcp_manager = mcp_manager
     app.state.mcp_watcher = mcp_watcher
 
+    _restart_task: asyncio.Task | None = None
+
+    async def _restart_services() -> None:
+        """Stop all managers, then rebuild from config (no exit).
+
+        Single-flight: only one restart runs at a time. Concurrent or
+        duplicate callers wait for the in-progress restart and return
+        successfully. Uses asyncio.shield() so that when the caller
+        (e.g. channel request) is cancelled, the restart task keeps
+        running and does not propagate cancellation into deep task
+        trees (avoids RecursionError on cancel).
+        """
+        # pylint: disable=too-many-statements
+        nonlocal _restart_task
+        # Caller task (in _local_tasks) must not be cancelled so it can
+        # yield the final "Restart completed" message.
+        restart_requester_task = asyncio.current_task()
+
+        async def _run_then_clear() -> None:
+            try:
+                await _do_restart_services(
+                    restart_requester_task=restart_requester_task,
+                )
+            finally:
+                nonlocal _restart_task
+                _restart_task = None
+
+        if _restart_task is not None and not _restart_task.done():
+            logger.info(
+                "_restart_services: waiting for in-progress restart to finish",
+            )
+            await asyncio.shield(_restart_task)
+            return
+        if _restart_task is not None and _restart_task.done():
+            _restart_task = None
+        logger.info("_restart_services: starting restart")
+        _restart_task = asyncio.create_task(_run_then_clear())
+        await asyncio.shield(_restart_task)
+
+    async def _teardown_new_stack(
+        mcp_watcher=None,
+        config_watcher=None,
+        cron_mgr=None,
+        ch_mgr=None,
+        mcp_mgr=None,
+    ) -> None:
+        """Stop new stack in reverse start order (for rollback on failure)."""
+        if mcp_watcher is not None:
+            try:
+                await mcp_watcher.stop()
+            except Exception:
+                logger.debug(
+                    "rollback: mcp_watcher.stop failed",
+                    exc_info=True,
+                )
+        if config_watcher is not None:
+            try:
+                await config_watcher.stop()
+            except Exception:
+                logger.debug(
+                    "rollback: config_watcher.stop failed",
+                    exc_info=True,
+                )
+        if cron_mgr is not None:
+            try:
+                await cron_mgr.stop()
+            except Exception:
+                logger.debug(
+                    "rollback: cron_manager.stop failed",
+                    exc_info=True,
+                )
+        if ch_mgr is not None:
+            try:
+                await ch_mgr.stop_all()
+            except Exception:
+                logger.debug(
+                    "rollback: channel_manager.stop_all failed",
+                    exc_info=True,
+                )
+        if mcp_mgr is not None:
+            try:
+                await mcp_mgr.close_all()
+            except Exception:
+                logger.debug(
+                    "rollback: mcp_manager.close_all failed",
+                    exc_info=True,
+                )
+
+    async def _do_restart_services(
+        restart_requester_task: asyncio.Task | None = None,
+    ) -> None:
+        """Cancel in-flight agent requests first (so they can send error to
+        channel), then stop old stack, then start new stack and swap.
+        """
+        # pylint: disable=too-many-statements
+        try:
+            config = load_config(get_config_path())
+        except Exception:
+            logger.exception("restart_services: load_config failed")
+            return
+
+        # 1) Cancel in-flight agent requests. Do not wait for them so the
+        # console restart task never blocks (avoid deadlock when cancelled
+        # task is slow to exit).
+        local_tasks = getattr(agent_app, "_local_tasks", None)
+        if local_tasks:
+            to_cancel = [
+                t
+                for t in list(local_tasks.values())
+                if t is not restart_requester_task and not t.done()
+            ]
+            for t in to_cancel:
+                t.cancel()
+            if to_cancel:
+                logger.info(
+                    "restart: cancelled %s in-flight task(s), not waiting",
+                    len(to_cancel),
+                )
+
+        # 2) Stop old stack
+        cfg_w = app.state.config_watcher
+        mcp_w = getattr(app.state, "mcp_watcher", None)
+        cron_mgr = app.state.cron_manager
+        ch_mgr = app.state.channel_manager
+        mcp_mgr = app.state.mcp_manager
+        try:
+            await cfg_w.stop()
+        except Exception:
+            logger.exception(
+                "restart_services: old config_watcher.stop failed",
+            )
+        if mcp_w is not None:
+            try:
+                await mcp_w.stop()
+            except Exception:
+                logger.exception(
+                    "restart_services: old mcp_watcher.stop failed",
+                )
+        try:
+            await cron_mgr.stop()
+        except Exception:
+            logger.exception(
+                "restart_services: old cron_manager.stop failed",
+            )
+        try:
+            await ch_mgr.stop_all()
+        except Exception:
+            logger.exception(
+                "restart_services: old channel_manager.stop_all failed",
+            )
+        if mcp_mgr is not None:
+            try:
+                await mcp_mgr.close_all()
+            except Exception:
+                logger.exception(
+                    "restart_services: old mcp_manager.close_all failed",
+                )
+
+        # 3) Build and start new stack
+        new_mcp_manager = MCPClientManager()
+        if hasattr(config, "mcp"):
+            try:
+                await new_mcp_manager.init_from_config(config.mcp)
+            except Exception:
+                logger.exception(
+                    "restart_services: mcp init_from_config failed",
+                )
+                return
+
+        new_channel_manager = ChannelManager.from_config(
+            process=make_process_from_runner(runner),
+            config=config,
+            on_last_dispatch=update_last_dispatch,
+        )
+        try:
+            await new_channel_manager.start_all()
+        except Exception:
+            logger.exception(
+                "restart_services: channel_manager.start_all failed",
+            )
+            await _teardown_new_stack(mcp_mgr=new_mcp_manager)
+            return
+
+        job_repo = JsonJobRepository(get_jobs_path())
+        new_cron_manager = CronManager(
+            repo=job_repo,
+            runner=runner,
+            channel_manager=new_channel_manager,
+            timezone="UTC",
+        )
+        try:
+            await new_cron_manager.start()
+        except Exception:
+            logger.exception(
+                "restart_services: cron_manager.start failed",
+            )
+            await _teardown_new_stack(
+                ch_mgr=new_channel_manager,
+                mcp_mgr=new_mcp_manager,
+            )
+            return
+
+        new_config_watcher = ConfigWatcher(
+            channel_manager=new_channel_manager,
+            cron_manager=new_cron_manager,
+        )
+        try:
+            await new_config_watcher.start()
+        except Exception:
+            logger.exception(
+                "restart_services: config_watcher.start failed",
+            )
+            await _teardown_new_stack(
+                cron_mgr=new_cron_manager,
+                ch_mgr=new_channel_manager,
+                mcp_mgr=new_mcp_manager,
+            )
+            return
+
+        new_mcp_watcher = None
+        if hasattr(config, "mcp"):
+            try:
+                new_mcp_watcher = MCPConfigWatcher(
+                    mcp_manager=new_mcp_manager,
+                    config_loader=load_config,
+                    config_path=get_config_path(),
+                )
+                await new_mcp_watcher.start()
+            except Exception:
+                logger.exception(
+                    "restart_services: mcp_watcher.start failed",
+                )
+                await _teardown_new_stack(
+                    config_watcher=new_config_watcher,
+                    cron_mgr=new_cron_manager,
+                    ch_mgr=new_channel_manager,
+                    mcp_mgr=new_mcp_manager,
+                )
+                return
+
+        if hasattr(config, "mcp"):
+            runner.set_mcp_manager(new_mcp_manager)
+            app.state.mcp_manager = new_mcp_manager
+            app.state.mcp_watcher = new_mcp_watcher
+        else:
+            runner.set_mcp_manager(None)
+            app.state.mcp_manager = None
+            app.state.mcp_watcher = None
+        app.state.channel_manager = new_channel_manager
+        app.state.cron_manager = new_cron_manager
+        app.state.config_watcher = new_config_watcher
+        logger.info("Daemon restart (in-process) completed: managers rebuilt")
+
+    setattr(runner, "_restart_callback", _restart_services)
+
     try:
         yield
     finally:
+        # Stop current app.state refs (post-restart instances if any)
+        cfg_w = getattr(app.state, "config_watcher", None)
+        mcp_w = getattr(app.state, "mcp_watcher", None)
+        cron_mgr = getattr(app.state, "cron_manager", None)
+        ch_mgr = getattr(app.state, "channel_manager", None)
+        mcp_mgr = getattr(app.state, "mcp_manager", None)
         # stop order: watchers -> cron -> channels -> mcp -> runner
-        try:
-            await config_watcher.stop()
-        except Exception:
-            pass
-        if mcp_watcher:
+        if cfg_w is not None:
             try:
-                await mcp_watcher.stop()
+                await cfg_w.stop()
             except Exception:
                 pass
-        try:
-            await cron_manager.stop()
-        finally:
-            await channel_manager.stop_all()
-            if mcp_manager:
-                try:
-                    await mcp_manager.close_all()
-                except Exception:
-                    pass
-            await runner.stop()
+        if mcp_w is not None:
+            try:
+                await mcp_w.stop()
+            except Exception:
+                pass
+        if cron_mgr is not None:
+            try:
+                await cron_mgr.stop()
+            except Exception:
+                pass
+        if ch_mgr is not None:
+            try:
+                await ch_mgr.stop_all()
+            except Exception:
+                pass
+        if mcp_mgr is not None:
+            try:
+                await mcp_mgr.close_all()
+            except Exception:
+                pass
+        await runner.stop()
 
 
 app = FastAPI(
diff --git a/src/copaw/app/channels/base.py b/src/copaw/app/channels/base.py
index 30e7d1108..163a5d465 100644
--- a/src/copaw/app/channels/base.py
+++ b/src/copaw/app/channels/base.py
@@ -471,11 +471,6 @@ async def _run_process_loop(
         Run _process and send events. Override to use channel-specific
         loop (e.g. DingTalk _process_one_request with webhook sends).
         """
-        bot_prefix = send_meta.get("bot_prefix", "") or getattr(
-            self,
-            "bot_prefix",
-            "",
-        )
         last_response = None
         try:
             async for event in self._process(request):
@@ -491,14 +486,13 @@ async def _run_process_loop(
                 elif obj == "response":
                     last_response = event
                     await self.on_event_response(request, event)
-            if last_response and getattr(last_response, "error", None):
-                err = getattr(
-                    last_response.error,
-                    "message",
-                    str(last_response.error),
+            err_msg = self._get_response_error_message(last_response)
+            if err_msg:
+                await self._on_consume_error(
+                    request,
+                    to_handle,
+                    f"Error: {err_msg}",
                 )
-                err_text = (bot_prefix or "") + f"Error: {err}"
-                await self._on_consume_error(request, to_handle, err_text)
             if self._on_reply_sent:
                 args = self.get_on_reply_sent_args(request, to_handle)
                 self._on_reply_sent(self.channel, *args)
@@ -510,6 +504,27 @@ async def _run_process_loop(
                 "An error occurred while processing your request.",
             )
 
+    def _get_response_error_message(self, last_response: Any) -> Optional[str]:
+        """
+        Extract error message from runtime response event.
+        Handles AgentResponse.error or Event wrapper (e.g. .data / .response).
+        """
+        if not last_response:
+            return None
+        resp = last_response
+        if getattr(last_response, "data", None) is not None:
+            resp = last_response.data
+        elif getattr(last_response, "response", None) is not None:
+            resp = last_response.response
+        err = getattr(resp, "error", None)
+        if not err:
+            return None
+        if hasattr(err, "message"):
+            return getattr(err, "message", None) or str(err)
+        if isinstance(err, dict):
+            return err.get("message") or str(err)
+        return str(err)
+
     async def _before_consume_process(self, request: "AgentRequest") -> None:
         """
         Hook called once per consume_one before running _process. Override
@@ -549,7 +564,7 @@ async def _on_consume_error(
         """
         await self.send_content_parts(
             to_handle,
-            [{"type": "text", "text": err_text}],
+            [TextContent(type=ContentType.TEXT, text=err_text)],
             getattr(request, "channel_meta", None) or {},
         )
 
diff --git a/src/copaw/app/channels/console/channel.py b/src/copaw/app/channels/console/channel.py
index 5a010c424..34c3098dd 100644
--- a/src/copaw/app/channels/console/channel.py
+++ b/src/copaw/app/channels/console/channel.py
@@ -183,13 +183,9 @@ async def consume_one(self, payload: Any) -> None:
                 last_response is not None,
             )
 
-            if last_response and getattr(last_response, "error", None):
-                err = getattr(
-                    last_response.error,
-                    "message",
-                    str(last_response.error),
-                )
-                self._print_error(err)
+            err_msg = self._get_response_error_message(last_response)
+            if err_msg:
+                self._print_error(err_msg)
 
             to_handle = request.user_id or ""
             if self._on_reply_sent:
@@ -199,11 +195,10 @@ async def consume_one(self, payload: Any) -> None:
                     request.session_id or f"{self.channel}:{to_handle}",
                 )
 
-        except Exception:
+        except Exception as e:
             logger.exception("console process/reply failed")
-            self._print_error(
-                "An error occurred while processing your request.",
-            )
+            err_msg = str(e).strip() or "An error occurred while processing."
+            self._print_error(err_msg)
 
     # ── pretty-print helpers ────────────────────────────────────────
 
diff --git a/src/copaw/app/channels/dingtalk/channel.py b/src/copaw/app/channels/dingtalk/channel.py
index ea55b0ac2..be807ecd6 100644
--- a/src/copaw/app/channels/dingtalk/channel.py
+++ b/src/copaw/app/channels/dingtalk/channel.py
@@ -1024,11 +1024,19 @@ async def send_content_parts(
         text_parts = []
         media_parts: List[OutgoingContentPart] = []
         for p in parts:
-            t = getattr(p, "type", None)
-            if t == ContentType.TEXT and getattr(p, "text", None):
-                text_parts.append(p.text or "")
-            elif t == ContentType.REFUSAL and getattr(p, "refusal", None):
-                text_parts.append(p.refusal or "")
+            t = getattr(p, "type", None) or (
+                p.get("type") if isinstance(p, dict) else None
+            )
+            text_val = getattr(p, "text", None) or (
+                p.get("text") if isinstance(p, dict) else None
+            )
+            refusal_val = getattr(p, "refusal", None) or (
+                p.get("refusal") if isinstance(p, dict) else None
+            )
+            if t == ContentType.TEXT and text_val:
+                text_parts.append(text_val or "")
+            elif t == ContentType.REFUSAL and refusal_val:
+                text_parts.append(refusal_val or "")
             elif t == ContentType.IMAGE:
                 media_parts.append(p)
             elif t == ContentType.FILE:
@@ -1175,12 +1183,12 @@ async def _run_process_loop(
         )
         try:
             await self._process_one_request(request, reply_meta=send_meta)
-        except Exception:
+        except Exception as e:
             logger.exception("dingtalk _process_one_request failed")
+            err_msg = str(e).strip() or "An error occurred while processing."
             self._reply_sync_batch(
                 send_meta,
-                self.bot_prefix
-                + "An error occurred while processing your request.",
+                self.bot_prefix + f"Error: {err_msg}",
             )
             raise
 
@@ -1296,13 +1304,9 @@ async def _process_one_request(
             use_multi,
         )
 
-        if last_response and getattr(last_response, "error", None):
-            err = getattr(
-                last_response.error,
-                "message",
-                str(last_response.error),
-            )
-            err_text = self.bot_prefix + f"Error: {err}"
+        err_msg = self._get_response_error_message(last_response)
+        if err_msg:
+            err_text = self.bot_prefix + f"Error: {err_msg}"
             if use_multi and session_webhook:
                 await self._send_via_session_webhook(
                     session_webhook,
diff --git a/src/copaw/app/channels/feishu/channel.py b/src/copaw/app/channels/feishu/channel.py
index 25bc1f49e..da7f03eac 100644
--- a/src/copaw/app/channels/feishu/channel.py
+++ b/src/copaw/app/channels/feishu/channel.py
@@ -1410,11 +1410,19 @@ async def send_content_parts(
         text_parts: List[str] = []
         media_parts: List[OutgoingContentPart] = []
         for p in parts:
-            t = getattr(p, "type", None)
-            if t == ContentType.TEXT and getattr(p, "text", None):
-                text_parts.append(p.text or "")
-            elif t == ContentType.REFUSAL and getattr(p, "refusal", None):
-                text_parts.append(p.refusal or "")
+            t = getattr(p, "type", None) or (
+                p.get("type") if isinstance(p, dict) else None
+            )
+            text_val = getattr(p, "text", None) or (
+                p.get("text") if isinstance(p, dict) else None
+            )
+            refusal_val = getattr(p, "refusal", None) or (
+                p.get("refusal") if isinstance(p, dict) else None
+            )
+            if t == ContentType.TEXT and text_val:
+                text_parts.append(text_val or "")
+            elif t == ContentType.REFUSAL and refusal_val:
+                text_parts.append(refusal_val or "")
             elif t in (
                 ContentType.IMAGE,
                 ContentType.FILE,
diff --git a/src/copaw/app/channels/manager.py b/src/copaw/app/channels/manager.py
index 22ba251a7..c936e56a9 100644
--- a/src/copaw/app/channels/manager.py
+++ b/src/copaw/app/channels/manager.py
@@ -343,7 +343,16 @@ async def stop_all(self) -> None:
         for task in self._consumer_tasks:
             task.cancel()
         if self._consumer_tasks:
-            await asyncio.gather(*self._consumer_tasks, return_exceptions=True)
+            _, pending = await asyncio.wait(
+                self._consumer_tasks,
+                timeout=5.0,
+                return_when=asyncio.ALL_COMPLETED,
+            )
+            if pending:
+                logger.warning(
+                    "stop_all: %s consumer task(s) still pending after 5s",
+                    len(pending),
+                )
         self._consumer_tasks.clear()
         self._queues.clear()
         async with self._lock:
diff --git a/src/copaw/app/channels/qq/channel.py b/src/copaw/app/channels/qq/channel.py
index a3b0538f5..49bba543b 100644
--- a/src/copaw/app/channels/qq/channel.py
+++ b/src/copaw/app/channels/qq/channel.py
@@ -520,13 +520,9 @@ async def consume_one(self, payload: Any) -> None:
                 elif obj == "response":
                     last_response = event
 
-            if last_response and getattr(last_response, "error", None):
-                err = getattr(
-                    last_response.error,
-                    "message",
-                    str(last_response.error),
-                )
-                err_text = self.bot_prefix + f"Error: {err}"
+            err_msg = self._get_response_error_message(last_response)
+            if err_msg:
+                err_text = self.bot_prefix + f"Error: {err_msg}"
                 await self.send_content_parts(
                     to_handle,
                     [{"type": "text", "text": err_text}],
@@ -557,19 +553,14 @@ async def consume_one(self, payload: Any) -> None:
                     to_handle,
                     request.session_id or f"{self.channel}:{to_handle}",
                 )
-        except Exception:
+        except Exception as e:
             logger.exception("qq process/reply failed")
+            err_msg = str(e).strip() or "An error occurred while processing."
             try:
                 fallback_handle = getattr(request, "user_id", "")
                 await self.send_content_parts(
                     fallback_handle,
-                    [
-                        {
-                            "type": "text",
-                            "text": "An error occurred while processing "
-                            "your request.",
-                        },
-                    ],
+                    [{"type": "text", "text": f"Error: {err_msg}"}],
                     getattr(request, "channel_meta", None) or {},
                 )
             except Exception:
diff --git a/src/copaw/app/runner/command_dispatch.py b/src/copaw/app/runner/command_dispatch.py
new file mode 100644
index 000000000..818457fa6
--- /dev/null
+++ b/src/copaw/app/runner/command_dispatch.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+"""Command dispatch: run command path without creating CoPawAgent.
+
+Yields (Msg, last) compatible with query_handler stream.
+"""
+from __future__ import annotations
+
+import logging
+from typing import AsyncIterator
+
+from reme.memory.file_based_copaw import CoPawInMemoryMemory
+from agentscope.message import Msg, TextBlock
+
+from ...agents.command_handler import CommandHandler
+from ...agents.model_factory import create_model_and_formatter
+from ...agents.utils.token_counting import _get_token_counter
+from ...config import load_config
+
+from .daemon_commands import (
+    DaemonContext,
+    DaemonCommandHandlerMixin,
+    parse_daemon_query,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _get_last_user_text(msgs) -> str | None:
+    """Extract last user message text from msgs (runtime message list)."""
+    if not msgs or len(msgs) == 0:
+        return None
+    last = msgs[-1]
+    if hasattr(last, "get_text_content"):
+        return last.get_text_content()
+    if isinstance(last, dict):
+        content = last.get("content") or last.get("text")
+        if isinstance(content, str):
+            return content
+        if isinstance(content, list):
+            for block in content:
+                if isinstance(block, dict) and block.get("type") == "text":
+                    return block.get("text")
+    return None
+
+
+def _is_conversation_command(query: str | None) -> bool:
+    """True if query is a conversation command (/compact, /new, etc.)."""
+    if not query or not query.startswith("/"):
+        return False
+    cmd = query.strip().lstrip("/").split()[0] if query.strip() else ""
+    return cmd in CommandHandler.SYSTEM_COMMANDS
+
+
+def _is_command(query: str | None) -> bool:
+    """True if query is any known command (daemon or conversation)."""
+    if not query or not query.startswith("/"):
+        return False
+    if parse_daemon_query(query) is not None:
+        return True
+    return _is_conversation_command(query)
+
+
+class _LightweightSessionAgent:
+    """Minimal agent-like object for session load/save (memory only)."""
+
+    def __init__(self, memory: CoPawInMemoryMemory) -> None:
+        self.memory = memory
+
+    def state_dict(self) -> dict:
+        return {"memory": self.memory.state_dict()}
+
+    def load_state_dict(self, state_dict: dict, strict: bool = True) -> None:
+        mem = state_dict.get("memory", state_dict)
+        self.memory.load_state_dict(mem, strict=strict)
+
+
+async def run_command_path(
+    request,
+    msgs,
+    runner,
+) -> AsyncIterator[tuple]:
+    """Run command path and yield (msg, last) for each response.
+
+    Args:
+        request: AgentRequest (session_id, user_id, etc.)
+        msgs: List of messages from runtime (last is user input)
+        runner: AgentRunner (session, memory_manager, etc.)
+
+    Yields:
+        (Msg, bool) compatible with query_handler stream
+    """
+    query = _get_last_user_text(msgs)
+    if not query:
+        return
+
+    session_id = getattr(request, "session_id", "") or ""
+    user_id = getattr(request, "user_id", "") or ""
+
+    # Daemon path
+    parsed = parse_daemon_query(query)
+    if parsed is not None:
+        handler = DaemonCommandHandlerMixin()
+        restart_cb = getattr(runner, "_restart_callback", None)
+        if parsed[0] == "restart":
+            logger.info(
+                "run_command_path: daemon restart, callback=%s",
+                "set" if restart_cb is not None else "None",
+            )
+            # Yield hint first so user sees it before restart runs.
+            hint = Msg(
+                name="Friday",
+                role="assistant",
+                content=[
+                    TextBlock(
+                        type="text",
+                        text=(
+                            "**Restart in progress**\n\n"
+                            "- The service may be unresponsive for a while. "
+                            "Please wait."
+                        ),
+                    ),
+                ],
+            )
+            yield (hint, True)
+        context = DaemonContext(
+            load_config_fn=load_config,
+            memory_manager=runner.memory_manager,
+            restart_callback=restart_cb,
+        )
+        msg = await handler.handle_daemon_command(query, context)
+        yield (msg, True)
+        logger.info("handle_daemon_command %s completed", query)
+        return
+
+    # Conversation path: lightweight memory + CommandHandler
+    _, formatter = create_model_and_formatter()
+    token_counter = _get_token_counter()
+    memory = CoPawInMemoryMemory(
+        token_counter=token_counter,
+        formatter=formatter,
+    )
+    light = _LightweightSessionAgent(memory=memory)
+    if session_id and user_id:
+        try:
+            await runner.session.load_session_state(
+                session_id=session_id,
+                user_id=user_id,
+                agent=light,
+            )
+        except ValueError:
+            pass  # No session file yet
+
+    conv_handler = CommandHandler(
+        agent_name="Friday",
+        memory=light.memory,
+        memory_manager=runner.memory_manager,
+        enable_memory_manager=runner.memory_manager is not None,
+    )
+    try:
+        response_msg = await conv_handler.handle_conversation_command(query)
+    except RuntimeError as e:
+        response_msg = Msg(
+            name="Friday",
+            role="assistant",
+            content=[TextBlock(type="text", text=str(e))],
+        )
+    yield (response_msg, True)
+
+    if session_id and user_id:
+        await runner.session.save_session_state(
+            session_id=session_id,
+            user_id=user_id,
+            agent=light,
+        )
diff --git a/src/copaw/app/runner/daemon_commands.py b/src/copaw/app/runner/daemon_commands.py
new file mode 100644
index 000000000..c39131021
--- /dev/null
+++ b/src/copaw/app/runner/daemon_commands.py
@@ -0,0 +1,245 @@
+# -*- coding: utf-8 -*-
+"""Daemon command execution layer and DaemonCommandHandlerMixin.
+
+Shared by in-chat /daemon <sub> and CLI `copaw daemon <sub>`.
+Logs: tail WORKING_DIR / "copaw.log". Restart: in-process reload of channels,
+cron and MCP (no process exit); works on Mac/Windows without a process manager.
+"""
+# pylint: disable=too-many-return-statements
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Awaitable, Callable, Optional
+
+from agentscope.message import Msg, TextBlock
+
+from ...constant import WORKING_DIR
+from ...config import load_config
+
+RestartCallback = Callable[[], Awaitable[None]]
+logger = logging.getLogger(__name__)
+
+
+class RestartInProgressError(Exception):
+    """Raised when /daemon restart is invoked while another restart runs."""
+
+
+DAEMON_PREFIX = "/daemon"
+DAEMON_SUBCOMMANDS = frozenset(
+    {"status", "restart", "reload-config", "version", "logs"},
+)
+# Short names: /restart -> /daemon restart, etc.
+DAEMON_SHORT_ALIASES = {
+    "restart": "restart",
+    "status": "status",
+    "reload-config": "reload-config",
+    "reload_config": "reload-config",
+    "version": "version",
+    "logs": "logs",
+}
+
+
+@dataclass
+class DaemonContext:
+    """Context for daemon commands (inject deps from runner or CLI)."""
+
+    working_dir: Path = WORKING_DIR
+    load_config_fn: Callable[[], Any] = load_config
+    memory_manager: Optional[Any] = None
+    # Optional: async restart (channels, cron, MCP) in-process.
+    restart_callback: Optional[RestartCallback] = None
+
+
+def _get_last_lines(
+    path: Path,
+    lines: int = 100,
+    max_bytes: int = 512 * 1024,
+) -> str:
+    """Read last N lines from a text file (tail) with bounded memory.
+
+    Reads at most max_bytes from the end of the file so large logs
+    do not cause high memory usage or latency.
+    """
+    path = Path(path)
+    if not path.exists() or not path.is_file():
+        return f"(Log file not found: {path})"
+    try:
+        size = path.stat().st_size
+        if size == 0:
+            return "(empty)"
+        with open(path, "rb") as f:
+            if size <= max_bytes:
+                content = f.read().decode("utf-8", errors="replace")
+            else:
+                f.seek(size - max_bytes)
+                content = f.read().decode("utf-8", errors="replace")
+                first_nl = content.find("\n")
+                if first_nl != -1:
+                    content = content[first_nl + 1 :]
+                else:
+                    content = ""
+        all_lines = content.splitlines()
+        last = all_lines[-lines:] if len(all_lines) > lines else all_lines
+        return "\n".join(last) if last else "(empty)"
+    except OSError as e:
+        return f"(Error reading log: {e})"
+
+
+def run_daemon_status(context: DaemonContext) -> str:
+    """Return status text (health, config, memory_manager)."""
+    parts = ["**Daemon Status**", ""]
+    try:
+        cfg = context.load_config_fn()
+        parts.append("- Config loaded: yes")
+        if getattr(cfg, "agents", None) and getattr(
+            cfg.agents,
+            "running",
+            None,
+        ):
+            max_in = getattr(cfg.agents.running, "max_input_length", "N/A")
+            parts.append(f"- Max input length: {max_in}")
+    except Exception as e:
+        parts.append(f"- Config loaded: no ({e})")
+
+    parts.append(f"- Working dir: {context.working_dir}")
+    if context.memory_manager is not None:
+        parts.append("- Memory manager: running")
+    else:
+        parts.append("- Memory manager: not attached")
+    return "\n".join(parts)
+
+
+async def run_daemon_restart(context: DaemonContext) -> str:
+    """Trigger in-process restart (channels, cron, MCP) or instruct user."""
+    if context.restart_callback is not None:
+        try:
+            await context.restart_callback()
+            return (
+                "**Restart completed**\n\n"
+                "- Channels, cron and MCP reloaded in-process (no exit)."
+            )
+        except RestartInProgressError:
+            return (
+                "**Restart skipped**\n\n"
+                "- A restart is already in progress. Please wait for it to "
+                "finish."
+            )
+        except Exception as e:
+            return f"**Restart failed**\n\n- {e}"
+    return (
+        "**Restart**\n\n"
+        "- No restart callback (e.g. not running inside app). "
+        "Run the app (e.g. `copaw app`) and use /daemon restart in chat, "
+        "or restart the process with systemd/supervisor/docker."
+    )
+
+
+def run_daemon_reload_config(context: DaemonContext) -> str:
+    """Reload config (re-call load_config); no process restart."""
+    try:
+        context.load_config_fn()
+        return (
+            "**Config reloaded**\n\n- load_config() re-invoked successfully."
+        )
+    except Exception as e:
+        return f"**Reload failed**\n\n- {e}"
+
+
+def run_daemon_version(context: DaemonContext) -> str:
+    """Return version and paths."""
+    try:
+        from ...__version__ import __version__ as ver
+    except ImportError:
+        ver = "unknown"
+    return (
+        f"**Daemon version**\n\n"
+        f"- Version: {ver}\n"
+        f"- Working dir: {context.working_dir}\n"
+        f"- Log file: {context.working_dir / 'copaw.log'}"
+    )
+
+
+def run_daemon_logs(context: DaemonContext, lines: int = 100) -> str:
+    """Tail last N lines from WORKING_DIR / copaw.log."""
+    log_path = context.working_dir / "copaw.log"
+    content = _get_last_lines(log_path, lines=lines)
+    return f"**Console log (last {lines} lines)**\n\n```\n{content}\n```"
+
+
+def parse_daemon_query(query: str) -> Optional[tuple[str, list[str]]]:
+    """Parse /daemon <sub> or /<short>. Return (subcommand, args) or None."""
+    if not query or not isinstance(query, str):
+        return None
+    raw = query.strip()
+    if not raw.startswith("/"):
+        return None
+    rest = raw.lstrip("/").strip()
+    if not rest:
+        return None
+    parts = rest.split()
+    first = parts[0].lower() if parts else ""
+
+    if first == "daemon":
+        if len(parts) < 2:
+            return ("status", [])
+        sub = parts[1].lower().replace("_", "-")
+        if sub not in DAEMON_SUBCOMMANDS and "reload" in sub:
+            sub = "reload-config"
+        if sub not in DAEMON_SUBCOMMANDS:
+            return None
+        args = parts[2:] if len(parts) > 2 else []
+        return (sub, args)
+    if first in DAEMON_SHORT_ALIASES:
+        sub = DAEMON_SHORT_ALIASES[first]
+        return (sub, parts[1:] if len(parts) > 1 else [])
+    return None
+
+
+class DaemonCommandHandlerMixin:
+    """Mixin for daemon commands: /daemon status, restart, logs, etc."""
+
+    def is_daemon_command(self, query: str | None) -> bool:
+        """True if query is /daemon <sub> or short name (/restart, etc.)."""
+        return parse_daemon_query(query or "") is not None
+
+    async def handle_daemon_command(
+        self,
+        query: str,
+        context: DaemonContext,
+    ) -> Msg:
+        """Run daemon subcommand; return a single assistant Msg."""
+        parsed = parse_daemon_query(query)
+        if not parsed:
+            return Msg(
+                name="Friday",
+                role="assistant",
+                content=[
+                    TextBlock(type="text", text="Unknown daemon command."),
+                ],
+            )
+        sub, args = parsed
+        if sub == "status":
+            text = run_daemon_status(context)
+        elif sub == "restart":
+            text = await run_daemon_restart(context)
+        elif sub == "reload-config":
+            text = run_daemon_reload_config(context)
+        elif sub == "version":
+            text = run_daemon_version(context)
+        elif sub == "logs":
+            n = 100
+            for a in args:
+                if a.isdigit():
+                    n = max(1, min(int(a), 2000))
+                    break
+            text = run_daemon_logs(context, lines=n)
+        else:
+            text = "Unknown daemon subcommand."
+        logger.info("handle_daemon_command %s completed", query)
+        return Msg(
+            name="Friday",
+            role="assistant",
+            content=[TextBlock(type="text", text=text)],
+        )
diff --git a/src/copaw/app/runner/runner.py b/src/copaw/app/runner/runner.py
index a5dbdd091..fedc6f573 100644
--- a/src/copaw/app/runner/runner.py
+++ b/src/copaw/app/runner/runner.py
@@ -11,6 +11,11 @@
 from agentscope_runtime.engine.schemas.agent_schemas import AgentRequest
 from dotenv import load_dotenv
 
+from .command_dispatch import (
+    _get_last_user_text,
+    _is_command,
+    run_command_path,
+)
 from .query_error_dump import write_query_error_dump
 from .session import SafeJSONSession
 from .utils import build_env_context
@@ -35,7 +40,6 @@ def __init__(self) -> None:
         self.framework_type = "agentscope"
         self._chat_manager = None  # Store chat_manager reference
         self._mcp_manager = None  # MCP client manager for hot-reload
-
         self.memory_manager: MemoryManager | None = None
 
     def set_chat_manager(self, chat_manager):
@@ -63,11 +67,17 @@ async def query_handler(
         """
         Handle agent query.
         """
+        # Command path: do not create agent; yield from run_command_path
+        query = _get_last_user_text(msgs)
+        if query and _is_command(query):
+            logger.info("Command path: %s", query.strip()[:50])
+            async for msg, last in run_command_path(request, msgs, self):
+                yield msg, last
+            return
 
         agent = None
         chat = None
         session_state_loaded = False
-
         try:
             session_id = request.session_id
             user_id = request.user_id
@@ -134,11 +144,18 @@ async def query_handler(
                     name=name,
                 )
 
-            await self.session.load_session_state(
-                session_id=session_id,
-                user_id=user_id,
-                agent=agent,
-            )
+            try:
+                await self.session.load_session_state(
+                    session_id=session_id,
+                    user_id=user_id,
+                    agent=agent,
+                )
+            except KeyError as e:
+                logger.warning(
+                    "load_session_state skipped (state schema mismatch): %s; "
+                    "will save fresh state on completion to recover file",
+                    e,
+                )
             session_state_loaded = True
 
             # Rebuild system prompt so it always reflects the latest
@@ -152,10 +169,11 @@ async def query_handler(
             ):
                 yield msg, last
 
-        except asyncio.CancelledError:
+        except asyncio.CancelledError as exc:
+            logger.info(f"query_handler: {session_id} cancelled!")
             if agent is not None:
                 await agent.interrupt()
-            raise
+            raise RuntimeError("Task has been cancelled!") from exc
         except Exception as e:
             debug_dump_path = write_query_error_dump(
                 request=request,
diff --git a/src/copaw/cli/daemon_cmd.py b/src/copaw/cli/daemon_cmd.py
new file mode 100644
index 000000000..446b3e4cd
--- /dev/null
+++ b/src/copaw/cli/daemon_cmd.py
@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+"""CLI daemon subcommands: status, restart, reload-config, version, logs.
+
+Shares execution with in-chat /daemon <sub> via daemon_commands.
+"""
+from __future__ import annotations
+
+import asyncio
+
+import click
+
+from ..app.runner.daemon_commands import (
+    DaemonContext,
+    run_daemon_logs,
+    run_daemon_reload_config,
+    run_daemon_restart,
+    run_daemon_status,
+    run_daemon_version,
+)
+from ..constant import WORKING_DIR
+
+
+def _context() -> DaemonContext:
+    return DaemonContext(
+        working_dir=WORKING_DIR,
+        memory_manager=None,
+        restart_callback=None,
+    )
+
+
+@click.group("daemon")
+def daemon_group() -> None:
+    """Daemon commands: status, restart, reload-config, version, logs."""
+
+
+@daemon_group.command("status")
+def status_cmd() -> None:
+    """Show daemon status (config, working dir, memory manager)."""
+    ctx = _context()
+    click.echo(run_daemon_status(ctx))
+
+
+@daemon_group.command("restart")
+def restart_cmd() -> None:
+    """Print restart instructions (CLI has no process to restart)."""
+    ctx = _context()
+    click.echo(asyncio.run(run_daemon_restart(ctx)))
+
+
+@daemon_group.command("reload-config")
+def reload_config_cmd() -> None:
+    """Reload config (re-read from file)."""
+    ctx = _context()
+    click.echo(run_daemon_reload_config(ctx))
+
+
+@daemon_group.command("version")
+def version_cmd() -> None:
+    """Show version and paths."""
+    ctx = _context()
+    click.echo(run_daemon_version(ctx))
+
+
+@daemon_group.command("logs")
+@click.option(
+    "-n",
+    "--lines",
+    default=100,
+    type=int,
+    help="Number of last lines to show (default 100).",
+)
+def logs_cmd(lines: int) -> None:
+    """Tail last N lines of WORKING_DIR/copaw.log."""
+    ctx = _context()
+    lines = min(max(1, lines), 2000)
+    click.echo(run_daemon_logs(ctx, lines=lines))
diff --git a/src/copaw/cli/main.py b/src/copaw/cli/main.py
index 775f05659..f0e1313a9 100644
--- a/src/copaw/cli/main.py
+++ b/src/copaw/cli/main.py
@@ -51,6 +51,11 @@ def _record(label: str, elapsed: float) -> None:
 
 _record(".chats_cmd", time.perf_counter() - _t)
 
+_t = time.perf_counter()
+from .daemon_cmd import daemon_group  # noqa: E402
+
+_record(".daemon_cmd", time.perf_counter() - _t)
+
 _t = time.perf_counter()
 from .clean_cmd import clean_cmd  # noqa: E402
 
@@ -126,6 +131,7 @@ def cli(ctx: click.Context, host: str | None, port: int | None) -> None:
 
 cli.add_command(app_cmd)
 cli.add_command(channels_group)
+cli.add_command(daemon_group)
 cli.add_command(chats_group)
 cli.add_command(clean_cmd)
 cli.add_command(cron_group)
diff --git a/src/copaw/utils/logging.py b/src/copaw/utils/logging.py
index eeb1a1b2f..c32f81a98 100644
--- a/src/copaw/utils/logging.py
+++ b/src/copaw/utils/logging.py
@@ -1,8 +1,15 @@
 # -*- coding: utf-8 -*-
+"""Logging setup for CoPaw: console output and optional file handler."""
 import logging
+import logging.handlers
 import os
 import platform
 import sys
+from pathlib import Path
+
+# Rotating file handler limits (idempotent add avoids duplicate handlers)
+_COPAW_LOG_MAX_BYTES = 5 * 1024 * 1024  # 5 MiB
+_COPAW_LOG_BACKUP_COUNT = 3
 
 
 _LEVEL_MAP = {
@@ -118,3 +125,32 @@ def setup_logger(level: int | str = logging.INFO):
         logger.addHandler(handler)
 
     return logger
+
+
+def add_copaw_file_handler(log_path: Path) -> None:
+    """Add a RotatingFileHandler to the copaw logger for /daemon logs.
+
+    Idempotent: if the logger already has a file handler for the same path,
+    no new handler is added (avoids duplicate lines and leaked descriptors
+    when lifespan runs multiple times in the same process).
+
+    Args:
+        log_path: Path to the log file (e.g. WORKING_DIR / "copaw.log").
+    """
+    log_path = Path(log_path).resolve()
+    log_path.parent.mkdir(parents=True, exist_ok=True)
+    logger = logging.getLogger(LOG_NAMESPACE)
+    for handler in logger.handlers:
+        base = getattr(handler, "baseFilename", None)
+        if base is not None and Path(base).resolve() == log_path:
+            return
+    file_handler = logging.handlers.RotatingFileHandler(
+        log_path,
+        encoding="utf-8",
+        maxBytes=_COPAW_LOG_MAX_BYTES,
+        backupCount=_COPAW_LOG_BACKUP_COUNT,
+    )
+    file_handler.setFormatter(
+        logging.Formatter("%(asctime)s | %(message)s", "%Y-%m-%d %H:%M:%S"),
+    )
+    logger.addHandler(file_handler)
diff --git a/website/public/docs/cli.en.md b/website/public/docs/cli.en.md
index 7a8f1c0f2..0c41ae7a3 100644
--- a/website/public/docs/cli.en.md
+++ b/website/public/docs/cli.en.md
@@ -69,6 +69,26 @@ If the frontend was not built, the root URL returns a JSON message like `{"messa
 `npm ci && npm run build` (output in `src/copaw/console/`). Docker images and pip
 packages already include the Console.
 
+### copaw daemon
+
+Inspect status, version, and recent logs without starting a conversation. Same
+behavior as sending `/daemon status` etc. in chat (CLI can show local info when
+the app is not running).
+
+| Command                      | Description                                                                               |
+| ---------------------------- | ----------------------------------------------------------------------------------------- |
+| `copaw daemon status`        | Status (config, working dir, memory manager)                                              |
+| `copaw daemon restart`       | Print instructions (in-chat /daemon restart does in-process reload)                       |
+| `copaw daemon reload-config` | Re-read and validate config (channel/MCP changes need /daemon restart or process restart) |
+| `copaw daemon version`       | Version and paths                                                                         |
+| `copaw daemon logs [-n N]`   | Last N lines of log (default 100; from `copaw.log` in working dir)                        |
+
+```bash
+copaw daemon status
+copaw daemon version
+copaw daemon logs -n 50
+```
+
 ---
 
 ## Models & environment variables
diff --git a/website/public/docs/cli.zh.md b/website/public/docs/cli.zh.md
index abc3acfe1..642f9a874 100644
--- a/website/public/docs/cli.zh.md
+++ b/website/public/docs/cli.zh.md
@@ -62,6 +62,24 @@ copaw app --log-level debug           # 详细日志
 **构建方式：** 在项目 `console/` 目录下执行 `npm ci && npm run build`，产物在
 `src/copaw/console/`。Docker 镜像或 pip 安装包已内置控制台，无需单独构建。
 
+### copaw daemon
+
+查看运行状态、版本、最近日志等，无需启动对话。与在对话中发送 `/daemon status` 等效果一致（CLI 无进程时可查看本地信息）。
+
+| 命令                         | 说明                                                                           |
+| ---------------------------- | ------------------------------------------------------------------------------ |
+| `copaw daemon status`        | 状态（配置、工作目录、记忆服务）                                               |
+| `copaw daemon restart`       | 打印说明（在对话中用 /daemon restart 可进程内重载）                            |
+| `copaw daemon reload-config` | 重新读取并校验配置（频道/MCP 变更需在对话中 /daemon restart 或重启进程后生效） |
+| `copaw daemon version`       | 版本与路径                                                                     |
+| `copaw daemon logs [-n N]`   | 最近 N 行日志（默认 100，来自工作目录 `copaw.log`）                            |
+
+```bash
+copaw daemon status
+copaw daemon version
+copaw daemon logs -n 50
+```
+
 ---
 
 ## 模型与环境变量
diff --git a/website/public/docs/commands.en.md b/website/public/docs/commands.en.md
index 96b7b2548..16e881217 100644
--- a/website/public/docs/commands.en.md
+++ b/website/public/docs/commands.en.md
@@ -1,8 +1,6 @@
-# System Commands
+# Magic Commands
 
-> **Experimental**: System commands do not yet fully cover all scenarios and edge cases; using them may lead to errors or unexpected behavior. Please rely on actual behavior as the source of truth.
-
-**System commands** are special instructions prefixed with `/` that let you directly control conversation state without waiting for the AI to interpret your intent.
+**Magic commands** are special instructions prefixed with `/` that let you directly control conversation state without waiting for the AI to interpret your intent.
 
 Five commands are currently supported:
 
@@ -154,6 +152,28 @@ Example response (when no summary):
 
 ---
 
+## Daemon commands (ops)
+
+In chat, send `/daemon <subcommand>` or use short names (e.g. `/status`). From the terminal, run `copaw daemon <subcommand>`. These run without the Agent.
+
+| Command                             | Description                                                                                  |
+| ----------------------------------- | -------------------------------------------------------------------------------------------- |
+| `/daemon status` or `/status`       | Show status (config, working dir, memory manager)                                            |
+| `/daemon restart` or `/restart`     | In-process restart (channels, cron, MCP) when in chat; from CLI prints instructions          |
+| `/daemon reload-config`             | Re-read and validate config (channel/MCP changes require /daemon restart or process restart) |
+| `/daemon version`                   | Version and paths (working dir, log file)                                                    |
+| `/daemon logs` or `/daemon logs 50` | Last N lines of console log (default 100; from `copaw.log` in working dir)                   |
+
+From the terminal:
+
+```bash
+copaw daemon status
+copaw daemon version
+copaw daemon logs -n 50
+```
+
+---
+
 ## Related Pages
 
 - [Introduction](./intro.en.md) — What this project can do
diff --git a/website/public/docs/commands.zh.md b/website/public/docs/commands.zh.md
index f52a23f7b..e6934c6a5 100644
--- a/website/public/docs/commands.zh.md
+++ b/website/public/docs/commands.zh.md
@@ -1,8 +1,6 @@
-# 系统命令
+# 魔法命令
 
-> **试验性功能**：系统命令尚未充分覆盖所有场景与边界情况，使用中仍可能遇到报错或异常，请以实际行为为准。
-
-系统命令是一组以 `/` 开头的特殊指令，让你可以**直接控制对话状态**，而不需要等 AI 理解你的意图。
+魔法命令是一组以 `/` 开头的特殊指令，让你可以**直接控制对话状态**，而不需要等 AI 理解你的意图。
 
 | 命令           | 需要等待 | 压缩摘要      | 长期记忆    | 消息历史      | 上下文占用          |
 | -------------- | -------- | ------------- | ----------- | ------------- | ------------------- |
@@ -137,3 +135,25 @@
 - No summary has been generated yet
 - Use /compact or wait for auto-compaction
 ```
+
+---
+
+## Daemon 命令（运维）
+
+在对话中发送 `/daemon <子命令>` 或在终端执行 `copaw daemon <子命令>`，可查看状态、最近日志、版本等，无需经过 Agent。支持短名（如 `/status` 等价于 `/daemon status`）。
+
+| 命令                                | 说明                                                                   |
+| ----------------------------------- | ---------------------------------------------------------------------- |
+| `/daemon status` 或 `/status`       | 查看运行状态（配置、工作目录、记忆服务等）                             |
+| `/daemon restart` 或 `/restart`     | 在对话中为进程内重启（频道、定时任务、MCP）；在 CLI 下仅打印说明       |
+| `/daemon reload-config`             | 重新读取并校验配置（频道/MCP 变更需 /daemon restart 或重启进程后生效） |
+| `/daemon version`                   | 版本号与工作目录、日志路径                                             |
+| `/daemon logs` 或 `/daemon logs 50` | 查看最近 N 行控制台日志（默认 100 行，来自工作目录下 `copaw.log`）     |
+
+终端中可直接使用：
+
+```bash
+copaw daemon status
+copaw daemon version
+copaw daemon logs -n 50
+```
diff --git a/website/src/pages/Docs.tsx b/website/src/pages/Docs.tsx
index 47b2df4d7..50a5867ea 100644
--- a/website/src/pages/Docs.tsx
+++ b/website/src/pages/Docs.tsx
@@ -204,11 +204,9 @@ const DOC_SLUGS: DocEntry[] = [
   {
     slug: "memory",
     titleKey: "docs.memory",
-    children: [
-      { slug: "compact", titleKey: "docs.compact" },
-      { slug: "commands", titleKey: "docs.commands" },
-    ],
+    children: [{ slug: "compact", titleKey: "docs.compact" }],
   },
+  { slug: "commands", titleKey: "docs.commands" },
   { slug: "heartbeat", titleKey: "docs.heartbeat" },
   { slug: "config", titleKey: "docs.config" },
   { slug: "cli", titleKey: "docs.cli" },
@@ -240,7 +238,7 @@ const DOC_TITLES: Record<Lang, Record<string, string>> = {
     "docs.memory": "记忆",
     "docs.compact": "压缩",
     "docs.config": "配置与工作目录",
-    "docs.commands": "系统命令",
+    "docs.commands": "魔法命令",
     "docs.faq": "FAQ 常见问题",
     "docs.community": "问题反馈与交流",
     "docs.contributing": "开源与贡献",
@@ -258,7 +256,7 @@ const DOC_TITLES: Record<Lang, Record<string, string>> = {
     "docs.memory": "Memory",
     "docs.compact": "Compaction",
     "docs.config": "Config & working dir",
-    "docs.commands": "Commands",
+    "docs.commands": "Magic commands",
     "docs.faq": "FAQ",
     "docs.community": "Bug reports & community",
     "docs.contributing": "Open source & contribution",


Test patch:
diff --git a/tests/test_command_dispatch.py b/tests/test_command_dispatch.py
new file mode 100644
index 000000000..18d800356
--- /dev/null
+++ b/tests/test_command_dispatch.py
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+"""Tests for command dispatch and daemon commands (sync helpers)."""
+import asyncio
+
+from copaw.agents.command_handler import (
+    CommandHandler,
+    ConversationCommandHandlerMixin,
+)
+from copaw.app.runner.command_dispatch import (
+    _get_last_user_text,
+    _is_command,
+    _is_conversation_command,
+)
+from copaw.app.runner.daemon_commands import (
+    DaemonContext,
+    parse_daemon_query,
+    run_daemon_logs,
+    run_daemon_restart,
+    run_daemon_status,
+    run_daemon_version,
+)
+
+
+def test_conversation_commands_set() -> None:
+    """Conversation command set is unchanged."""
+    assert ConversationCommandHandlerMixin.SYSTEM_COMMANDS == (
+        CommandHandler.SYSTEM_COMMANDS
+    )
+    assert CommandHandler.SYSTEM_COMMANDS == frozenset(
+        {
+            "compact",
+            "new",
+            "clear",
+            "history",
+            "compact_str",
+            "await_summary",
+        },
+    )
+
+
+def test_is_conversation_command() -> None:
+    """Conversation commands are detected."""
+    assert _is_conversation_command("/compact") is True
+    assert _is_conversation_command("/new") is True
+    assert _is_conversation_command("/unknown") is False
+    assert _is_conversation_command("hello") is False
+    assert _is_conversation_command(None) is False
+
+
+def test_is_command_daemon_and_short() -> None:
+    """Daemon and short names are commands."""
+    assert _is_command("/daemon status") is True
+    assert _is_command("/restart") is True
+    assert _is_command("/status") is True
+    assert _is_command("/daemon logs") is True
+    assert _is_command("/compact") is True
+    assert _is_command("hello") is False
+
+
+def test_get_last_user_text() -> None:
+    """Last user text is extracted from msgs."""
+
+    class MsgWithText:
+        def get_text_content(self) -> str:
+            return "/daemon status"
+
+    assert _get_last_user_text([MsgWithText()]) == "/daemon status"
+    assert _get_last_user_text([]) is None
+    assert _get_last_user_text(None) is None
+
+
+def test_parse_daemon_query() -> None:
+    """Daemon query parsing."""
+    assert parse_daemon_query("/daemon status") == ("status", [])
+    assert parse_daemon_query("/daemon restart") == ("restart", [])
+    assert parse_daemon_query("/restart") == ("restart", [])
+    assert parse_daemon_query("/status") == ("status", [])
+    assert parse_daemon_query("/daemon logs 50") == ("logs", ["50"])
+    assert parse_daemon_query("/daemon version") == ("version", [])
+    assert parse_daemon_query("/compact") is None
+    assert parse_daemon_query("hello") is None
+
+
+def test_run_daemon_status() -> None:
+    """run_daemon_status returns string with working dir."""
+    ctx = DaemonContext()
+    out = run_daemon_status(ctx)
+    assert "Working dir" in out
+    assert "Config" in out or "Memory" in out
+
+
+def test_run_daemon_version() -> None:
+    """run_daemon_version returns string with version."""
+    ctx = DaemonContext()
+    out = run_daemon_version(ctx)
+    assert "version" in out.lower() or "Version" in out
+    assert "copaw.log" in out
+
+
+def test_run_daemon_restart_no_callback() -> None:
+    """run_daemon_restart without callback returns instructions."""
+    ctx = DaemonContext(restart_callback=None)
+    out = asyncio.run(run_daemon_restart(ctx))
+    assert "restart" in out.lower() or "systemd" in out.lower()
+
+
+def test_run_daemon_logs_missing_file() -> None:
+    """run_daemon_logs handles missing file."""
+    ctx = DaemonContext()
+    out = run_daemon_logs(ctx, lines=5)
+    assert "last" in out.lower() or "log" in out.lower()
+    assert "not found" in out or "empty" in out or "(" in out
+
+
+async def test_daemon_restart_with_callback_returns_completed() -> None:
+    """run_daemon_restart with a no-op callback returns completion text.
+
+    When _do_restart_services excludes the restart-requester task (via
+    restart_requester_task), the callback runs to completion and
+    run_daemon_restart returns \"Restart completed\". This test verifies
+    the daemon layer; full flow (run_command_path hint then completion)
+    is covered by integration when the requester task is not cancelled.
+    """
+
+    async def noop_restart() -> None:
+        await asyncio.sleep(0)
+
+    ctx = DaemonContext(restart_callback=noop_restart)
+    out = await run_daemon_restart(ctx)
+    assert (
+        "completed" in out.lower() or "完成" in out
+    ), f"Expected completion message, got: {out[:200]}"