Przeglądaj źródła

去掉sse方式,改用hooks方式

moki 4 dni temu
rodzic
commit
710129ac16

+ 41 - 363
cmd/monitor/main.go

@@ -7,21 +7,13 @@ import (
 	"io"
 	"os"
 	"os/exec"
-	"os/signal"
 	"path/filepath"
-	"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/logger"
-	"ai-status-light/internal/monitor"
 	mqttcli "ai-status-light/internal/mqtt"
 )
 
@@ -36,30 +28,27 @@ func main() {
 	}
 
 	switch os.Args[1] {
-	case "monitor":
-		runMonitor(os.Args[2:])
-	case "config":
-		runConfig(os.Args[2:])
 	case "serve":
 		runServe(os.Args[2:])
+	case "config":
+		runConfig(os.Args[2:])
 	case "version":
-		fmt.Printf("opencode-monitor %s\n", Version)
+		fmt.Printf("ai-status-light %s\n", Version)
 	default:
 		printUsage()
 	}
 }
 
 func printUsage() {
-	fmt.Printf("opencode-monitor %s\n\n", Version)
-	fmt.Println("用法: opencode-monitor <命令> [选项]")
+	fmt.Printf("ai-status-light %s\n\n", Version)
+	fmt.Println("用法: ai-status-light <命令> [选项]")
 	fmt.Println("")
 	fmt.Println("命令:")
-	fmt.Println("  monitor    启动监控")
+	fmt.Println("  serve      启动 API 服务(接收 OpenCode 插件事件)")
 	fmt.Println("  config     管理 MQTT 和 BLE 配置")
-	fmt.Println("  serve      启动 API 服务")
 	fmt.Println("  version    显示版本信息")
 	fmt.Println("")
-	fmt.Println("运行 'opencode-monitor <命令> -h' 查看命令帮助")
+	fmt.Println("运行 'ai-status-light <命令> -h' 查看命令帮助")
 }
 
 func runServe(args []string) {
@@ -80,89 +69,9 @@ func runServe(args []string) {
 	}
 	defer logger.Close()
 
-	db, err := database.New(*dbPath)
-	if err != nil {
-		logger.Error("打开数据库失败: %v", err)
-		fmt.Printf("打开数据库失败: %v\n", err)
-		return
-	}
-	defer db.Close()
-	logger.Info("数据库已连接: %s", *dbPath)
-
-	server := api.New(db, *addr)
-
-	if *tls {
-		if err := api.EnsureSelfSignedCert(*tlsCert, *tlsKey); err != nil {
-			logger.Error("生成自签名证书失败: %v", err)
-			fmt.Printf("生成自签名证书失败: %v\n", err)
-			return
-		}
-		server.EnableTLS(*tlsCert, *tlsKey)
-		logger.Info("HTTPS 已启用")
-		fmt.Println("HTTPS 已启用 (自签名证书)")
-	} else {
-		fmt.Printf("API 服务已启动: %s\n", *addr)
-	}
-
-	fmt.Println("接口文档:")
-	fmt.Println("  GET    /api/health       - 健康检查")
-	fmt.Println("  GET    /api/mqtt         - 获取所有 MQTT 配置")
-	fmt.Println("  POST   /api/mqtt         - 创建 MQTT 配置")
-	fmt.Println("  GET    /api/mqtt/:id     - 获取单个 MQTT 配置")
-	fmt.Println("  PUT    /api/mqtt/:id     - 更新 MQTT 配置")
-	fmt.Println("  DELETE /api/mqtt/:id     - 删除 MQTT 配置")
-	fmt.Println("  GET    /api/ble          - 获取所有 BLE 配置")
-	fmt.Println("  POST   /api/ble          - 创建 BLE 配置")
-	fmt.Println("  GET    /api/ble/:id      - 获取单个 BLE 配置")
-	fmt.Println("  PUT    /api/ble/:id      - 更新 BLE 配置")
-	fmt.Println("  DELETE /api/ble/:id      - 删除 BLE 配置")
-
-	if err := server.Start(); err != nil {
-		logger.Error("服务启动失败: %v", err)
-		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)")
-	intervalFlag := fs.Int("interval", 1, "动态扫描间隔(秒), 默认1")
-	dbPath := fs.String("db", defaultDBPath, "数据库路径")
-	apiAddr := fs.String("api-addr", "", "API 服务地址 (如: :8080)")
-	tls := fs.Bool("tls", false, "启用 HTTPS (使用自签名证书)")
-	tlsCert := fs.String("tls-cert", "./data/tls/cert.pem", "TLS 证书文件路径")
-	tlsKey := fs.String("tls-key", "./data/tls/key.pem", "TLS 私钥文件路径")
-	logFile := fs.String("log-file", "./logs", "日志文件路径(默认 ./logs/monitor.log)")
-	logLevel := fs.String("log-level", "info", "日志级别 (debug/info/warn/error)")
-	fs.Parse(args)
-
-	logger.SetLevel(logger.ParseLevel(*logLevel))
-	if err := logger.InitFileLog(*logFile); err != nil {
-		fmt.Printf("初始化日志文件失败: %v\n", err)
-		return
-	}
-	defer logger.Close()
-
-	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 {
 		logger.Error("打开数据库失败: %v", err)
@@ -203,47 +112,43 @@ func runMonitor(args []string) {
 		logger.Info("未配置 BLE,跳过 BLE 中继")
 	}
 
-	var apiServer *api.Server
-	if *apiAddr != "" {
-		apiServer = api.New(db, *apiAddr)
-		if *tls {
-			if err := api.EnsureSelfSignedCert(*tlsCert, *tlsKey); err != nil {
-				logger.Error("生成自签名证书失败: %v", err)
-				fmt.Printf("生成自签名证书失败: %v\n", err)
-				return
-			}
-			apiServer.EnableTLS(*tlsCert, *tlsKey)
+	server := api.New(db, *addr)
+	server.SetMQTTClient(mqttClient)
+	server.SetBLEStdin(bleStdin)
+
+	if *tls {
+		if err := api.EnsureSelfSignedCert(*tlsCert, *tlsKey); err != nil {
+			logger.Error("生成自签名证书失败: %v", err)
+			fmt.Printf("生成自签名证书失败: %v\n", err)
+			return
 		}
-		go func() {
-			scheme := "http"
-			if *tls {
-				scheme = "https"
-			}
-			logger.Info("API 服务已启动: %s://%s", scheme, *apiAddr)
-			fmt.Printf("API 服务已启动: %s://%s\n", scheme, *apiAddr)
-			if err := apiServer.Start(); err != nil {
-				logger.Error("API 服务失败: %v", err)
-				fmt.Printf("API 服务失败: %v\n", err)
-			}
-		}()
+		server.EnableTLS(*tlsCert, *tlsKey)
+		logger.Info("HTTPS 已启用")
+		fmt.Println("HTTPS 已启用 (自签名证书)")
 	}
 
-	callback := createCallback(mqttClient, apiServer, bleStdin)
-
-	if *portsFlag != "" {
-		runFixedMode(ctx, *host, *portsFlag, callback, sigChan)
-		cancel()
-		return
-	}
+	fmt.Printf("API 服务已启动: %s\n", *addr)
+	fmt.Println("")
+	fmt.Println("接口文档:")
+	fmt.Println("  POST   /api/event        - 接收 OpenCode 插件事件")
+	fmt.Println("  GET    /api/health       - 健康检查")
+	fmt.Println("  GET    /api/mqtt         - 获取所有 MQTT 配置")
+	fmt.Println("  POST   /api/mqtt         - 创建 MQTT 配置")
+	fmt.Println("  GET    /api/mqtt/:id     - 获取单个 MQTT 配置")
+	fmt.Println("  PUT    /api/mqtt/:id     - 更新 MQTT 配置")
+	fmt.Println("  DELETE /api/mqtt/:id     - 删除 MQTT 配置")
+	fmt.Println("  GET    /api/ble          - 获取所有 BLE 配置")
+	fmt.Println("  POST   /api/ble          - 创建 BLE 配置")
+	fmt.Println("  GET    /api/ble/:id      - 获取单个 BLE 配置")
+	fmt.Println("  PUT    /api/ble/:id      - 更新 BLE 配置")
+	fmt.Println("  DELETE /api/ble/:id      - 删除 BLE 配置")
+	fmt.Println("")
+	fmt.Println("Ctrl+C 停止")
 
-	if scanRange != nil {
-		fmt.Printf("扫描端口范围 %d-%d...\n", scanRange[0], scanRange[1])
-	} else {
-		fmt.Println("查找 OpenCode 实例...")
+	if err := server.Start(); err != nil {
+		logger.Error("服务启动失败: %v", err)
+		fmt.Printf("服务启动失败: %v\n", err)
 	}
-
-	runDynamicMode(ctx, *host, scanRange, *intervalFlag, callback, sigChan)
-	cancel()
 }
 
 func runConfig(args []string) {
@@ -380,7 +285,7 @@ func runConfig(args []string) {
 }
 
 func printConfigUsage() {
-	fmt.Println("用法: opencode-monitor config <子命令> [选项]")
+	fmt.Println("用法: ai-status-light config <子命令> [选项]")
 	fmt.Println("")
 	fmt.Println("子命令:")
 	fmt.Println("  list              列出所有 MQTT 配置")
@@ -551,7 +456,7 @@ func runBleConfig(db *database.DB, args []string) {
 }
 
 func printBleConfigUsage() {
-	fmt.Println("用法: opencode-monitor config ble <子命令> [选项]")
+	fmt.Println("用法: ai-status-light config ble <子命令> [选项]")
 	fmt.Println("")
 	fmt.Println("子命令:")
 	fmt.Println("  list              列出所有 BLE 配置")
@@ -564,230 +469,3 @@ func printBleConfigUsage() {
 	fmt.Println("  --char-uuid       BLE 特征 UUID (必填)")
 	fmt.Println("  --enabled         是否启用 (true/false)")
 }
-
-func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server, bleStdin io.Writer) monitor.EventCallback {
-	lastStatus := make(map[int]string)
-	var mu sync.Mutex
-
-	publish := func(port int, status string, code string) {
-		if mqttClient != nil {
-			payload := map[string]interface{}{
-				"port":      port,
-				"status":    status,
-				"code":      code,
-				"timestamp": time.Now().Format(time.RFC3339),
-			}
-			if err := mqttClient.PublishRaw(mqttClient.GetTopic(), payload); err != nil {
-				logger.Error("MQTT 发送失败: %v", err)
-				fmt.Printf("MQTT 发送失败: %v\n", err)
-			}
-		}
-		if apiServer != nil {
-			apiServer.BroadcastStatus(port, status, code)
-		}
-		if bleStdin != nil {
-			msg := fmt.Sprintf(`{"port":%d,"code":"%s"}`+"\n", port, code)
-			if _, err := bleStdin.Write([]byte(msg)); err != nil {
-				logger.Error("BLE 发送失败: %v", err)
-			}
-		}
-	}
-
-	return func(port int, evt *event.SSEEvent) {
-		msg := event.FormatEvent(port, evt)
-		if msg != "" {
-			fmt.Println(msg)
-		}
-
-		if mqttClient == nil && apiServer == nil && bleStdin == nil {
-			return
-		}
-
-		var status, code string
-
-		switch evt.Type {
-		case "session.status":
-			if s, ok := evt.Properties["status"].(map[string]interface{}); ok {
-				if t, ok := s["type"].(string); ok {
-					code = t
-				}
-				status = event.ParseStatus(s)
-			}
-		case "session.idle":
-			status = "空闲"
-			code = "idle"
-		case "message.part.updated":
-			if part, ok := evt.Properties["part"].(map[string]interface{}); ok {
-				switch part["type"].(string) {
-				case "tool":
-					if st, ok := part["state"].(map[string]interface{}); ok {
-						if s, ok := st["status"].(string); ok {
-							code = s
-						}
-						status = event.ParseToolState(st)
-					}
-				case "reasoning":
-					status = "思考中"
-					code = "reasoning"
-				default:
-					status = "使用工具中"
-					code = "using_tool"
-				}
-			}
-		case "permission.updated":
-			code = "permission"
-			if title, ok := evt.Properties["title"].(string); ok && title != "" {
-				status = "等待权限: " + title
-			} else {
-				status = "等待权限"
-			}
-		case "session.error":
-			status = "错误"
-			code = "error"
-		}
-
-		if status != "" {
-			mu.Lock()
-			prev := lastStatus[port]
-			if status == "空闲" && prev != "" && prev != "空闲" {
-				publish(port, "会话完成", "session_completed")
-			}
-			lastStatus[port] = status
-			mu.Unlock()
-
-			publish(port, status, code)
-		}
-	}
-}
-
-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
-	}
-
-	logger.Info("固定模式启动,监控端口: %v", ports)
-	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()
-			logger.Info("开始监控端口: %d", p)
-			m := monitor.New(host, p, callback)
-			m.Run(ctx)
-			logger.Info("端口 %d 监控已停止", p)
-		}(port)
-	}
-
-	<-sigChan
-	logger.Info("收到停止信号,正在退出")
-	fmt.Println("\n已停止")
-	wg.Wait()
-	logger.Info("所有监控协程已退出")
-}
-
-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() {
-			logger.Info("开始监控端口: %d", port)
-			fmt.Printf("开始监控端口: %d\n", port)
-			m := monitor.New(host, port, callback)
-			m.Run(monitorCtx)
-			// 连接断开后清理记录,允许重新连接
-			mu.Lock()
-			delete(runningMonitors, port)
-			delete(monitoredPorts, port)
-			mu.Unlock()
-			logger.Info("端口 %d 监控已停止,等待重新连接", port)
-			fmt.Printf("端口 %d 监控已停止,等待重新连接\n", port)
-		}()
-	}
-
-	initial := scanner.Discover()
-	if len(initial) == 0 {
-		logger.Info("未找到运行中的 OpenCode 实例,等待自动检测")
-		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)
-		logger.Info("找到 %d 个实例: %v", len(monitoredPorts), ports)
-		fmt.Printf("找到 %d 个实例: %v\n", len(monitoredPorts), ports)
-	}
-
-	logger.Info("动态模式启动,每 %d 秒扫描新实例", interval)
-	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()
-				if len(newPorts) > 0 {
-					logger.Debug("扫描到 %d 个端口: %v", len(newPorts), newPorts)
-				}
-				for _, port := range newPorts {
-					mu.Lock()
-					alreadyMonitored := monitoredPorts[port]
-					if !alreadyMonitored {
-						monitoredPorts[port] = true
-					}
-					mu.Unlock()
-
-					if !alreadyMonitored {
-						logger.Info("发现新实例端口: %d,开始监控", port)
-						startMonitor(port)
-					}
-				}
-			}
-		}
-	}()
-
-	<-sigChan
-	logger.Info("收到停止信号,正在退出")
-	fmt.Println("\n已停止")
-}

