Daemon CLI 工具

后台常驻 CLI 工具开发指南,涵盖进程管理、心跳机制、HTTP API 架构和守护进程生命周期。

Daemon CLI 工具在后台持续运行,通过 HTTP API 供 CLI 命令交互。适用于需要长连接、高初始化成本、持续任务执行或共享状态的场景。

阅读本页前,请先了解适用于所有工具类型的通用规范

为什么需要 Daemon

普通 CLI 是「调用即退出」的模式,但有些场景下这种模式存在根本性的局限:

1. 外部连接需要保活

以微信消息代理(wechat_agent)为例:微信网页协议需要维持一个长连接来接收消息。如果每次发消息都重新登录、建立连接,不仅速度慢(需要扫码),而且频繁登录会触发风控。Daemon 模式下,只需登录一次,连接就一直保持,后续操作直接复用。

2. 初始化成本高昂

某些工具的启动过程涉及耗时操作:加载大量配置、建立数据库连接池、初始化 SDK 等。如果每次调用都重复这些初始化,性能完全无法接受。Daemon 将这些成本一次性支付,后续调用直接使用已初始化的资源。

3. 需要持续运行的任务

定时调度(agent_cron)需要在后台按计划执行任务,即使用户关闭了终端也不能中断。这本质上需要一个常驻进程来承载调度器。

4. 共享状态与资源

多个 CLI 调用可能需要共享同一份状态(如微信的登录会话、已加载的联系人列表)。Daemon 作为中心进程持有这些状态,所有 CLI 调用通过 API 访问,避免了状态同步的问题。

总结: 当你的工具需要满足以下任一条件时,就应该考虑 Daemon 模式:

  • 需要维持长连接或外部会话
  • 初始化成本高,需要复用资源
  • 需要在后台持续执行任务
  • 多次调用之间需要共享状态

架构概览

Daemon CLI 采用 CLI + 后台进程 + HTTP API 的三层架构:

┌──────────────────┐          HTTP API          ┌───────────────────┐
│   CLI 命令       │  ─────────────────────►   │   Daemon 进程     │
│                  │                            │                   │
│  tool start      │  启动 daemon               │  常驻后台运行     │
│  tool status     │  查询状态                  │  提供 HTTP API    │
│  tool stop       │  停止 daemon               │  管理会话/心跳    │
│  tool send       │  业务操作                  │                   │
└──────────────────┘          ◄────────────────  └───────────────────┘
                              JSON 响应

用户和 AI Agent 仍然通过 CLI 命令交互,感知上与普通 CLI 无异。区别在于:CLI 命令不再直接执行业务逻辑,而是将请求转发给后台 Daemon 进程,由 Daemon 执行后返回结果。

项目结构

wechat_agent/
├── main.go
├── go.mod
├── cmd/
│   ├── root.go           # 命令路由、--help / --skill
│   ├── skill.go          # skillSpec 常量
│   ├── server.go         # daemon 启动与进程管理
│   ├── client.go         # CLI → daemon 的 HTTP 请求封装
│   ├── send.go           # 发送消息(通过 API 转发)
│   ├── contacts.go       # 查询联系人(通过 API 转发)
│   ├── status.go         # 查询 daemon 状态
│   ├── stop.go           # 停止 daemon
│   ├── daemon_unix.go    # Unix 进程分离
│   └── daemon_windows.go # Windows 进程分离
└── internal/
    └── output/
        └── output.go

Daemon 生命周期

启动

采用「自启动」模式:CLI 命令启动自身的一个子进程并立即分离:

func startDaemon(dataDir string, port int) {
    exePath, _ := os.Executable()
    childArgs := []string{"start", "--foreground", "--port", strconv.Itoa(port), "--data-dir", dataDir}

    cmd := exec.Command(exePath, childArgs...)
    cmd.Stdout = logFile
    cmd.Stderr = logFile
    cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}

    cmd.Start()
    cmd.Process.Release()
}

关键点:

  • 默认 tool start 以后台方式启动,用户终端不会被阻塞
  • 子进程使用 --foreground 参数来真正运行服务(用于调试或进程管理器场景)
  • Setsid: true 让进程脱离终端会话,终端关闭不影响 Daemon
  • 日志输出到文件而非终端

状态文件

Daemon 通过文件系统与 CLI 共享连接信息:

~/.my_tool/
├── daemon.pid        # 进程 PID —— 用于检测是否存活
├── daemon.port       # HTTP 服务端口 —— CLI 通过此端口连接
├── session.json      # 业务会话状态(如微信登录态)
└── daemon.log        # 运行日志
func WriteDaemonPort(port int) error {
    return os.WriteFile(portFilePath(), []byte(strconv.Itoa(port)), 0644)
}

func ReadDaemonPort() (int, error) {
    data, err := os.ReadFile(portFilePath())
    if err != nil {
        return 0, err
    }
    return strconv.Atoi(strings.TrimSpace(string(data)))
}

func IsDaemonRunning() bool {
    pid, err := ReadPID()
    if err != nil {
        return false
    }
    return isProcessAlive(pid)
}

停止

var daemonStopCmd = &cobra.Command{
    Use:   "stop",
    Short: "Stop the running daemon",
    RunE: func(cmd *cobra.Command, args []string) error {
        if !session.IsDaemonRunning() {
            output.Print(GetJSONOutput(), map[string]interface{}{
                "message": "daemon not running",
            }, nil)
            return nil
        }

        resp, err := daemon.Stop()
        if err != nil {
            session.CleanDaemonFiles()
            output.Success(GetJSONOutput(), "daemon stopped")
            return nil
        }
        // ...
    },
}

