moki 3 долоо хоног өмнө
commit
c0a30d6e30

+ 10 - 0
.idea/.gitignore

@@ -0,0 +1,10 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 已忽略包含查询文件的默认文件夹
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/

+ 9 - 0
.idea/AI-Status-Light.iml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="WEB_MODULE" version="4">
+  <component name="Go" enabled="true" />
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 11 - 0
.idea/amazonq.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="qAccountSettings">
+    <option name="activeRegion" value="us-east-1" />
+    <option name="recentlyUsedRegions">
+      <list>
+        <option value="us-east-1" />
+      </list>
+    </option>
+  </component>
+</project>

+ 11 - 0
.idea/aws.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="accountSettings">
+    <option name="activeRegion" value="us-east-1" />
+    <option name="recentlyUsedRegions">
+      <list>
+        <option value="us-east-1" />
+      </list>
+    </option>
+  </component>
+</project>

+ 11 - 0
.idea/go.imports.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="GoImports">
+    <option name="excludedPackages">
+      <array>
+        <option value="github.com/pkg/errors" />
+        <option value="golang.org/x/net/context" />
+      </array>
+    </option>
+  </component>
+</project>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/AI-Status-Light.iml" filepath="$PROJECT_DIR$/.idea/AI-Status-Light.iml" />
+    </modules>
+  </component>
+</project>

+ 59 - 0
Makefile

@@ -0,0 +1,59 @@
+.PHONY: build clean tidy run serve build-all build-linux build-darwin build-windows
+
+BINARY_NAME=opencode-monitor
+VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
+
+build:
+	go mod tidy
+	go build -ldflags "-X main.Version=$(VERSION)" -o bin/$(BINARY_NAME) ./cmd/monitor
+
+clean:
+	rm -rf bin/ dist/
+
+tidy:
+	go mod tidy
+
+run: build
+	./bin/$(BINARY_NAME) monitor --ports 4096
+
+serve: build
+	./bin/$(BINARY_NAME) serve --addr :8080
+
+monitor-with-api: build
+	./bin/$(BINARY_NAME) monitor --ports 4096 --api-addr :8080
+
+config-list: build
+	./bin/$(BINARY_NAME) config list
+
+config-set: build
+	./bin/$(BINARY_NAME) config set --broker tcp://127.0.0.1:1883
+
+build-all: build-linux build-darwin build-windows
+
+build-linux:
+	@echo "Building for Linux..."
+	@mkdir -p dist
+	GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-linux-amd64 ./cmd/monitor
+	GOOS=linux GOARCH=arm64 go build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-linux-arm64 ./cmd/monitor
+
+build-darwin:
+	@echo "Building for macOS..."
+	@mkdir -p dist
+	GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-darwin-amd64 ./cmd/monitor
+	GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-darwin-arm64 ./cmd/monitor
+
+build-windows:
+	@echo "Building for Windows..."
+	@mkdir -p dist
+	GOOS=windows GOARCH=amd64 go build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-windows-amd64.exe ./cmd/monitor
+	GOOS=windows GOARCH=arm64 go build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-windows-arm64.exe ./cmd/monitor
+
+release: build-all
+	@echo "Creating release archives..."
+	@cd dist && tar -czf $(BINARY_NAME)-linux-amd64.tar.gz $(BINARY_NAME)-linux-amd64
+	@cd dist && tar -czf $(BINARY_NAME)-linux-arm64.tar.gz $(BINARY_NAME)-linux-arm64
+	@cd dist && tar -czf $(BINARY_NAME)-darwin-amd64.tar.gz $(BINARY_NAME)-darwin-amd64
+	@cd dist && tar -czf $(BINARY_NAME)-darwin-arm64.tar.gz $(BINARY_NAME)-darwin-arm64
+	@cd dist && zip -q $(BINARY_NAME)-windows-amd64.zip $(BINARY_NAME)-windows-amd64.exe
+	@cd dist && zip -q $(BINARY_NAME)-windows-arm64.zip $(BINARY_NAME)-windows-arm64.exe
+	@echo "Release archives created in dist/"

+ 423 - 0
cmd/monitor/main.go