+ 73 - 9
internal/api/api.go

@@ -2,6 +2,8 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
+	"io"
 	"net/http"
 	"sort"
 	"strconv"
@@ -13,6 +15,7 @@ import (
 
 	"ai-status-light/internal/database"
 	"ai-status-light/internal/logger"
+	mqttcli "ai-status-light/internal/mqtt"
 	"ai-status-light/internal/web"
 )
 
@@ -23,16 +26,23 @@ type ClientStatus struct {
 	Timestamp string `json:"timestamp"`
 }
 
+type EventRequest struct {
+	Code      string `json:"code"`
+	Timestamp string `json:"timestamp,omitempty"`
+}
+
 type Server struct {
-	db        *database.DB
-	server    *http.Server
-	clients   map[*websocket.Conn]bool
-	clientsMu sync.Mutex
-	upgrader  websocket.Upgrader
-	statusMap map[int]*ClientStatus
-	statusMu  sync.RWMutex
-	certFile  string
-	keyFile   string
+	db         *database.DB
+	server     *http.Server
+	clients    map[*websocket.Conn]bool
+	clientsMu  sync.Mutex
+	upgrader   websocket.Upgrader
+	statusMap  map[int]*ClientStatus
+	statusMu   sync.RWMutex
+	certFile   string
+	keyFile    string
+	mqttClient *mqttcli.Client
+	bleStdin   io.Writer
 }
 
 type Response struct {
@@ -55,6 +65,7 @@ func New(db *database.DB, addr string) *Server {
 
 	mux := http.NewServeMux()
 	mux.HandleFunc("/api/clients", s.handleClients)
+	mux.HandleFunc("/api/event", s.handleEvent)
 	mux.HandleFunc("/api/mqtt", s.handleMQTT)
 	mux.HandleFunc("/api/mqtt/", s.handleMQTTByID)
 	mux.HandleFunc("/api/ble", s.handleBLE)
@@ -75,6 +86,14 @@ func (s *Server) EnableTLS(certFile, keyFile string) {
 	s.keyFile = keyFile
 }
 
+func (s *Server) SetMQTTClient(client *mqttcli.Client) {
+	s.mqttClient = client
+}
+
+func (s *Server) SetBLEStdin(stdin io.Writer) {
+	s.bleStdin = stdin
+}
+
 func (s *Server) Start() error {
 	if s.certFile != "" && s.keyFile != "" {
 		logger.Info("API 服务器开始监听 (HTTPS): %s", s.server.Addr)
@@ -111,6 +130,51 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "ok"})
 }
 
+func (s *Server) handleEvent(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, http.StatusMethodNotAllowed, Response{Code: -1, Message: "方法不允许"})
+		return
+	}
+
+	var req EventRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		logger.Warn("事件请求体解析失败: %v", err)
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "无效的请求体"})
+		return
+	}
+
+	if req.Code == "" {
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "code 不能为空"})
+		return
+	}
+
+	logger.Info("收到事件: code=%s", req.Code)
+
+	// 通过 MQTT 发送
+	if s.mqttClient != nil {
+		payload := map[string]interface{}{
+			"code":      req.Code,
+			"timestamp": time.Now().Format(time.RFC3339),
+		}
+		if err := s.mqttClient.PublishRaw(s.mqttClient.GetTopic(), payload); err != nil {
+			logger.Error("MQTT 发送失败: %v", err)
+		}
+	}
+
+	// 通过 BLE 发送
+	if s.bleStdin != nil {
+		msg := fmt.Sprintf(`{"code":"%s"}`+"\n", req.Code)
+		if _, err := s.bleStdin.Write([]byte(msg)); err != nil {
+			logger.Error("BLE 发送失败: %v", err)
+		}
+	}
+
+	// 广播到 WebSocket 客户端
+	s.BroadcastStatus(0, req.Code, req.Code)
+
+	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "ok"})
+}
+
 func (s *Server) handleClients(w http.ResponseWriter, r *http.Request) {
 	if r.Method != http.MethodGet {
 		writeJSON(w, http.StatusMethodNotAllowed, Response{Code: -1, Message: "方法不允许"})

+ 0 - 87
internal/discovery/discovery.go

@@ -1,87 +0,0 @@
-package discovery
-
-import (
-	"fmt"
-	"net/http"
-	"sort"
-	"time"
-
-	"ai-status-light/internal/logger"
-)
-
-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 {
-	logger.Debug("开始发现 OpenCode 实例,主机: %s", s.Host)
-	ports := findByCmdline()
-	if len(ports) > 0 {
-		logger.Debug("通过命令行参数发现端口: %v", ports)
-	} else {
-		logger.Debug("命令行未发现端口,尝试通过 PID 查找")
-		ports = findByPID()
-		if len(ports) > 0 {
-			logger.Debug("通过 PID 发现端口: %v", ports)
-		}
-	}
-	if len(ports) == 0 && s.ScanRange != nil {
-		logger.Info("未通过系统命令发现实例,开始端口扫描 %d-%d", s.ScanRange[0], s.ScanRange[1])
-		ports = scanPorts(s.Host, s.ScanRange[0], s.ScanRange[1])
-		if len(ports) > 0 {
-			logger.Info("端口扫描发现 %d 个实例: %v", len(ports), ports)
-		}
-	}
-	result := unique(ports)
-	logger.Debug("发现完成,共 %d 个实例: %v", len(result), result)
-	return result
-}
-
-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 {
-			continue
-		}
-		resp.Body.Close()
-		if resp.StatusCode == 200 {
-			logger.Debug("端口扫描发现实例: %d", port)
-			found = append(found, port)
-		}
-	}
-
-	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
-}