Daemon HTTP API

Daemon 进程在本地随机端口启动 HTTP 服务,仅监听 127.0.0.1

mux := http.NewServeMux()
mux.HandleFunc("/api/status", s.handleStatus)
mux.HandleFunc("/api/stop", s.handleStop)
mux.HandleFunc("/api/jobs/list", s.handleJobsList)

s.listener, _ = net.Listen("tcp", "127.0.0.1:0")
s.httpPort = s.listener.Addr().(*net.TCPAddr).Port
session.WriteDaemonPort(s.httpPort)

CLI 命令通过读取端口文件找到 Daemon 并发起请求:

func getServerPort(dataDir string) (int, error) {
    data, err := os.ReadFile(portFilePath(dataDir))
    if err != nil {
        return 0, fmt.Errorf("daemon not running (no port file)")
    }
    return strconv.Atoi(strings.TrimSpace(string(data)))
}

func apiGet(dataDir, path string) (*http.Response, error) {
    port, err := getServerPort(dataDir)
    if err != nil {
        return nil, err
    }
    url := fmt.Sprintf("http://127.0.0.1:%d%s", port, path)
    return http.Get(url)
}

心跳机制

对于需要维持外部连接的 Daemon(如微信长连接),必须实现心跳来保持连接活性并处理异常:

bot.MessageErrorHandler = func(err error) error {
    log.Printf("[心跳] 同步异常: %v(将自动重试)", err)
    return nil
}

心跳设计要点:

  • 错误不应终止进程,而是记录日志后自动重试
  • 连接断开时尝试重新建立
  • 提供状态查询接口,让 CLI 和 AI Agent 能感知连接健康度

Daemon 子命令设计

推荐使用 daemon 子命令组(或直接作为顶级命令)来管理守护进程生命周期:

daemonCmd = &cobra.Command{
    Use:   "daemon <start|status|stop>",
    Short: "Manage the daemon process",
}

daemonStartCmd = &cobra.Command{
    Use:   "start",
    Short: "Start the daemon",
}

daemonStatusCmd = &cobra.Command{
    Use:   "status",
    Short: "Show daemon status",
}

daemonStopCmd = &cobra.Command{
    Use:   "stop",
    Short: "Stop the running daemon",
}

func init() {
    rootCmd.AddCommand(daemonCmd)
    daemonCmd.AddCommand(daemonStartCmd)
    daemonCmd.AddCommand(daemonStatusCmd)
    daemonCmd.AddCommand(daemonStopCmd)

    daemonStartCmd.Flags().BoolVar(&foreground, "foreground", false,
        "Run daemon in foreground (blocks terminal)")
}

对于 wechat_agent 这类工具,start / status / stop 直接作为顶级命令,因为 Daemon 是该工具的唯一运行方式:

wechat_agent start      # 启动 daemon(默认后台)
wechat_agent status     # 查询状态
wechat_agent stop       # 停止 daemon
wechat_agent send ...   # 业务命令(自动转发到 daemon)

--help 中的 Daemon 说明

Daemon CLI 的 --help 需要额外说明架构模式,让用户理解工具的运行方式:

fmt.Fprint(w, `wechat_agent - 微信消息助手

重要: 使用前请务必通过如下命令获取完整使用指南:
  wechat_agent --skill

架构:
  采用守护进程模式。start 命令默认以后台 daemon 运行(终端关闭不影响),
  然后使用 contacts/send 等命令操作,会话在进程存活期间一直有效。

用法:
  wechat_agent start     [flags]              启动守护进程
  wechat_agent contacts  [flags]              获取联系人列表
  wechat_agent send      [flags] "消息文本"    发送消息
  wechat_agent status    [flags]              查看状态
  wechat_agent stop      [flags]              停止守护进程
`)

实战案例:agent_cron

agent_cron 是一个基于 Daemon 模式的定时任务调度器,完整展示了 Daemon CLI 的设计模式。它支持两种周期性任务:

  • 脚本任务 — 按计划执行本地脚本/可执行文件
  • 外部任务 — 按计划向 WinClaw 本地开放接口 提交 prompt

Daemon 负责任务调度、执行和结果持久化,CLI 提供熟悉的命令行界面进行任务管理:

agent_cron daemon start                        # 启动调度器守护进程
agent_cron job add --type external_task \       # 添加定时 AI 任务
  --name "morning-report" \
  --cron "0 0 9 * * 1-5" \
  --prompt "请总结昨天的工作进展"
agent_cron job ls                              # 列出所有任务
agent_cron job run <id>                        # 立即触发一个任务
agent_cron job logs <id>                       # 查看执行历史

外部任务类型使用 WinClaw 本地开放接口 将 prompt 发送给 WinClaw 执行,实现定时自动运行 AI 任务。

开发检查清单

  • 通用规范中的所有基础要求
  • start / status / stop 生命周期管理命令
  • --foreground 标志(用于调试和进程管理器)
  • PID 文件和端口文件管理
  • 进程分离(Unix: Setsid,Windows: CREATE_NEW_PROCESS_GROUP
  • 日志输出到文件
  • HTTP API 仅监听 127.0.0.1
  • 心跳机制(如需维持外部连接)
  • 优雅停止(清理资源、删除状态文件)
  • --help 中说明 Daemon 架构