@@ -0,0 +1,423 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"os"
+	"os/signal"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	"AI-Status-Light/internal/api"
+	"AI-Status-Light/internal/database"
+	"AI-Status-Light/internal/discovery"
+	"AI-Status-Light/internal/event"
+	"AI-Status-Light/internal/monitor"
+	mqttcli "AI-Status-Light/internal/mqtt"
+)
+
+const defaultDBPath = "./data/config.db"
+
+var Version = "dev"
+
+func main() {
+	if len(os.Args) < 2 {
+		printUsage()
+		return
+	}
+
+	switch os.Args[1] {
+	case "monitor":
+		runMonitor(os.Args[2:])
+	case "config":
+		runConfig(os.Args[2:])
+	case "serve":
+		runServe(os.Args[2:])
+	case "version":
+		fmt.Printf("opencode-monitor %s\n", Version)
+	default:
+		printUsage()
+	}
+}
+
+func printUsage() {
+	fmt.Printf("opencode-monitor %s\n\n", Version)
+	fmt.Println("用法: opencode-monitor <命令> [选项]")
+	fmt.Println("")
+	fmt.Println("命令:")
+	fmt.Println("  monitor    启动监控")
+	fmt.Println("  config     管理 MQTT 配置")
+	fmt.Println("  serve      启动 API 服务")
+	fmt.Println("  version    显示版本信息")
+	fmt.Println("")
+	fmt.Println("运行 'opencode-monitor <命令> -h' 查看命令帮助")
+}
+
+func runServe(args []string) {
+	fs := flag.NewFlagSet("serve", flag.ExitOnError)
+	addr := fs.String("addr", ":8080", "监听地址")
+	dbPath := fs.String("db", defaultDBPath, "数据库路径")
+	fs.Parse(args)
+
+	db, err := database.New(*dbPath)
+	if err != nil {
+		fmt.Printf("打开数据库失败: %v\n", err)
+		return
+	}
+	defer db.Close()
+
+	server := api.New(db, *addr)
+	fmt.Printf("API 服务已启动: %s\n", *addr)
+	fmt.Println("接口文档:")
+	fmt.Println("  GET    /api/health       - 健康检查")
+	fmt.Println("  GET    /api/mqtt         - 获取所有配置")
+	fmt.Println("  POST   /api/mqtt         - 创建配置")
+	fmt.Println("  GET    /api/mqtt/:id     - 获取单个配置")
+	fmt.Println("  PUT    /api/mqtt/:id     - 更新配置")
+	fmt.Println("  DELETE /api/mqtt/:id     - 删除配置")
+
+	if err := server.Start(); err != nil {
+		fmt.Printf("服务启动失败: %v\n", err)
+	}
+}
+
+func runMonitor(args []string) {
+	fs := flag.NewFlagSet("monitor", flag.ExitOnError)
+	host := fs.String("host", "127.0.0.1", "主机地址")
+	portsFlag := fs.String("ports", "", "端口列表,逗号分隔 (如: 4096,4097,4098)")
+	scanFlag := fs.String("scan", "", "扫描端口范围 (如: 4096-4100)")
+	pidFlag := fs.Bool("pid", false, "通过进程号查找端口")
+	intervalFlag := fs.Int("interval", 5, "动态扫描间隔(秒), 默认5")
+	dbPath := fs.String("db", defaultDBPath, "数据库路径")
+	apiAddr := fs.String("api-addr", "", "API 服务地址 (如: :8080)")
+	fs.Parse(args)
+
+	var scanRange *[2]int
+	if *scanFlag != "" {
+		parts := strings.Split(*scanFlag, "-")
+		if len(parts) == 2 {
+			start, err1 := strconv.Atoi(parts[0])
+			end, err2 := strconv.Atoi(parts[1])
+			if err1 == nil && err2 == nil {
+				scanRange = &[2]int{start, end}
+			}
+		}
+	}
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+	db, err := database.New(*dbPath)
+	if err != nil {
+		fmt.Printf("打开数据库失败: %v\n", err)
+		return
+	}
+	defer db.Close()
+
+	if *apiAddr != "" {
+		apiServer := api.New(db, *apiAddr)
+		go func() {
+			fmt.Printf("API 服务已启动: %s\n", *apiAddr)
+			if err := apiServer.Start(); err != nil {
+				fmt.Printf("API 服务失败: %v\n", err)
+			}
+		}()
+	}
+
+	var mqttClient *mqttcli.Client
+	cfg, err := db.GetMQTTConfig()
+	if err != nil {
+		fmt.Printf("读取 MQTT 配置失败: %v\n", err)
+	} else if cfg != nil {
+		mqttClient = mqttcli.NewFromConfig(cfg)
+		if err := mqttClient.Connect(); err != nil {
+			fmt.Printf("MQTT 连接失败: %v\n", err)
+			mqttClient = nil
+		} else {
+			defer mqttClient.Disconnect()
+			fmt.Printf("MQTT 已连接: %s (主题: %s)\n", cfg.Broker, cfg.Topic)
+		}
+	}
+
+	callback := createCallback(mqttClient)
+
+	if *portsFlag != "" {
+		runFixedMode(ctx, *host, *portsFlag, callback, sigChan)
+		cancel()
+		return
+	}
+
+	if scanRange != nil {
+		fmt.Printf("扫描端口范围 %d-%d...\n", scanRange[0], scanRange[1])
+	} else if *pidFlag {
+		fmt.Println("通过进程号查找 OpenCode 端口...")
+	} else {
+		fmt.Println("查找 OpenCode 实例...")
+	}
+
+	runDynamicMode(ctx, *host, scanRange, *intervalFlag, callback, sigChan)
+	cancel()
+}
+
+func runConfig(args []string) {
+	if len(args) < 1 {
+		printConfigUsage()
+		return
+	}
+
+	dbPath := defaultDBPath
+	for i, arg := range args {
+		if arg == "--db" && i+1 < len(args) {
+			dbPath = args[i+1]
+			break
+		}
+	}
+
+	db, err := database.New(dbPath)
+	if err != nil {
+		fmt.Printf("打开数据库失败: %v\n", err)
+		return
+	}
+	defer db.Close()
+
+	switch args[0] {
+	case "list":
+		configs, err := db.ListMQTTConfigs()
+		if err != nil {
+			fmt.Printf("查询失败: %v\n", err)
+			return
+		}
+		if len(configs) == 0 {
+			fmt.Println("未配置 MQTT")
+			return
+		}
+		for _, cfg := range configs {
+			status := "禁用"
+			if cfg.Enabled {
+				status = "启用"
+			}
+			fmt.Printf("[%d] %s | %s | %s | %s\n", cfg.ID, cfg.Broker, cfg.ClientID, cfg.Topic, status)
+		}
+
+	case "set":
+		fs := flag.NewFlagSet("config set", flag.ExitOnError)
+		broker := fs.String("broker", "", "MQTT Broker 地址 (如: tcp://127.0.0.1:1883)")
+		clientID := fs.String("client-id", "opencode-monitor", "MQTT 客户端 ID")
+		topic := fs.String("topic", "opencode/status", "MQTT 主题")
+		enabled := fs.Bool("enabled", true, "是否启用")
+		fs.Parse(args[1:])
+
+		if *broker == "" {
+			fmt.Println("必须指定 --broker")
+			return
+		}
+
+		cfg := &database.MQTTConfig{
+			Broker:   *broker,
+			ClientID: *clientID,
+			Topic:    *topic,
+			Enabled:  *enabled,
+		}
+
+		if err := db.SaveMQTTConfig(cfg); err != nil {
+			fmt.Printf("保存失败: %v\n", err)
+			return
+		}
+		fmt.Println("配置已保存")
+
+	case "delete":
+		if len(args) < 2 {
+			fmt.Println("必须指定配置 ID")
+			return
+		}
+		id, err := strconv.Atoi(args[1])
+		if err != nil {
+			fmt.Println("无效的 ID")
+			return
+		}
+		if err := db.DeleteMQTTConfig(id); err != nil {
+			fmt.Printf("删除失败: %v\n", err)
+			return
+		}
+		fmt.Println("配置已删除")
+
+	default:
+		printConfigUsage()
+	}
+}
+
+func printConfigUsage() {
+	fmt.Println("用法: opencode-monitor config <子命令> [选项]")
+	fmt.Println("")
+	fmt.Println("子命令:")
+	fmt.Println("  list              列出所有配置")
+	fmt.Println("  set               设置 MQTT 配置")
+	fmt.Println("  delete <id>       删除配置")
+	fmt.Println("")
+	fmt.Println("选项:")
+	fmt.Println("  --broker          MQTT Broker 地址")
+	fmt.Println("  --client-id       MQTT 客户端 ID")
+	fmt.Println("  --topic           MQTT 主题")
+	fmt.Println("  --enabled         是否启用 (true/false)")
+	fmt.Println("  --db              数据库路径")
+}
+
+func createCallback(mqttClient *mqttcli.Client) monitor.EventCallback {
+	return func(port int, evt *event.SSEEvent) {
+		msg := event.FormatEvent(port, evt)
+		if msg != "" {
+			fmt.Println(msg)
+		}
+
+		if mqttClient == nil {
+			return
+		}
+
+		var evtType, status, tool, state, title string
+		evtType = evt.Type
+
+		switch evt.Type {
+		case "session.status":
+			if s, ok := evt.Properties["status"].(map[string]interface{}); ok {
+				status = event.ParseStatus(s)
+			}
+		case "session.idle":
+			status = "空闲"
+		case "message.part.updated":
+			if part, ok := evt.Properties["part"].(map[string]interface{}); ok {
+				pt, _ := part["type"].(string)
+				switch pt {
+				case "tool":
+					tool, _ = part["tool"].(string)
+					if st, ok := part["state"].(map[string]interface{}); ok {
+						state = event.ParseToolState(st)
+					}
+				case "reasoning":
+					state = "思考中"
+				}
+			}
+		case "permission.updated":
+			title, _ = evt.Properties["title"].(string)
+		case "session.error":
+			status = "错误"
+		}
+
+		if err := mqttClient.Publish(port, evtType, status, tool, state, title); err != nil {
+			fmt.Printf("MQTT 发送失败: %v\n", err)
+		}
+	}
+}
+
+func runFixedMode(ctx context.Context, host string, portsFlag string, callback monitor.EventCallback, sigChan chan os.Signal) {
+	var ports []int
+	for _, p := range strings.Split(portsFlag, ",") {
+		p = strings.TrimSpace(p)
+		if port, err := strconv.Atoi(p); err == nil {
+			ports = append(ports, port)
+		}
+	}
+
+	if len(ports) == 0 {
+		fmt.Println("未指定端口")
+		return
+	}
+
+	fmt.Printf("监控端口: %v\n", ports)
+	fmt.Println("Ctrl+C 停止")
+	fmt.Println(strings.Repeat("-", 40))
+
+	var wg sync.WaitGroup
+	for _, port := range ports {
+		wg.Add(1)
+		go func(p int) {
+			defer wg.Done()
+			m := monitor.New(host, p, callback)
+			m.Run(ctx)
+		}(port)
+	}
+
+	<-sigChan
+	fmt.Println("\n已停止")
+	wg.Wait()
+}
+
+func runDynamicMode(ctx context.Context, host string, scanRange *[2]int, interval int, callback monitor.EventCallback, sigChan chan os.Signal) {
+	scanner := discovery.NewScanner(host, scanRange)
+
+	monitoredPorts := make(map[int]bool)
+	runningMonitors := make(map[int]context.CancelFunc)
+	var mu sync.Mutex
+
+	startMonitor := func(port int) {
+		mu.Lock()
+		if _, exists := runningMonitors[port]; exists {
+			mu.Unlock()
+			return
+		}
+		monitorCtx, monitorCancel := context.WithCancel(ctx)
+		runningMonitors[port] = monitorCancel
+		mu.Unlock()
+
+		go func() {
+			m := monitor.New(host, port, callback)
+			m.Run(monitorCtx)
+		}()
+	}
+
+	initial := scanner.Discover()
+	if len(initial) == 0 {
+		fmt.Println("未找到运行中的 OpenCode 实例")
+		fmt.Println("请先执行: opencode serve --port 4096")
+		fmt.Println("启动后会自动检测,等待中...")
+	}
+
+	for _, port := range initial {
+		monitoredPorts[port] = true
+		startMonitor(port)
+	}
+
+	if len(monitoredPorts) > 0 {
+		ports := make([]int, 0, len(monitoredPorts))
+		for p := range monitoredPorts {
+			ports = append(ports, p)
+		}
+		sort.Ints(ports)
+		fmt.Printf("找到 %d 个实例: %v\n", len(monitoredPorts), ports)
+	}
+
+	fmt.Printf("每 %d 秒扫描新实例,Ctrl+C 停止\n", interval)
+	fmt.Println(strings.Repeat("-", 40))
+
+	scanTicker := time.NewTicker(time.Duration(interval) * time.Second)
+	defer scanTicker.Stop()
+
+	go func() {
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-scanTicker.C:
+				newPorts := scanner.Discover()
+				for _, port := range newPorts {
+					mu.Lock()
+					if !monitoredPorts[port] {
+						monitoredPorts[port] = true
+						startMonitor(port)
+					}
+					mu.Unlock()
+				}
+			}
+		}
+	}()
+
+	<-sigChan
+	fmt.Println("\n已停止")
+}