+ 0 - 104
internal/discovery/discovery_darwin.go

@@ -1,104 +0,0 @@
-//go:build darwin
-
-package discovery
-
-import (
-	"os/exec"
-	"regexp"
-	"sort"
-	"strconv"
-	"strings"
-
-	"ai-status-light/internal/logger"
-)
-
-func findByPID() []int {
-	var ports []int
-	pidRegex := regexp.MustCompile(`(?:^|/)opencode(?:\s|$)`)
-
-	out, err := exec.Command("ps", "aux").Output()
-	if err != nil {
-		logger.Debug("执行 ps aux 失败: %v", err)
-		return ports
-	}
-
-	var pids []int
-	for _, line := range strings.Split(string(out), "\n") {
-		if pidRegex.MatchString(line) && !strings.Contains(line, "grep") && !strings.Contains(line, "opencode-monitor") {
-			fields := strings.Fields(line)
-			if len(fields) > 1 {
-				if pid, err := strconv.Atoi(fields[1]); err == nil {
-					pids = append(pids, pid)
-				}
-			}
-		}
-	}
-
-	if len(pids) == 0 {
-		logger.Debug("未找到 opencode 进程")
-		return ports
-	}
-	logger.Debug("发现 opencode 进程 PID: %v", pids)
-
-	for _, pid := range pids {
-		lsofOut, err := exec.Command("lsof", "-p", strconv.Itoa(pid), "-i", "TCP", "-s", "TCP:LISTEN").Output()
-		if err != nil {
-			logger.Debug("执行 lsof 失败: %v", err)
-			continue
-		}
-
-		for _, line := range strings.Split(string(lsofOut), "\n") {
-			if strings.Contains(line, "LISTEN") {
-				// lsof 输出格式: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
-				// NAME 部分格式: *:port 或 host:port
-				fields := strings.Fields(line)
-				if len(fields) >= 9 {
-					nameField := fields[len(fields)-1]
-					if strings.Contains(nameField, ":") {
-						parts := strings.Split(nameField, ":")
-						if len(parts) >= 2 {
-							if port, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
-								if port > 0 && 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 {
-		logger.Debug("执行 ps aux 失败: %v", err)
-		return ports
-	}
-
-	for _, line := range strings.Split(string(out), "\n") {
-		if strings.Contains(line, "opencode") && !strings.Contains(line, "opencode-monitor") && !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
-}

+ 0 - 102
internal/discovery/discovery_linux.go

@@ -1,102 +0,0 @@
-//go:build linux
-
-package discovery
-
-import (
-	"fmt"
-	"os/exec"
-	"regexp"
-	"sort"
-	"strconv"
-	"strings"
-
-	"ai-status-light/internal/logger"
-)
-
-func findByPID() []int {
-	var ports []int
-	pidRegex := regexp.MustCompile(`(?:^|/)opencode(?:\s|$)`)
-
-	out, err := exec.Command("ps", "aux").Output()
-	if err != nil {
-		logger.Debug("执行 ps aux 失败: %v", err)
-		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)
-				}
-			}
-		}
-	}
-
-	if len(pids) == 0 {
-		logger.Debug("未找到 opencode 进程")
-		return ports
-	}
-	logger.Debug("发现 opencode 进程 PID: %v", pids)
-
-	for _, pid := range pids {
-		ssOut, err := exec.Command("ss", "-tlnp").Output()
-		if err != nil {
-			logger.Debug("执行 ss -tlnp 失败: %v", err)
-			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 port > 0 && 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 {
-		logger.Debug("执行 ps aux 失败: %v", err)
-		return ports
-	}
-
-	for _, line := range strings.Split(string(out), "\n") {
-		if strings.Contains(line, "opencode") && !strings.Contains(line, "opencode-monitor") && !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
-}

+ 0 - 134
internal/discovery/discovery_windows.go

@@ -1,134 +0,0 @@
-//go:build windows
-
-package discovery
-
-import (
-	"os/exec"
-	"regexp"
-	"sort"
-	"strconv"
-	"strings"
-)
-
-func findByPID() []int {
-	var ports []int
-	pidRegex := regexp.MustCompile(`(?i)(opencode|bun)`)
-
-	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) && !strings.Contains(line, "opencode-monitor") {
-			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
-
-	// 查找 opencode 进程
-	wmicOut, err := exec.Command("wmic", "process", "where", "name like '%opencode%' and name not like '%opencode-monitor%'", "get", "commandline", "/FORMAT:LIST").Output()
-	if err == nil {
-		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)
-							}
-						}
-					}
-				}
-			}
-		}
-	}
-
-	// 查找 bun 进程运行的 opencode
-	if len(ports) == 0 {
-		bunOut, err := exec.Command("wmic", "process", "where", "name like '%bun%'", "get", "commandline", "/FORMAT:LIST").Output()
-		if err == nil {
-			for _, line := range strings.Split(string(bunOut), "\n") {
-				if strings.Contains(line, "opencode") && 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)
-								}
-							}
-						}
-					}
-				}
-			}
-		}
-	}
-
-	// 使用 PowerShell 作为后备方案
-	if len(ports) == 0 {
-		powershellOut, err := exec.Command("powershell", "-Command", "Get-CimInstance Win32_Process | Where-Object {($_.Name -like '*opencode*' -and $_.Name -notlike '*opencode-monitor*') -or ($_.Name -like '*bun*' -and $_.CommandLine -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
-}

+ 0 - 95
internal/event/event.go

@@ -1,95 +0,0 @@
-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 "重试中"
-	case "pending":
-		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 "pending":
-		return "等待中"
-	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 ""
-}

