feat(gateway): add --install-daemon to run picoclaw as system service

Add daemon management flags to the gateway subcommand using
kardianos/service, supporting Linux (systemd), macOS (launchd),
and Windows (SCM).

New flags:
  --install-daemon    Install as system service
  --uninstall-daemon  Uninstall system service
  --start-daemon      Start the installed service
  --stop-daemon       Stop the installed service
  --daemon-status     Show service status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
czyt 2026-02-13 14:46:56 +08:00
parent 9edfe98ba1
commit f903cd047c
5 changed files with 216 additions and 42 deletions

View file

@ -403,8 +403,34 @@ picoclaw agent -m "Hello"
| `picoclaw agent -m "..."` | エージェントとチャット |
| `picoclaw agent` | インタラクティブチャットモード |
| `picoclaw gateway` | ゲートウェイを起動 |
| `picoclaw gateway --install-daemon` | システムサービスとしてインストール |
| `picoclaw gateway --uninstall-daemon` | システムサービスをアンインストール |
| `picoclaw gateway --start-daemon` | インストール済みサービスを起動 |
| `picoclaw gateway --stop-daemon` | インストール済みサービスを停止 |
| `picoclaw gateway --daemon-status` | サービスの状態を表示 |
| `picoclaw status` | ステータスを表示 |
### システムサービス(デーモン)として実行
PicoClaw は [kardianos/service](https://github.com/kardianos/service) を使用してシステムサービスとしてインストールできます。**Linux (systemd)**、**macOS (launchd)**、**Windows (SCM)** に対応しています。
```bash
# システムサービスとしてインストールLinux では root/sudo が必要)
sudo picoclaw gateway --install-daemon
# サービスを起動
sudo picoclaw gateway --start-daemon
# サービスの状態を確認
picoclaw gateway --daemon-status
# サービスを停止
sudo picoclaw gateway --stop-daemon
# サービスをアンインストール
sudo picoclaw gateway --uninstall-daemon
```
## 🤝 コントリビュート&ロードマップ
PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗

View file

@ -527,6 +527,11 @@ picoclaw agent -m "Hello"
| `picoclaw agent -m "..."` | Chat with the agent |
| `picoclaw agent` | Interactive chat mode |
| `picoclaw gateway` | Start the gateway |
| `picoclaw gateway --install-daemon` | Install as system service |
| `picoclaw gateway --uninstall-daemon` | Uninstall system service |
| `picoclaw gateway --start-daemon` | Start the installed service |
| `picoclaw gateway --stop-daemon` | Stop the installed service |
| `picoclaw gateway --daemon-status` | Show service status |
| `picoclaw status` | Show status |
| `picoclaw cron list` | List all scheduled jobs |
| `picoclaw cron add ...` | Add a scheduled job |
@ -541,6 +546,31 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too
Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
### Run as System Service (Daemon)
PicoClaw can be installed as a system service using [kardianos/service](https://github.com/kardianos/service), which supports **Linux (systemd)**, **macOS (launchd)**, and **Windows (SCM)**.
```bash
# Install as a system service (requires root/sudo on Linux)
sudo picoclaw gateway --install-daemon
# Start the service
sudo picoclaw gateway --start-daemon
# Check service status
picoclaw gateway --daemon-status
# Stop the service
sudo picoclaw gateway --stop-daemon
# Uninstall the service
sudo picoclaw gateway --uninstall-daemon
```
> [!NOTE]
> The service runs as the user who installed it, so it can access `~/.picoclaw/config.json`.
> To install with debug logging enabled, use `sudo picoclaw gateway --install-daemon --debug`.
## 🤝 Contribute & Roadmap
PRs welcome! The codebase is intentionally small and readable. 🤗

View file

@ -12,13 +12,14 @@ import (
"fmt"
"io"
"os"
"os/signal"
"os/user"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/chzyer/readline"
"github.com/kardianos/service"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/bus"
@ -180,6 +181,11 @@ func printHelp() {
fmt.Println(" agent Interact with the agent directly")
fmt.Println(" auth Manage authentication (login, logout, status)")
fmt.Println(" gateway Start picoclaw gateway")
fmt.Println(" --install-daemon Install as system service")
fmt.Println(" --uninstall-daemon Uninstall system service")
fmt.Println(" --start-daemon Start the installed service")
fmt.Println(" --stop-daemon Stop the installed service")
fmt.Println(" --daemon-status Show service status")
fmt.Println(" status Show picoclaw status")
fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
@ -605,35 +611,68 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
}
}
func gatewayCmd() {
// Check for --debug flag
args := os.Args[2:]
for _, arg := range args {
if arg == "--debug" || arg == "-d" {
// gatewayProgram implements service.Interface for running picoclaw as a system daemon.
type gatewayProgram struct {
debug bool
ctx context.Context
cancel context.CancelFunc
cronService *cron.CronService
heartbeatService *heartbeat.HeartbeatService
agentLoop *agent.AgentLoop
channelManager *channels.Manager
}
func (p *gatewayProgram) Start(s service.Service) error {
p.ctx, p.cancel = context.WithCancel(context.Background())
go p.run()
return nil
}
func (p *gatewayProgram) Stop(s service.Service) error {
fmt.Println("\nShutting down...")
p.cancel()
if p.heartbeatService != nil {
p.heartbeatService.Stop()
}
if p.cronService != nil {
p.cronService.Stop()
}
if p.agentLoop != nil {
p.agentLoop.Stop()
}
if p.channelManager != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
p.channelManager.StopAll(shutdownCtx)
}
fmt.Println("✓ Gateway stopped")
return nil
}
func (p *gatewayProgram) run() {
if p.debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
break
}
}
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
return
}
provider, err := providers.CreateProvider(cfg)
if err != nil {
fmt.Printf("Error creating provider: %v\n", err)
os.Exit(1)
return
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
p.agentLoop = agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info
fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
startupInfo := p.agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]interface{})
skillsInfo := startupInfo["skills"].(map[string]interface{})
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
@ -650,19 +689,20 @@ func gatewayCmd() {
})
// Setup cron tool and service
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath())
p.cronService = setupCronTool(p.agentLoop, msgBus, cfg.WorkspacePath())
heartbeatService := heartbeat.NewHeartbeatService(
p.heartbeatService = heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
nil,
30*60,
true,
)
channelManager, err := channels.NewManager(cfg, msgBus)
if err != nil {
fmt.Printf("Error creating channel manager: %v\n", err)
os.Exit(1)
var channelErr error
p.channelManager, channelErr = channels.NewManager(cfg, msgBus)
if channelErr != nil {
fmt.Printf("Error creating channel manager: %v\n", channelErr)
return
}
var transcriber *voice.GroqTranscriber
@ -672,19 +712,19 @@ func gatewayCmd() {
}
if transcriber != nil {
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
if telegramChannel, ok := p.channelManager.GetChannel("telegram"); ok {
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
tc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
}
}
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
if discordChannel, ok := p.channelManager.GetChannel("discord"); ok {
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
dc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Discord channel")
}
}
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
if slackChannel, ok := p.channelManager.GetChannel("slack"); ok {
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
sc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Slack channel")
@ -692,7 +732,7 @@ func gatewayCmd() {
}
}
enabledChannels := channelManager.GetEnabledChannels()
enabledChannels := p.channelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
} else {
@ -700,38 +740,113 @@ func gatewayCmd() {
}
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
if service.Interactive() {
fmt.Println("Press Ctrl+C to stop")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := cronService.Start(); err != nil {
if err := p.cronService.Start(); err != nil {
fmt.Printf("Error starting cron service: %v\n", err)
}
fmt.Println("✓ Cron service started")
if err := heartbeatService.Start(); err != nil {
if err := p.heartbeatService.Start(); err != nil {
fmt.Printf("Error starting heartbeat service: %v\n", err)
}
fmt.Println("✓ Heartbeat service started")
if err := channelManager.StartAll(ctx); err != nil {
if err := p.channelManager.StartAll(p.ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
}
go agentLoop.Run(ctx)
// Run agent loop - blocks until ctx is cancelled
p.agentLoop.Run(p.ctx)
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
<-sigChan
func gatewayCmd() {
args := os.Args[2:]
fmt.Println("\nShutting down...")
cancel()
heartbeatService.Stop()
cronService.Stop()
agentLoop.Stop()
channelManager.StopAll(ctx)
fmt.Println("✓ Gateway stopped")
var debug bool
var daemonAction string
for _, arg := range args {
switch arg {
case "--debug", "-d":
debug = true
case "--install-daemon":
daemonAction = "install"
case "--uninstall-daemon":
daemonAction = "uninstall"
case "--start-daemon":
daemonAction = "start"
case "--stop-daemon":
daemonAction = "stop"
case "--daemon-status":
daemonAction = "status"
}
}
execPath, err := os.Executable()
if err != nil {
fmt.Printf("Error finding executable path: %v\n", err)
os.Exit(1)
}
serviceArgs := []string{"gateway"}
if debug {
serviceArgs = append(serviceArgs, "--debug")
}
svcConfig := &service.Config{
Name: "picoclaw",
DisplayName: "PicoClaw Gateway",
Description: "PicoClaw personal AI assistant gateway service",
Executable: execPath,
Arguments: serviceArgs,
}
// Run as the current user so the service can access ~/.picoclaw
if u, err := user.Current(); err == nil {
svcConfig.UserName = u.Username
}
prg := &gatewayProgram{debug: debug}
s, err := service.New(prg, svcConfig)
if err != nil {
fmt.Printf("Error creating service: %v\n", err)
os.Exit(1)
}
if daemonAction != "" {
if daemonAction == "status" {
status, err := s.Status()
if err != nil {
fmt.Printf("Error getting service status: %v\n", err)
os.Exit(1)
}
switch status {
case service.StatusRunning:
fmt.Println("Service is running")
case service.StatusStopped:
fmt.Println("Service is stopped")
default:
fmt.Printf("Service status: unknown (%v)\n", status)
}
return
}
err := service.Control(s, daemonAction)
if err != nil {
fmt.Printf("Error: failed to %s service: %v\n", daemonAction, err)
os.Exit(1)
}
fmt.Printf("Service %sed successfully\n", daemonAction)
return
}
// Run the service (works both interactively and as a system daemon)
if err := s.Run(); err != nil {
fmt.Printf("Error running service: %v\n", err)
os.Exit(1)
}
}
func statusCmd() {

1
go.mod
View file

@ -10,6 +10,7 @@ require (
github.com/chzyer/readline v1.5.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/kardianos/service v1.2.4
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mymmrac/telego v1.6.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1

2
go.sum
View file

@ -66,6 +66,8 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=