+ 189 - 0
docs/api.md

@@ -0,0 +1,189 @@
+# OpenCode Monitor API 接口文档
+
+## 基础信息
+
+- Base URL: `http://localhost:8080`
+- Content-Type: `application/json`
+- 响应格式: `{ "code": 0, "message": "ok", "data": {} }`
+
+## 接口列表
+
+### 1. 健康检查
+
+```
+GET /api/health
+```
+
+**响应示例:**
+```json
+{
+  "code": 0,
+  "message": "ok"
+}
+```
+
+### 2. 获取所有 MQTT 配置
+
+```
+GET /api/mqtt
+```
+
+**响应示例:**
+```json
+{
+  "code": 0,
+  "message": "ok",
+  "data": [
+    {
+      "id": 1,
+      "broker": "tcp://127.0.0.1:1883",
+      "client_id": "opencode-monitor",
+      "topic": "opencode/status",
+      "enabled": true
+    }
+  ]
+}
+```
+
+### 3. 创建 MQTT 配置
+
+```
+POST /api/mqtt
+```
+
+**请求体:**
+```json
+{
+  "broker": "tcp://127.0.0.1:1883",
+  "client_id": "opencode-monitor",
+  "topic": "opencode/status",
+  "enabled": true
+}
+```
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+|------|------|------|--------|------|
+| broker | string | 是 | - | MQTT Broker 地址 |
+| client_id | string | 否 | opencode-monitor | 客户端 ID |
+| topic | string | 否 | opencode/status | 主题前缀 |
+| enabled | boolean | 否 | true | 是否启用 |
+
+**响应示例:**
+```json
+{
+  "code": 0,
+  "message": "创建成功",
+  "data": {
+    "id": 1,
+    "broker": "tcp://127.0.0.1:1883",
+    "client_id": "opencode-monitor",
+    "topic": "opencode/status",
+    "enabled": true
+  }
+}
+```
+
+### 4. 获取单个 MQTT 配置
+
+```
+GET /api/mqtt/:id
+```
+
+**路径参数:**
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| id | integer | 配置 ID |
+
+**响应示例:**
+```json
+{
+  "code": 0,
+  "message": "ok",
+  "data": {
+    "id": 1,
+    "broker": "tcp://127.0.0.1:1883",
+    "client_id": "opencode-monitor",
+    "topic": "opencode/status",
+    "enabled": true
+  }
+}
+```
+
+### 5. 更新 MQTT 配置
+
+```
+PUT /api/mqtt/:id
+```
+
+**路径参数:**
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| id | integer | 配置 ID |
+
+**请求体:**
+```json
+{
+  "broker": "tcp://192.168.1.100:1883",
+  "client_id": "my-monitor",
+  "topic": "my/topic",
+  "enabled": false
+}
+```
+
+**响应示例:**
+```json
+{
+  "code": 0,
+  "message": "更新成功",
+  "data": {
+    "id": 1,
+    "broker": "tcp://192.168.1.100:1883",
+    "client_id": "my-monitor",
+    "topic": "my/topic",
+    "enabled": false
+  }
+}
+```
+
+### 6. 删除 MQTT 配置
+
+```
+DELETE /api/mqtt/:id
+```
+
+**路径参数:**
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| id | integer | 配置 ID |
+
+**响应示例:**
+```json
+{
+  "code": 0,
+  "message": "删除成功"
+}
+```
+
+## 错误响应
+
+所有错误响应格式:
+```json
+{
+  "code": -1,
+  "message": "错误信息"
+}
+```
+
+## 启动方式
+
+```bash
+# 方式1: 独立启动 API 服务
+./bin/opencode-monitor serve --addr :8080
+
+# 方式2: 监控时同时启动 API 服务
+./bin/opencode-monitor monitor --ports 4096 --api-addr :8080
+```
+
+## CORS
+
+API 已启用 CORS,支持跨域请求。