+ 0 - 164
internal/monitor/monitor.go

@@ -1,164 +0,0 @@
-package monitor
-
-import (
-	"bufio"
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"strings"
-	"time"
-
-	"ai-status-light/internal/event"
-	"ai-status-light/internal/logger"
-)
-
-type EventCallback func(port int, evt *event.SSEEvent)
-
-type Monitor struct {
-	baseURL      string
-	port         int
-	client       *http.Client
-	callback     EventCallback
-	lastActivity time.Time
-	timeout      time.Duration
-}
-
-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,
-		lastActivity: time.Now(),
-		timeout:      5 * time.Minute,
-	}
-}
-
-func (m *Monitor) CheckHealth() bool {
-	resp, err := m.client.Get(m.baseURL + "/global/health")
-	if err != nil {
-		logger.Debug("端口 %d 健康检查失败: %v", m.port, err)
-		return false
-	}
-	defer resp.Body.Close()
-	healthy := resp.StatusCode == 200
-	if healthy {
-		logger.Debug("端口 %d 健康检查通过", m.port)
-	} else {
-		logger.Debug("端口 %d 健康检查返回状态码: %d", m.port, resp.StatusCode)
-	}
-	return healthy
-}
-
-func (m *Monitor) Run(ctx context.Context) {
-	for {
-		if ctx.Err() != nil {
-			return
-		}
-
-		if !m.CheckHealth() {
-			logger.Debug("端口 %d 健康检查失败,等待重试", m.port)
-			select {
-			case <-ctx.Done():
-				return
-			case <-time.After(5 * time.Second):
-			}
-			continue
-		}
-
-		err := m.connectAndRead(ctx)
-		if ctx.Err() != nil {
-			return
-		}
-		if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
-			logger.Info("端口 %d 连接关闭: %v, 停止监控", m.port, err)
-			return
-		}
-		logger.Warn("端口 %d 连接断开: %v, 3秒后重连", m.port, err)
-		select {
-		case <-ctx.Done():
-			return
-		case <-time.After(3 * time.Second):
-		}
-	}
-}
-
-func (m *Monitor) connectAndRead(ctx context.Context) error {
-	req, err := http.NewRequestWithContext(ctx, "GET", m.baseURL+"/event", nil)
-	if err != nil {
-		logger.Error("端口 %d 创建请求失败: %v", m.port, err)
-		return err
-	}
-	req.Header.Set("Accept", "text/event-stream")
-
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		logger.Debug("端口 %d 连接事件流失败: %v", m.port, err)
-		return err
-	}
-	defer resp.Body.Close()
-
-	logger.Info("端口 %d 已连接到事件流: %s/event", m.port, m.baseURL)
-	m.lastActivity = time.Now()
-
-	// 启动超时检测 goroutine
-	timeoutChan := make(chan struct{})
-	go func() {
-		ticker := time.NewTicker(30 * time.Second)
-		defer ticker.Stop()
-		for {
-			select {
-			case <-ctx.Done():
-				return
-			case <-timeoutChan:
-				return
-			case <-ticker.C:
-				if time.Since(m.lastActivity) > m.timeout {
-					logger.Warn("端口 %d 超时 %v,回收监控", m.port, m.timeout)
-					fmt.Printf("端口 %d 超时 %v,回收监控\n", m.port, m.timeout)
-					return
-				}
-			}
-		}
-	}()
-
-	scanner := bufio.NewScanner(resp.Body)
-	scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
-	for scanner.Scan() {
-		select {
-		case <-ctx.Done():
-			logger.Debug("端口 %d 上下文已取消,退出事件循环", m.port)
-			close(timeoutChan)
-			return nil
-		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 {
-			logger.Debug("端口 %d 解析事件失败: %v", m.port, err)
-			continue
-		}
-
-		m.lastActivity = time.Now()
-
-		if m.callback != nil {
-			m.callback(m.port, &evt)
-		}
-	}
-
-	close(timeoutChan)
-	logger.Debug("端口 %d 事件流读取结束", m.port)
-	return scanner.Err()
-}

