package rpc import ( "bufio" "encoding/json" "fmt" "io" "log" "os" "os/exec" "strconv" "strings" "sync" "time" "sproutclaw-web/internal/models" ) const ( cmdTimeout = 60 * time.Second promptTimeout = 30 * time.Second ) // bufferedEventTypes are the event types replayed to newly-connected SSE // clients while the agent is mid-run (mirrors the original pi-client.ts). var bufferedEventTypes = map[string]bool{ "agent_start": true, "agent_end": true, "message_start": true, "message_update": true, "message_end": true, "tool_execution_start": true, "tool_execution_update": true, "tool_execution_end": true, "compaction_start": true, "compaction_end": true, "queue_update": true, "auto_retry_start": true, "auto_retry_end": true, "bash_update": true, } // SSEClient represents a connected browser SSE client. type SSEClient struct { Ch chan []byte Done <-chan struct{} } // RunSnapshot holds a snapshot of the current pi agent run state. type RunSnapshot struct { IsStreaming bool SessionFile string ReplayEvents []json.RawMessage } // Client manages a pi CLI subprocess and bridges its JSON-RPC protocol // to HTTP handlers and SSE clients. type Client struct { mu sync.Mutex cmd *exec.Cmd stdin io.WriteCloser pending map[string]chan models.RPCResponse sseClients map[*SSEClient]struct{} replay []json.RawMessage // events in current agent run isRunning bool sessionFile string reqMu sync.Mutex reqCounter int } // NewClient starts the pi CLI subprocess and begins reading its stdout. // repoRoot is used as the subprocess working directory; onExit is called // with the exit code when the subprocess terminates. func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit func(int)) (*Client, error) { if piCmd == "" { return nil, fmt.Errorf("pi CLI command not specified (use --pi-cmd)") } // pi enters RPC mode via "--mode rpc" args := append(append([]string{}, piArgs...), "--mode", "rpc") cmd := exec.Command(piCmd, args...) cmd.Dir = repoRoot cmd.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir)) log.Printf("[sproutclaw-web] launching pi RPC: %s %s (cwd=%s)", piCmd, strings.Join(args, " "), repoRoot) stdin, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("stdin pipe: %w", err) } stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("stdout pipe: %w", err) } cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return nil, fmt.Errorf("start pi cli: %w", err) } // Keep stdin open; closing it on Windows can trigger RPC shutdown. _, _ = stdin.Write([]byte("")) c := &Client{ cmd: cmd, stdin: stdin, pending: make(map[string]chan models.RPCResponse), sseClients: make(map[*SSEClient]struct{}), } go c.readLoop(stdout) go func() { _ = cmd.Wait() code := 0 if cmd.ProcessState != nil { code = cmd.ProcessState.ExitCode() } log.Printf("[sproutclaw-web] pi RPC exited, code=%d", code) onExit(code) }() return c, nil } func (c *Client) nextReqID() string { c.reqMu.Lock() c.reqCounter++ id := c.reqCounter c.reqMu.Unlock() return "req_" + strconv.Itoa(id) } // readLoop reads stdout line-by-line and dispatches messages. func (c *Client) readLoop(r io.Reader) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 4*1024*1024), 16*1024*1024) for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } c.dispatch(append([]byte(nil), line...)) } } // dispatch parses one JSON line from stdout and routes it. func (c *Client) dispatch(raw []byte) { var head struct { Type string `json:"type"` ID string `json:"id"` } if err := json.Unmarshal(raw, &head); err != nil { return // ignore non-JSON lines } // Response frame: resolve the pending request. if head.Type == "response" && head.ID != "" { var resp models.RPCResponse if err := json.Unmarshal(raw, &resp); err == nil { // capture sessionFile from get_state responses if resp.Command == "get_state" && resp.Success { var data struct { SessionFile string `json:"sessionFile"` } if json.Unmarshal(resp.Data, &data) == nil && data.SessionFile != "" { c.mu.Lock() c.sessionFile = data.SessionFile c.mu.Unlock() } } c.mu.Lock() ch, ok := c.pending[resp.ID] if ok { delete(c.pending, resp.ID) } c.mu.Unlock() if ok { ch <- resp } return } } // Event frame: track agent run state + broadcast to SSE clients. c.trackAgentEvent(head.Type, raw) c.mu.Lock() c.broadcastLocked(raw) c.mu.Unlock() } func (c *Client) trackAgentEvent(typ string, raw []byte) { c.mu.Lock() defer c.mu.Unlock() switch typ { case "agent_start": c.isRunning = true c.replay = []json.RawMessage{raw} case "agent_end": c.replay = append(c.replay, raw) c.isRunning = false c.replay = nil default: if c.isRunning && bufferedEventTypes[typ] { c.replay = append(c.replay, raw) } } } // broadcastLocked sends raw JSON to all SSE clients. Must hold c.mu. func (c *Client) broadcastLocked(raw []byte) { frame := formatSSEData(raw) for client := range c.sseClients { select { case client.Ch <- frame: default: // slow client: drop } } } // send writes a JSON command to the pi CLI stdin. func (c *Client) send(cmd models.RPCCommand) error { b, err := json.Marshal(cmd) if err != nil { return err } c.mu.Lock() defer c.mu.Unlock() _, err = c.stdin.Write(append(b, '\n')) return err } // SendCmd sends a command and waits for the matching response. func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) { cmd.ID = c.nextReqID() ch := make(chan models.RPCResponse, 1) c.mu.Lock() c.pending[cmd.ID] = ch c.mu.Unlock() if err := c.send(cmd); err != nil { c.mu.Lock() delete(c.pending, cmd.ID) c.mu.Unlock() return models.RPCResponse{}, err } select { case resp := <-ch: return resp, nil case <-time.After(cmdTimeout): c.mu.Lock() delete(c.pending, cmd.ID) c.mu.Unlock() return models.RPCResponse{}, fmt.Errorf("命令超时: %s", cmd.Type) } } // SubmitPrompt sends a prompt. It registers a preflight pending entry but does // not block the HTTP handler — agent output arrives via SSE. func (c *Client) SubmitPrompt(req models.ChatRequest) error { id := c.nextReqID() cmd := models.RPCCommand{ Type: "prompt", ID: id, Message: req.Message, Images: req.Images, StreamingBehavior: req.StreamingBehavior, } ch := make(chan models.RPCResponse, 1) c.mu.Lock() c.pending[id] = ch c.mu.Unlock() if err := c.send(cmd); err != nil { c.mu.Lock() delete(c.pending, id) c.mu.Unlock() return err } // Preflight: wait briefly for a rejection; success streams via SSE. go func() { select { case resp := <-ch: if !resp.Success { c.mu.Lock() c.broadcastLocked([]byte(fmt.Sprintf(`{"type":"prompt_rejected","error":%q}`, resp.Error))) c.mu.Unlock() } case <-time.After(promptTimeout): c.mu.Lock() delete(c.pending, id) c.mu.Unlock() } }() return nil } // GetSnapshot returns the current run snapshot. func (c *Client) GetSnapshot() RunSnapshot { c.mu.Lock() defer c.mu.Unlock() replay := make([]json.RawMessage, len(c.replay)) copy(replay, c.replay) return RunSnapshot{ IsStreaming: c.isRunning, SessionFile: c.sessionFile, ReplayEvents: replay, } } // RegisterSSEClient adds a client and sends the initial "connected" frame // carrying the current run snapshot (matches the original pi-client.ts). func (c *Client) RegisterSSEClient(client *SSEClient) { c.mu.Lock() c.sseClients[client] = struct{}{} replay := make([]json.RawMessage, len(c.replay)) copy(replay, c.replay) isStreaming := c.isRunning c.mu.Unlock() connected := map[string]any{ "type": "connected", "isStreaming": isStreaming, "replay": replay, } b, _ := json.Marshal(connected) select { case client.Ch <- formatSSEData(b): case <-client.Done: } } // UnregisterSSEClient removes a client from the broadcast set. func (c *Client) UnregisterSSEClient(client *SSEClient) { c.mu.Lock() delete(c.sseClients, client) c.mu.Unlock() } // SendExtensionUIResponse forwards a UI response back to the pi CLI. // The response object is merged into the top-level frame: {type, id, ...response}. func (c *Client) SendExtensionUIResponse(id string, response json.RawMessage) error { merged := map[string]any{"type": "extension_ui_response", "id": id} var extra map[string]any if json.Unmarshal(response, &extra) == nil { for k, v := range extra { merged[k] = v } } b, err := json.Marshal(merged) if err != nil { return err } c.mu.Lock() defer c.mu.Unlock() _, err = c.stdin.Write(append(b, '\n')) return err } // Stop terminates the pi CLI subprocess. func (c *Client) Stop() { if c.cmd != nil && c.cmd.Process != nil { _ = c.cmd.Process.Kill() } } // IsRunning returns true when the agent is actively streaming. func (c *Client) IsRunning() bool { c.mu.Lock() defer c.mu.Unlock() return c.isRunning } // formatSSEData wraps a raw JSON payload as an SSE "data:" frame. func formatSSEData(raw []byte) []byte { trimmed := strings.TrimRight(string(raw), "\r\n") return []byte("data: " + trimmed + "\n\n") }