+ 8 - 0
go.mod

@@ -0,0 +1,8 @@
+module AI-Status-Light
+
+go 1.21
+
+require (
+	github.com/eclipse/paho.mqtt.golang v1.4.3
+	github.com/mattn/go-sqlite3 v1.14.17
+)

+ 2 - 0
go.sum

@@ -0,0 +1,2 @@
+github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
+github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=

+ 190 - 0
internal/api/api.go

@@ -0,0 +1,190 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"AI-Status-Light/internal/database"
+)
+
+type Server struct {
+	db     *database.DB
+	server *http.Server
+}
+
+type Response struct {
+	Code    int         `json:"code"`
+	Message string      `json:"message"`
+	Data    interface{} `json:"data,omitempty"`
+}
+
+func New(db *database.DB, addr string) *Server {
+	s := &Server{db: db}
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/api/mqtt", s.handleMQTT)
+	mux.HandleFunc("/api/mqtt/", s.handleMQTTByID)
+	mux.HandleFunc("/api/health", s.handleHealth)
+
+	s.server = &http.Server{
+		Addr:    addr,
+		Handler: corsMiddleware(mux),
+	}
+	return s
+}
+
+func (s *Server) Start() error {
+	return s.server.ListenAndServe()
+}
+
+func corsMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Access-Control-Allow-Origin", "*")
+		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+
+		if r.Method == "OPTIONS" {
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+
+		next.ServeHTTP(w, r)
+	})
+}
+
+func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "ok"})
+}
+
+func (s *Server) handleMQTT(w http.ResponseWriter, r *http.Request) {
+	switch r.Method {
+	case http.MethodGet:
+		s.listMQTTConfigs(w, r)
+	case http.MethodPost:
+		s.createMQTTConfig(w, r)
+	default:
+		writeJSON(w, http.StatusMethodNotAllowed, Response{Code: -1, Message: "方法不允许"})
+	}
+}
+
+func (s *Server) handleMQTTByID(w http.ResponseWriter, r *http.Request) {
+	idStr := strings.TrimPrefix(r.URL.Path, "/api/mqtt/")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "无效的 ID"})
+		return
+	}
+
+	switch r.Method {
+	case http.MethodGet:
+		s.getMQTTConfig(w, id)
+	case http.MethodPut:
+		s.updateMQTTConfig(w, r, id)
+	case http.MethodDelete:
+		s.deleteMQTTConfig(w, id)
+	default:
+		writeJSON(w, http.StatusMethodNotAllowed, Response{Code: -1, Message: "方法不允许"})
+	}
+}
+
+func (s *Server) listMQTTConfigs(w http.ResponseWriter, r *http.Request) {
+	configs, err := s.db.ListMQTTConfigs()
+	if err != nil {
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "ok", Data: configs})
+}
+
+func (s *Server) createMQTTConfig(w http.ResponseWriter, r *http.Request) {
+	var cfg database.MQTTConfig
+	if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "无效的请求体"})
+		return
+	}
+
+	if cfg.Broker == "" {
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "broker 不能为空"})
+		return
+	}
+
+	if cfg.ClientID == "" {
+		cfg.ClientID = "opencode-monitor"
+	}
+	if cfg.Topic == "" {
+		cfg.Topic = "opencode/status"
+	}
+
+	if err := s.db.SaveMQTTConfig(&cfg); err != nil {
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusCreated, Response{Code: 0, Message: "创建成功", Data: cfg})
+}
+
+func (s *Server) getMQTTConfig(w http.ResponseWriter, id int) {
+	configs, err := s.db.ListMQTTConfigs()
+	if err != nil {
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+
+	for _, cfg := range configs {
+		if cfg.ID == id {
+			writeJSON(w, http.StatusOK, Response{Code: 0, Message: "ok", Data: cfg})
+			return
+		}
+	}
+
+	writeJSON(w, http.StatusNotFound, Response{Code: -1, Message: "配置不存在"})
+}
+
+func (s *Server) updateMQTTConfig(w http.ResponseWriter, r *http.Request, id int) {
+	var cfg database.MQTTConfig
+	if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "无效的请求体"})
+		return
+	}
+
+	cfg.ID = id
+	if cfg.Broker == "" {
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "broker 不能为空"})
+		return
+	}
+
+	if cfg.ClientID == "" {
+		cfg.ClientID = "opencode-monitor"
+	}
+	if cfg.Topic == "" {
+		cfg.Topic = "opencode/status"
+	}
+
+	if err := s.db.SaveMQTTConfig(&cfg); err != nil {
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "更新成功", Data: cfg})
+}
+
+func (s *Server) deleteMQTTConfig(w http.ResponseWriter, id int) {
+	if err := s.db.DeleteMQTTConfig(id); err != nil {
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "删除成功"})
+}
+
+func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(statusCode)
+	json.NewEncoder(w).Encode(data)
+}
+
+func (s *Server) GetAddr() string {
+	return s.server.Addr
+}