+ 142 - 0
opencode-plugin/README.md

@@ -0,0 +1,142 @@
+# OpenCode Status Light Plugin
+
+这是一个OpenCode插件,用于将工具执行和会话状态事件发送到AI Status Light监控服务。
+
+## 功能
+
+- 监听工具执行事件(开始、完成)
+- 监听会话状态变化(空闲、错误)
+- 监听权限请求
+- 监听消息更新(工具状态、推理状态)
+- 通过HTTP webhook发送事件到监控服务
+
+## 安装
+
+### 方式1:本地安装
+
+1. 将此目录复制到OpenCode的插件目录:
+   - 项目级:`.opencode/plugins/`
+   - 全局级:`~/.config/opencode/plugins/`
+
+2. 安装依赖:
+   ```bash
+   cd opencode-plugin
+   npm install
+   ```
+
+### 方式2:npm安装(推荐)
+
+```bash
+npm install opencode-status-light-plugin
+```
+
+然后在`opencode.json`中添加:
+```json
+{
+  "plugin": ["opencode-status-light-plugin"]
+}
+```
+
+## 配置
+
+### 环境变量
+
+| 变量名 | 说明 | 默认值 |
+|--------|------|--------|
+| `OPENCODE_WEBHOOK_URL` | Webhook接收地址 | `http://localhost:8080/api/webhook` |
+| `OPENCODE_WEBHOOK_SECRET` | Webhook密钥(用于签名验证) | (空) |
+| `OPENCODE_WEBHOOK_ENABLED` | 是否启用webhook | `true` |
+
+### 示例配置
+
+```bash
+# 设置webhook地址
+export OPENCODE_WEBHOOK_URL="http://your-server:8080/api/webhook"
+
+# 设置密钥(可选)
+export OPENCODE_WEBHOOK_SECRET="your-secret-key"
+
+# 启用webhook
+export OPENCODE_WEBHOOK_ENABLED="true"
+```
+
+## 事件类型
+
+插件会发送以下事件:
+
+| 事件类型 | 说明 | 数据格式 |
+|----------|------|----------|
+| `tool.execute.before` | 工具执行前 | `{ tool, args, status: "running" }` |
+| `tool.execute.after` | 工具执行后 | `{ tool, args, result, status: "completed" }` |
+| `session.status` | 会话状态变化 | `{ status }` |
+| `session.idle` | 会话空闲 | `{ status: "idle" }` |
+| `session.error` | 会话错误 | `{ error }` |
+| `permission.asked` | 权限请求 | `{ permission }` |
+| `message.part.updated` | 消息部分更新 | `{ part: { type, tool?, state?, status? } }` |
+
+## Webhook请求格式
+
+```json
+{
+  "type": "tool.execute.before",
+  "timestamp": "2026-06-27T10:30:00Z",
+  "project": "my-project",
+  "directory": "/path/to/project",
+  "worktree": "/path/to/worktree",
+  "data": {
+    "tool": "read",
+    "args": { "filePath": "src/index.ts" },
+    "status": "running"
+  }
+}
+```
+
+## 签名验证
+
+如果设置了`OPENCODE_WEBHOOK_SECRET`,插件会在请求头中添加签名:
+
+```
+X-Webhook-Signature: sha256=<hex-signature>
+```
+
+验证签名的示例代码(Node.js):
+
+```javascript
+const crypto = require('crypto')
+
+function verifySignature(payload, signature, secret) {
+  const expectedSignature = crypto
+    .createHmac('sha256', secret)
+    .update(JSON.stringify(payload))
+    .digest('hex')
+  
+  return signature === `sha256=${expectedSignature}`
+}
+```
+
+## 开发
+
+### 本地开发
+
+```bash
+# 安装依赖
+npm install
+
+# 构建
+npm run build
+
+# 测试
+npm test
+```
+
+### 调试
+
+设置环境变量启用详细日志:
+
+```bash
+export DEBUG=status-light-plugin
+```
+
+## 许可证
+
+MIT

