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 -m "..."` | エージェントとチャット |
| `picoclaw agent` | インタラクティブチャットモード | | `picoclaw agent` | インタラクティブチャットモード |
| `picoclaw gateway` | ゲートウェイを起動 | | `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 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 歓迎!コードベースは意図的に小さく読みやすくしています。🤗 PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗

View file

@ -527,6 +527,11 @@ picoclaw agent -m "Hello"
| `picoclaw agent -m "..."` | Chat with the agent | | `picoclaw agent -m "..."` | Chat with the agent |
| `picoclaw agent` | Interactive chat mode | | `picoclaw agent` | Interactive chat mode |
| `picoclaw gateway` | Start the gateway | | `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 status` | Show status |
| `picoclaw cron list` | List all scheduled jobs | | `picoclaw cron list` | List all scheduled jobs |
| `picoclaw cron add ...` | Add a scheduled job | | `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. 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 ## 🤝 Contribute & Roadmap
PRs welcome! The codebase is intentionally small and readable. 🤗 PRs welcome! The codebase is intentionally small and readable. 🤗

View file

@ -12,13 +12,14 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"os/signal" "os/user"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"time" "time"
"github.com/chzyer/readline" "github.com/chzyer/readline"
"github.com/kardianos/service"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -180,6 +181,11 @@ func printHelp() {
fmt.Println(" agent Interact with the agent directly") fmt.Println(" agent Interact with the agent directly")
fmt.Println(" auth Manage authentication (login, logout, status)") fmt.Println(" auth Manage authentication (login, logout, status)")
fmt.Println(" gateway Start picoclaw gateway") 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(" status Show picoclaw status")
fmt.Println(" cron Manage scheduled tasks") fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
@ -605,35 +611,68 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
} }
} }
func gatewayCmd() { // gatewayProgram implements service.Interface for running picoclaw as a system daemon.
// Check for --debug flag type gatewayProgram struct {
args := os.Args[2:] debug bool
for _, arg := range args { ctx context.Context
if arg == "--debug" || arg == "-d" { 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) logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled") fmt.Println("🔍 Debug mode enabled")
break
}
} }
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) fmt.Printf("Error loading config: %v\n", err)
os.Exit(1) return
} }
provider, err := providers.CreateProvider(cfg) provider, err := providers.CreateProvider(cfg)
if err != nil { if err != nil {
fmt.Printf("Error creating provider: %v\n", err) fmt.Printf("Error creating provider: %v\n", err)
os.Exit(1) return
} }
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) p.agentLoop = agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info // Print agent startup info
fmt.Println("\n📦 Agent Status:") fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo() startupInfo := p.agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]interface{}) toolsInfo := startupInfo["tools"].(map[string]interface{})
skillsInfo := startupInfo["skills"].(map[string]interface{}) skillsInfo := startupInfo["skills"].(map[string]interface{})
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
@ -650,19 +689,20 @@ func gatewayCmd() {
}) })
// Setup cron tool and service // 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(), cfg.WorkspacePath(),
nil, nil,
30*60, 30*60,
true, true,
) )
channelManager, err := channels.NewManager(cfg, msgBus) var channelErr error
if err != nil { p.channelManager, channelErr = channels.NewManager(cfg, msgBus)
fmt.Printf("Error creating channel manager: %v\n", err) if channelErr != nil {
os.Exit(1) fmt.Printf("Error creating channel manager: %v\n", channelErr)
return
} }
var transcriber *voice.GroqTranscriber var transcriber *voice.GroqTranscriber
@ -672,19 +712,19 @@ func gatewayCmd() {
} }
if transcriber != nil { 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 { if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
tc.SetTranscriber(transcriber) tc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Telegram channel") 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 { if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
dc.SetTranscriber(transcriber) dc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Discord channel") 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 { if sc, ok := slackChannel.(*channels.SlackChannel); ok {
sc.SetTranscriber(transcriber) sc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Slack channel") 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 { if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
} else { } else {
@ -700,38 +740,113 @@ func gatewayCmd() {
} }
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
if service.Interactive() {
fmt.Println("Press Ctrl+C to stop") fmt.Println("Press Ctrl+C to stop")
}
ctx, cancel := context.WithCancel(context.Background()) if err := p.cronService.Start(); err != nil {
defer cancel()
if err := cronService.Start(); err != nil {
fmt.Printf("Error starting cron service: %v\n", err) fmt.Printf("Error starting cron service: %v\n", err)
} }
fmt.Println("✓ Cron service started") 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.Printf("Error starting heartbeat service: %v\n", err)
} }
fmt.Println("✓ Heartbeat service started") 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) 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) func gatewayCmd() {
signal.Notify(sigChan, os.Interrupt) args := os.Args[2:]
<-sigChan
fmt.Println("\nShutting down...") var debug bool
cancel() var daemonAction string
heartbeatService.Stop()
cronService.Stop() for _, arg := range args {
agentLoop.Stop() switch arg {
channelManager.StopAll(ctx) case "--debug", "-d":
fmt.Println("✓ Gateway stopped") 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() { func statusCmd() {

1
go.mod
View file

@ -10,6 +10,7 @@ require (
github.com/chzyer/readline v1.5.1 github.com/chzyer/readline v1.5.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 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/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mymmrac/telego v1.6.0 github.com/mymmrac/telego v1.6.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 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 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= 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/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/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 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= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=