+ 115 - 0
internal/database/database.go

@@ -0,0 +1,115 @@
+package database
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	_ "github.com/mattn/go-sqlite3"
+)
+
+type DB struct {
+	conn *sql.Conn
+}
+
+type MQTTConfig struct {
+	ID       int    `json:"id"`
+	Broker   string `json:"broker"`
+	ClientID string `json:"client_id"`
+	Topic    string `json:"topic"`
+	Enabled  bool   `json:"enabled"`
+}
+
+func New(dbPath string) (*DB, error) {
+	dir := filepath.Dir(dbPath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return nil, fmt.Errorf("创建目录失败: %w", err)
+	}
+
+	db, err := sql.Open("sqlite3", dbPath)
+	if err != nil {
+		return nil, fmt.Errorf("打开数据库失败: %w", err)
+	}
+
+	conn, err := db.Conn(context.Background())
+	if err != nil {
+		return nil, fmt.Errorf("获取连接失败: %w", err)
+	}
+
+	d := &DB{conn: conn}
+	if err := d.init(); err != nil {
+		return nil, err
+	}
+	return d, nil
+}
+
+func (d *DB) init() error {
+	query := `
+	CREATE TABLE IF NOT EXISTS mqtt_config (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		broker TEXT NOT NULL,
+		client_id TEXT NOT NULL,
+		topic TEXT NOT NULL,
+		enabled BOOLEAN DEFAULT 1
+	);
+	`
+	_, err := d.conn.ExecContext(context.Background(), query)
+	return err
+}
+
+func (d *DB) GetMQTTConfig() (*MQTTConfig, error) {
+	query := "SELECT id, broker, client_id, topic, enabled FROM mqtt_config WHERE enabled = 1 LIMIT 1"
+	row := d.conn.QueryRowContext(context.Background(), query)
+
+	var cfg MQTTConfig
+	err := row.Scan(&cfg.ID, &cfg.Broker, &cfg.ClientID, &cfg.Topic, &cfg.Enabled)
+	if err == sql.ErrNoRows {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, fmt.Errorf("查询配置失败: %w", err)
+	}
+	return &cfg, nil
+}
+
+func (d *DB) SaveMQTTConfig(cfg *MQTTConfig) error {
+	if cfg.ID == 0 {
+		query := "INSERT INTO mqtt_config (broker, client_id, topic, enabled) VALUES (?, ?, ?, ?)"
+		_, err := d.conn.ExecContext(context.Background(), query, cfg.Broker, cfg.ClientID, cfg.Topic, cfg.Enabled)
+		return err
+	}
+	query := "UPDATE mqtt_config SET broker = ?, client_id = ?, topic = ?, enabled = ? WHERE id = ?"
+	_, err := d.conn.ExecContext(context.Background(), query, cfg.Broker, cfg.ClientID, cfg.Topic, cfg.Enabled, cfg.ID)
+	return err
+}
+
+func (d *DB) DeleteMQTTConfig(id int) error {
+	query := "DELETE FROM mqtt_config WHERE id = ?"
+	_, err := d.conn.ExecContext(context.Background(), query, id)
+	return err
+}
+
+func (d *DB) ListMQTTConfigs() ([]MQTTConfig, error) {
+	query := "SELECT id, broker, client_id, topic, enabled FROM mqtt_config ORDER BY id"
+	rows, err := d.conn.QueryContext(context.Background(), query)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var configs []MQTTConfig
+	for rows.Next() {
+		var cfg MQTTConfig
+		if err := rows.Scan(&cfg.ID, &cfg.Broker, &cfg.ClientID, &cfg.Topic, &cfg.Enabled); err != nil {
+			continue
+		}
+		configs = append(configs, cfg)
+	}
+	return configs, nil
+}
+
+func (d *DB) Close() error {
+	return d.conn.Close()
+}

+ 68 - 0
internal/discovery/discovery.go

@@ -0,0 +1,68 @@
+package discovery
+
+import (
+	"fmt"
+	"net/http"
+	"sort"
+	"time"
+)
+
+type Scanner struct {
+	Host      string
+	ScanRange *[2]int
+}
+
+func NewScanner(host string, scanRange *[2]int) *Scanner {
+	return &Scanner{
+		Host:      host,
+		ScanRange: scanRange,
+	}
+}
+
+func (s *Scanner) Discover() []int {
+	ports := findByCmdline()
+	if len(ports) == 0 {
+		ports = findByPID()
+	}
+	if len(ports) == 0 && s.ScanRange != nil {
+		ports = scanPorts(s.Host, s.ScanRange[0], s.ScanRange[1])
+	}
+	return unique(ports)
+}
+
+func scanPorts(host string, startPort, endPort int) []int {
+	var found []int
+	client := http.Client{Timeout: 1 * time.Second}
+
+	for port := startPort; port <= endPort; port++ {
+		resp, err := client.Get(fmt.Sprintf("http://%s:%d/global/health", host, port))
+		if err == nil && resp.StatusCode == 200 {
+			found = append(found, port)
+			resp.Body.Close()
+		}
+	}
+
+	return found
+}
+
+func contains(slice []int, val int) bool {
+	for _, v := range slice {
+		if v == val {
+			return true
+		}
+	}
+	return false
+}
+
+func unique(slice []int) []int {
+	keys := make(map[int]bool)
+	var result []int
+	for _, v := range slice {
+		if !keys[v] {
+			keys[v] = true
+			result = append(result, v)
+		}
+	}
+	sort.Ints(result)
+	return result
+}