+ 152 - 0
opencode-plugin/index.ts

@@ -0,0 +1,152 @@
+import type { Plugin } from "@opencode-ai/plugin"
+
+interface WebhookConfig {
+  url: string
+  secret?: string
+  enabled: boolean
+}
+
+export const StatusLightPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
+  // 默认配置,可以通过环境变量或配置文件覆盖
+  const config: WebhookConfig = {
+    url: process.env.OPENCODE_WEBHOOK_URL || "http://localhost:8080/api/webhook",
+    secret: process.env.OPENCODE_WEBHOOK_SECRET || "",
+    enabled: process.env.OPENCODE_WEBHOOK_ENABLED !== "false",
+  }
+
+  console.log(`[StatusLight] Plugin initialized`)
+  console.log(`[StatusLight] Webhook URL: ${config.url}`)
+  console.log(`[StatusLight] Webhook enabled: ${config.enabled}`)
+
+  // 发送webhook请求
+  async function sendWebhook(eventType: string, data: any) {
+    if (!config.enabled) {
+      return
+    }
+
+    try {
+      const payload = {
+        type: eventType,
+        timestamp: new Date().toISOString(),
+        project: project?.name || "unknown",
+        directory,
+        worktree,
+        data,
+      }
+
+      const headers: Record<string, string> = {
+        "Content-Type": "application/json",
+      }
+
+      // 如果配置了密钥,添加签名头
+      if (config.secret) {
+        const encoder = new TextEncoder()
+        const key = await crypto.subtle.importKey(
+          "raw",
+          encoder.encode(config.secret),
+          { name: "HMAC", hash: "SHA-256" },
+          false,
+          ["sign"]
+        )
+        const signature = await crypto.subtle.sign(
+          "HMAC",
+          key,
+          encoder.encode(JSON.stringify(payload))
+        )
+        const signatureArray = Array.from(new Uint8Array(signature))
+        const signatureHex = signatureArray.map(b => b.toString(16).padStart(2, "0")).join("")
+        headers["X-Webhook-Signature"] = `sha256=${signatureHex}`
+      }
+
+      const response = await fetch(config.url, {
+        method: "POST",
+        headers,
+        body: JSON.stringify(payload),
+      })
+
+      if (!response.ok) {
+        console.error(`[StatusLight] Webhook failed: ${response.status} ${response.statusText}`)
+      }
+    } catch (error) {
+      console.error(`[StatusLight] Webhook error:`, error)
+    }
+  }
+
+  return {
+    // 工具执行前
+    "tool.execute.before": async (input, output) => {
+      console.log(`[StatusLight] Tool executing: ${input.tool}`)
+      await sendWebhook("tool.execute.before", {
+        tool: input.tool,
+        args: output.args,
+        status: "running",
+      })
+    },
+
+    // 工具执行后
+    "tool.execute.after": async (input, output) => {
+      console.log(`[StatusLight] Tool completed: ${input.tool}`)
+      await sendWebhook("tool.execute.after", {
+        tool: input.tool,
+        args: output.args,
+        result: output.result,
+        status: "completed",
+      })
+    },
+
+    // 会话状态变化
+    "session.status": async (input, output) => {
+      console.log(`[StatusLight] Session status: ${JSON.stringify(input)}`)
+      await sendWebhook("session.status", {
+        status: input,
+      })
+    },
+
+    // 会话空闲
+    "session.idle": async (input, output) => {
+      console.log(`[StatusLight] Session idle`)
+      await sendWebhook("session.idle", {
+        status: "idle",
+      })
+    },
+
+    // 会话错误
+    "session.error": async (input, output) => {
+      console.log(`[StatusLight] Session error: ${JSON.stringify(input)}`)
+      await sendWebhook("session.error", {
+        error: input,
+      })
+    },
+
+    // 权限请求
+    "permission.asked": async (input, output) => {
+      console.log(`[StatusLight] Permission requested: ${JSON.stringify(input)}`)
+      await sendWebhook("permission.asked", {
+        permission: input,
+      })
+    },
+
+    // 消息更新(包含工具状态)
+    "message.part.updated": async (input, output) => {
+      const part = input as any
+      if (part.type === "tool") {
+        console.log(`[StatusLight] Tool part updated: ${part.tool} - ${part.state?.status}`)
+        await sendWebhook("message.part.updated", {
+          part: {
+            type: part.type,
+            tool: part.tool,
+            state: part.state,
+          },
+        })
+      } else if (part.type === "reasoning") {
+        console.log(`[StatusLight] Reasoning part updated`)
+        await sendWebhook("message.part.updated", {
+          part: {
+            type: part.type,
+            status: "reasoning",
+          },
+        })
+      }
+    },
+  }
+}