+ 142 - 0
internal/discovery/discovery_darwin.go

@@ -0,0 +1,142 @@
+//go:build darwin
+
+package discovery
+
+import (
+	"fmt"
+	"os/exec"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+func findByPID() []int {
+	var ports []int
+	pidRegex := regexp.MustCompile(`\bopencode\b`)
+
+	out, err := exec.Command("ps", "aux").Output()
+	if err != nil {
+		return ports
+	}
+
+	var pids []int
+	for _, line := range strings.Split(string(out), "\n") {
+		if pidRegex.MatchString(line) && !strings.Contains(line, "grep") {
+			fields := strings.Fields(line)
+			if len(fields) > 1 {
+				if pid, err := strconv.Atoi(fields[1]); err == nil {
+					pids = append(pids, pid)
+				}
+			}
+		}
+	}
+
+	for _, pid := range pids {
+		lsofOut, err := exec.Command("lsof", "-p", strconv.Itoa(pid), "-i", "TCP", "-n").Output()
+		if err != nil {
+			continue
+		}
+
+		for _, line := range strings.Split(string(lsofOut), "\n") {
+			if strings.Contains(line, "LISTEN") {
+				fields := strings.Fields(line)
+				for _, f := range fields {
+					if strings.Contains(f, ":") {
+						parts := strings.Split(f, ":")
+						if len(parts) >= 2 {
+							if port, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
+								if !contains(ports, port) {
+									ports = append(ports, port)
+								}
+							}
+						}
+					}
+				}
+			}
+		}
+
+		netstatOut, err := exec.Command("netstat", "-an").Output()
+		if err == nil {
+			for _, line := range strings.Split(string(netstatOut), "\n") {
+				if strings.Contains(line, "LISTEN") {
+					fields := strings.Fields(line)
+					if len(fields) >= 4 {
+						addr := fields[3]
+						if strings.Contains(addr, ":") {
+							parts := strings.Split(addr, ":")
+							if len(parts) >= 2 {
+								if port, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
+									if port > 1024 && port < 65535 && !contains(ports, port) {
+										ports = append(ports, port)
+									}
+								}
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	sort.Ints(ports)
+	return ports
+}
+
+func findByCmdline() []int {
+	var ports []int
+
+	out, err := exec.Command("ps", "aux").Output()
+	if err != nil {
+		return ports
+	}
+
+	for _, line := range strings.Split(string(out), "\n") {
+		if strings.Contains(line, "opencode") && !strings.Contains(line, "grep") {
+			if strings.Contains(line, "--port") {
+				fields := strings.Fields(line)
+				for i, f := range fields {
+					if f == "--port" && i+1 < len(fields) {
+						if port, err := strconv.Atoi(fields[i+1]); err == nil {
+							if !contains(ports, port) {
+								ports = append(ports, port)
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	sort.Ints(ports)
+	return ports
+}
+
+func getListeningPorts(pid int) []int {
+	var ports []int
+
+	lsofOut, err := exec.Command("lsof", "-p", strconv.Itoa(pid), "-i", "TCP", "-n", "-P").Output()
+	if err != nil {
+		return ports
+	}
+
+	for _, line := range strings.Split(string(lsofOut), "\n") {
+		if strings.Contains(line, "LISTEN") {
+			fields := strings.Fields(line)
+			for _, f := range fields {
+				if strings.Contains(f, ":") {
+					parts := strings.Split(f, ":")
+					if len(parts) >= 2 {
+						if port, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
+							if !contains(ports, port) {
+								ports = append(ports, port)
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return ports
+}

+ 112 - 0
internal/discovery/discovery_linux.go

@@ -0,0 +1,112 @@
+//go:build linux
+
+package discovery
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+func findByPID() []int {
+	var ports []int
+	pidRegex := regexp.MustCompile(`\bopencode\b`)
+
+	out, err := exec.Command("ps", "aux").Output()
+	if err != nil {
+		return ports
+	}
+
+	var pids []int
+	for _, line := range strings.Split(string(out), "\n") {
+		if pidRegex.MatchString(line) && !strings.Contains(line, "grep") {
+			fields := strings.Fields(line)
+			if len(fields) > 1 {
+				if pid, err := strconv.Atoi(fields[1]); err == nil {
+					pids = append(pids, pid)
+				}
+			}
+		}
+	}
+
+	for _, pid := range pids {
+		ssOut, err := exec.Command("ss", "-tlnp").Output()
+		if err != nil {
+			continue
+		}
+
+		for _, line := range strings.Split(string(ssOut), "\n") {
+			if strings.Contains(line, fmt.Sprintf("pid=%d", pid)) {
+				fields := strings.Fields(line)
+				for _, f := range fields {
+					if strings.Contains(f, ":") {
+						parts := strings.Split(f, ":")
+						if len(parts) == 2 {
+							if port, err := strconv.Atoi(parts[1]); err == nil {
+								if !contains(ports, port) {
+									ports = append(ports, port)
+								}
+							}
+						}
+					}
+				}
+			}
+		}
+
+		tcpFile := fmt.Sprintf("/proc/%d/net/tcp", pid)
+		if data, err := os.ReadFile(tcpFile); err == nil {
+			for _, line := range strings.Split(string(data), "\n") {
+				fields := strings.Fields(line)
+				if len(fields) > 1 {
+					localAddr := fields[1]
+					if strings.Contains(localAddr, ":") {
+						parts := strings.Split(localAddr, ":")
+						if len(parts) == 2 {
+							if port, err := strconv.ParseInt(parts[1], 16, 32); err == nil {
+								if port > 1024 && port < 65535 && !contains(ports, int(port)) {
+									ports = append(ports, int(port))
+								}
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	sort.Ints(ports)
+	return ports
+}
+
+func findByCmdline() []int {
+	var ports []int
+
+	out, err := exec.Command("ps", "aux").Output()
+	if err != nil {
+		return ports
+	}
+
+	for _, line := range strings.Split(string(out), "\n") {
+		if strings.Contains(line, "opencode") && !strings.Contains(line, "grep") {
+			if strings.Contains(line, "--port") {
+				fields := strings.Fields(line)
+				for i, f := range fields {
+					if f == "--port" && i+1 < len(fields) {
+						if port, err := strconv.Atoi(fields[i+1]); err == nil {
+							if !contains(ports, port) {
+								ports = append(ports, port)
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	sort.Ints(ports)
+	return ports
+}

+ 113 - 0
internal/discovery/discovery_windows.go

@@ -0,0 +1,113 @@
+//go:build windows
+
+package discovery
+
+import (
+	"os/exec"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+func findByPID() []int {
+	var ports []int
+	pidRegex := regexp.MustCompile(`(?i)opencode`)
+
+	tasklistOut, err := exec.Command("tasklist", "/FO", "CSV", "/NH").Output()
+	if err != nil {
+		return ports
+	}
+
+	var pids []int
+	for _, line := range strings.Split(string(tasklistOut), "\n") {
+		if pidRegex.MatchString(line) {
+			fields := strings.Split(line, ",")
+			if len(fields) >= 2 {
+				pidStr := strings.Trim(fields[1], "\"")
+				if pid, err := strconv.Atoi(pidStr); err == nil {
+					pids = append(pids, pid)
+				}
+			}
+		}
+	}
+
+	for _, pid := range pids {
+		netstatOut, err := exec.Command("netstat", "-ano").Output()
+		if err != nil {
+			continue
+		}
+
+		for _, line := range strings.Split(string(netstatOut), "\n") {
+			if strings.Contains(line, "LISTENING") {
+				fields := strings.Fields(line)
+				if len(fields) >= 5 {
+					pidStr := fields[len(fields)-1]
+					if netPid, err := strconv.Atoi(pidStr); err == nil && netPid == pid {
+						addr := fields[1]
+						if strings.Contains(addr, ":") {
+							parts := strings.Split(addr, ":")
+							if len(parts) >= 2 {
+								if port, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
+									if !contains(ports, port) {
+										ports = append(ports, port)
+									}
+								}
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	sort.Ints(ports)
+	return ports
+}
+
+func findByCmdline() []int {
+	var ports []int
+
+	wmicOut, err := exec.Command("wmic", "process", "where", "name like '%opencode%'", "get", "commandline", "/FORMAT:LIST").Output()
+	if err != nil {
+		return ports
+	}
+
+	for _, line := range strings.Split(string(wmicOut), "\n") {
+		if strings.Contains(line, "--port") {
+			fields := strings.Fields(line)
+			for i, f := range fields {
+				if f == "--port" && i+1 < len(fields) {
+					if port, err := strconv.Atoi(fields[i+1]); err == nil {
+						if !contains(ports, port) {
+							ports = append(ports, port)
+						}
+					}
+				}
+			}
+		}
+	}
+
+	if len(ports) == 0 {
+		powershellOut, err := exec.Command("powershell", "-Command", "Get-CimInstance Win32_Process | Where-Object {$_.Name -like '*opencode*'} | Select-Object CommandLine").Output()
+		if err == nil {
+			for _, line := range strings.Split(string(powershellOut), "\n") {
+				if strings.Contains(line, "--port") {
+					fields := strings.Fields(line)
+					for i, f := range fields {
+						if f == "--port" && i+1 < len(fields) {
+							if port, err := strconv.Atoi(fields[i+1]); err == nil {
+								if !contains(ports, port) {
+									ports = append(ports, port)
+								}
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	sort.Ints(ports)
+	return ports
+}

+ 91 - 0
internal/event/event.go

@@ -0,0 +1,91 @@
+package event
+
+import (
+	"strconv"
+	"time"
+)
+
+type SSEEvent struct {
+	Type       string                 `json:"type"`
+	Properties map[string]interface{} `json:"properties"`
+}
+
+type ParsedEvent struct {
+	Timestamp string
+	Port      int
+	Message   string
+}
+
+func ParseStatus(status map[string]interface{}) string {
+	if status == nil {
+		return "未知"
+	}
+	t, _ := status["type"].(string)
+	switch t {
+	case "idle":
+		return "空闲"
+	case "busy":
+		return "忙碌"
+	case "retry":
+		return "重试中"
+	default:
+		return t
+	}
+}
+
+func ParseToolState(state map[string]interface{}) string {
+	if state == nil {
+		return ""
+	}
+	s, _ := state["status"].(string)
+	title, _ := state["title"].(string)
+	switch s {
+	case "running":
+		if title != "" {
+			return "运行中: " + title
+		}
+		return "运行中"
+	case "completed":
+		if title != "" {
+			return "完成: " + title
+		}
+		return "完成"
+	case "error":
+		return "错误"
+	default:
+		return s
+	}
+}
+
+func FormatEvent(port int, evt *SSEEvent) string {
+	ts := time.Now().Format("15:04:05")
+	prefix := "[" + ts + "] [:" + strconv.Itoa(port) + "]"
+
+	switch evt.Type {
+	case "session.status":
+		if status, ok := evt.Properties["status"].(map[string]interface{}); ok {
+			return prefix + " 状态: " + ParseStatus(status)
+		}
+	case "session.idle":
+		return prefix + " 状态: 空闲"
+	case "message.part.updated":
+		if part, ok := evt.Properties["part"].(map[string]interface{}); ok {
+			pt, _ := part["type"].(string)
+			switch pt {
+			case "tool":
+				tool, _ := part["tool"].(string)
+				state := ParseToolState(part["state"].(map[string]interface{}))
+				return prefix + " 工具: " + tool + " - " + state
+			case "reasoning":
+				return prefix + " 思考中..."
+			}
+		}
+	case "permission.updated":
+		if title, ok := evt.Properties["title"].(string); ok {
+			return prefix + " 权限请求: " + title
+		}
+	case "session.error":
+		return prefix + " 错误"
+	}
+	return ""
+}

+ 81 - 0
internal/monitor/monitor.go

@@ -0,0 +1,81 @@
+package monitor
+
+import (
+	"bufio"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"AI-Status-Light/internal/event"
+)
+
+type EventCallback func(port int, evt *event.SSEEvent)
+
+type Monitor struct {
+	baseURL  string
+	port     int
+	client   *http.Client
+	callback EventCallback
+}
+
+func New(host string, port int, callback EventCallback) *Monitor {
+	return &Monitor{
+		baseURL:  fmt.Sprintf("http://%s:%d", host, port),
+		port:     port,
+		client:   &http.Client{Timeout: 2 * time.Second},
+		callback: callback,
+	}
+}
+
+func (m *Monitor) CheckHealth() bool {
+	resp, err := m.client.Get(m.baseURL + "/global/health")
+	if err != nil {
+		return false
+	}
+	defer resp.Body.Close()
+	return resp.StatusCode == 200
+}
+
+func (m *Monitor) Run(ctx context.Context) {
+	req, err := http.NewRequestWithContext(ctx, "GET", m.baseURL+"/event", nil)
+	if err != nil {
+		return
+	}
+	req.Header.Set("Accept", "text/event-stream")
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return
+	}
+	defer resp.Body.Close()
+
+	scanner := bufio.NewScanner(resp.Body)
+	for scanner.Scan() {
+		select {
+		case <-ctx.Done():
+			return
+		default:
+		}
+
+		line := scanner.Text()
+		if !strings.HasPrefix(line, "data:") {
+			continue
+		}
+		data := strings.TrimSpace(line[5:])
+		if data == "" {
+			continue
+		}
+
+		var evt event.SSEEvent
+		if err := json.Unmarshal([]byte(data), &evt); err != nil {
+			continue
+		}
+
+		if m.callback != nil {
+			m.callback(m.port, &evt)
+		}
+	}
+}

+ 105 - 0
internal/mqtt/mqtt.go

@@ -0,0 +1,105 @@
+package mqtt
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"time"
+
+	mqttlib "github.com/eclipse/paho.mqtt.golang"
+
+	"AI-Status-Light/internal/database"
+)
+
+type Client struct {
+	broker   string
+	clientID string
+	topic    string
+	client   mqttlib.Client
+}
+
+type StatusMessage struct {
+	Port      int    `json:"port"`
+	Type      string `json:"type"`
+	Status    string `json:"status,omitempty"`
+	Tool      string `json:"tool,omitempty"`
+	State     string `json:"state,omitempty"`
+	Title     string `json:"title,omitempty"`
+	Timestamp string `json:"timestamp"`
+}
+
+func New(broker, clientID, topic string) *Client {
+	return &Client{
+		broker:   broker,
+		clientID: clientID,
+		topic:    topic,
+	}
+}
+
+func NewFromConfig(cfg *database.MQTTConfig) *Client {
+	return &Client{
+		broker:   cfg.Broker,
+		clientID: cfg.ClientID,
+		topic:    cfg.Topic,
+	}
+}
+
+func (c *Client) Connect() error {
+	opts := mqttlib.NewClientOptions()
+	opts.AddBroker(c.broker)
+	opts.SetClientID(c.clientID)
+	opts.SetAutoReconnect(true)
+	opts.SetConnectTimeout(10 * time.Second)
+	opts.SetOnConnectHandler(func(client mqttlib.Client) {
+		log.Printf("MQTT 已连接: %s", c.broker)
+	})
+	opts.SetConnectionLostHandler(func(client mqttlib.Client, err error) {
+		log.Printf("MQTT 连接断开: %v", err)
+	})
+
+	c.client = mqttlib.NewClient(opts)
+	token := c.client.Connect()
+	token.Wait()
+	if token.Error() != nil {
+		return fmt.Errorf("MQTT 连接失败: %w", token.Error())
+	}
+	return nil
+}
+
+func (c *Client) Publish(port int, eventType, status, tool, state, title string) error {
+	msg := StatusMessage{
+		Port:      port,
+		Type:      eventType,
+		Status:    status,
+		Tool:      tool,
+		State:     state,
+		Title:     title,
+		Timestamp: time.Now().Format(time.RFC3339),
+	}
+
+	payload, err := json.Marshal(msg)
+	if err != nil {
+		return fmt.Errorf("序列化消息失败: %w", err)
+	}
+
+	topic := fmt.Sprintf("%s/%d", c.topic, port)
+	token := c.client.Publish(topic, 0, false, payload)
+	token.Wait()
+	return token.Error()
+}
+
+func (c *Client) PublishRaw(topic string, payload interface{}) error {
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+	token := c.client.Publish(topic, 0, false, data)
+	token.Wait()
+	return token.Error()
+}
+
+func (c *Client) Disconnect() {
+	if c.client != nil && c.client.IsConnected() {
+		c.client.Disconnect(1000)
+	}
+}

+ 22 - 0
scripts/build.bat

@@ -0,0 +1,22 @@
+@echo off
+setlocal
+
+set BINARY_NAME=opencode-monitor
+set VERSION=dev
+
+if not exist dist mkdir dist
+
+echo Building for Windows (amd64)...
+set GOOS=windows
+set GOARCH=amd64
+go build -ldflags "-X main.Version=%VERSION%" -o dist\%BINARY_NAME%-windows-amd64.exe .\cmd\monitor
+
+echo Building for Windows (arm64)...
+set GOARCH=arm64
+go build -ldflags "-X main.Version=%VERSION%" -o dist\%BINARY_NAME%-windows-arm64.exe .\cmd\monitor
+
+echo.
+echo Build complete! Binaries in dist\
+dir dist\
+
+endlocal

+ 28 - 0
scripts/build.sh

@@ -0,0 +1,28 @@
+#!/bin/bash
+
+BINARY_NAME="opencode-monitor"
+VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
+
+mkdir -p dist
+
+echo "Building for Linux (amd64)..."
+GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-linux-amd64 ./cmd/monitor
+
+echo "Building for Linux (arm64)..."
+GOOS=linux GOARCH=arm64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-linux-arm64 ./cmd/monitor
+
+echo "Building for macOS (amd64)..."
+GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-darwin-amd64 ./cmd/monitor
+
+echo "Building for macOS (arm64)..."
+GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-darwin-arm64 ./cmd/monitor
+
+echo "Building for Windows (amd64)..."
+GOOS=windows GOARCH=amd64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-windows-amd64.exe ./cmd/monitor
+
+echo "Building for Windows (arm64)..."
+GOOS=windows GOARCH=arm64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-windows-arm64.exe ./cmd/monitor
+
+echo ""
+echo "Build complete! Binaries in dist/"
+ls -la dist/