+ 18 - 0
opencode-plugin/package.json

@@ -0,0 +1,18 @@
+{
+  "name": "opencode-status-light-plugin",
+  "version": "1.0.0",
+  "description": "OpenCode plugin for AI Status Light monitoring",
+  "main": "index.ts",
+  "type": "module",
+  "dependencies": {
+    "@opencode-ai/plugin": "latest"
+  },
+  "keywords": [
+    "opencode",
+    "plugin",
+    "status-light",
+    "monitoring"
+  ],
+  "author": "",
+  "license": "MIT"
+}

+ 78 - 0
opencode-plugin/status-light.ts

@@ -0,0 +1,78 @@
+import type { Plugin } from "@opencode-ai/plugin"
+import { join } from "path"
+import { homedir } from "os"
+
+interface Config {
+  serviceUrl?: string
+}
+
+async function loadConfig(directory: string): Promise<Config> {
+  // 1. 尝试项目级配置
+  try {
+    const projectConfig = Bun.file(join(directory, ".opencode", "status-light.json"))
+    if (await projectConfig.exists()) {
+      return await projectConfig.json()
+    }
+  } catch {}
+
+  // 2. 尝试全局配置
+  try {
+    const globalConfig = Bun.file(join(homedir(), ".config", "opencode", "status-light.json"))
+    if (await globalConfig.exists()) {
+      return await globalConfig.json()
+    }
+  } catch {}
+
+  return {}
+}
+
+export const StatusLightPlugin: Plugin = async ({ directory }) => {
+  const config = await loadConfig(directory)
+  const serviceUrl = process.env.STATUS_LIGHT_URL || config.serviceUrl || "http://localhost:8080"
+
+  async function sendStatus(code: string) {
+    try {
+      await fetch(`${serviceUrl}/api/event`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ code, timestamp: new Date().toISOString() }),
+      })
+    } catch {
+      // 静默失败,不影响 OpenCode
+    }
+  }
+
+  return {
+    event: async ({ event }) => {
+      let code = ""
+
+      switch (event.type) {
+        case "session.idle":
+          code = "idle"
+          break
+        case "session.status":
+          code = event.properties?.status?.type || ""
+          break
+        case "message.part.updated": {
+          const part = event.properties?.part
+          if (part?.type === "tool") {
+            code = part.state?.status || "using_tool"
+          } else if (part?.type === "reasoning") {
+            code = "reasoning"
+          }
+          break
+        }
+        case "permission.asked":
+          code = "permission"
+          break
+        case "session.error":
+          code = "error"
+          break
+      }
+
+      if (code) {
+        await sendStatus(code)
+      }
+    },
+  }
+}