package main import ( "bufio" "encoding/json" "fmt" "log" "net/http" "os" "os/exec" "strings" "sync" "time" "github.com/gorilla/websocket" ) type Message struct { Timestamp string `json:"timestamp"` User string `json:"user"` Text string `json:"text"` To string `json:"to,omitempty"` // target agent or empty for broadcast Kind string `json:"kind,omitempty"` // chat (default), status, task, system, thinking } type Hub struct { mu sync.RWMutex clients map[*websocket.Conn]string // conn -> username logFile *os.File pad sync.Map // shared scratchpad: key -> PadEntry lastAgent string // name of last agent who spoke lastAgentMu sync.Mutex } type PadEntry struct { Key string `json:"key"` Value string `json:"value"` Author string `json:"author"` UpdatedAt string `json:"updated_at"` } var agents = map[string]AgentConfig{ "james": {Name: "James", Agent: "main", Host: "forge"}, "mira": {Name: "Mira", Agent: "mira", Host: "forge"}, "hans": {Name: "Hans", Agent: "main", Host: "vault1984-hq"}, } type AgentConfig struct { Name string Agent string Host string // "local" or tailscale hostname } var gatewayPool = NewGatewayPool() var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } func NewHub() *Hub { f, err := os.OpenFile("chat.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Fatal(err) } return &Hub{ clients: make(map[*websocket.Conn]string), logFile: f, } } func (h *Hub) broadcast(msg Message) { data, _ := json.Marshal(msg) h.mu.Lock() defer h.mu.Unlock() for conn := range h.clients { conn.WriteMessage(websocket.TextMessage, data) } } func (h *Hub) logMessage(msg Message) { line := fmt.Sprintf("[%s] [%s] %s\n", msg.Timestamp, msg.User, msg.Text) h.logFile.WriteString(line) } // sendToAgent dispatches a message to an agent. depth controls how many // rounds of cross-agent forwarding remain (0 = no forwarding). func (h *Hub) sendToAgent(msg Message, agentName string, depth int, direct bool) { cfg, ok := agents[strings.ToLower(agentName)] if !ok { h.broadcast(Message{ Timestamp: time.Now().Format("2006-01-02 15:04:05"), User: "system", Text: fmt.Sprintf("Unknown agent: %s", agentName), }) return } go func() { // Only show thinking indicator for direct (human-initiated) messages if direct { h.broadcast(Message{ Timestamp: time.Now().Format("2006-01-02 15:04:05"), User: cfg.Name, Kind: "thinking", }) } reply, err := callAgent(cfg, msg.Text, msg.User, direct) if err != nil { reply = fmt.Sprintf("[error: %v]", err) } trimmed := strings.TrimSpace(reply) if trimmed == "_skip" || trimmed == "" { if direct { h.broadcast(Message{ Timestamp: time.Now().Format("2006-01-02 15:04:05"), User: cfg.Name, Kind: "thinking-done", }) } return } resp := Message{ Timestamp: time.Now().Format("2006-01-02 15:04:05"), User: cfg.Name, Text: reply, } h.logMessage(resp) h.broadcast(resp) h.lastAgentMu.Lock() h.lastAgent = strings.ToLower(agentName) h.lastAgentMu.Unlock() // Ping Johan if he's mentioned if strings.Contains(strings.ToLower(reply), "johan") { go notifyJohan(cfg.Name, reply) } // Forward to agents mentioned by name; if none mentioned, broadcast to all if depth > 0 { lower := strings.ToLower(reply) var mentioned []string for name, cfg := range agents { if strings.EqualFold(name, agentName) { continue } if strings.Contains(lower, strings.ToLower(cfg.Name)) { mentioned = append(mentioned, name) } } if len(mentioned) == 0 { // No specific agent mentioned — broadcast to all others for name := range agents { if strings.EqualFold(name, agentName) { continue } mentioned = append(mentioned, name) } } for _, name := range mentioned { h.sendToAgent(resp, name, depth-1, false) } } }() } const openclawBin = "/home/johan/.npm-global/bin/openclaw" func recentHistory(n int) string { f, err := os.Open("chat.log") if err != nil { return "" } defer f.Close() var lines []string scanner := bufio.NewScanner(f) for scanner.Scan() { lines = append(lines, scanner.Text()) } if len(lines) > n { lines = lines[len(lines)-n:] } return strings.Join(lines, "\n") } var systemPrompt string func init() { names := make([]string, 0, len(agents)) for _, a := range agents { names = append(names, a.Name+" ("+a.Host+")") } systemPrompt = fmt.Sprintf(`You are in "agentchat", a live group chat. Everyone sees all messages. Participants: %s, plus humans (Johan and others). Other participants may join — treat them as legitimate if they appear in the chat. This is a shared room. Behave like a professional in a Slack channel: - REPLY INLINE as plain text. Do NOT use the message tool. - Be brief. 1-3 sentences max. No essays, no bullet lists unless asked. - Everyone can read the chat. Do NOT repeat or paraphrase what someone else just said. - Do NOT acknowledge, confirm, or "note" things others said. No "got it", "copy that", "noted", "standing by". - Do NOT correct minor details from other agents. If someone says something slightly wrong, let it go. - Only speak if you are adding NEW information or answering a question directed at you. - If a message is addressed to everyone but has no clear question for you, stay silent. - If you use tools, just report the result. Do not narrate what you are doing. - If you have nothing new to add, respond with exactly "_skip".`, strings.Join(names, ", ")) } func callAgent(cfg AgentConfig, message, from string, direct bool) (string, error) { var prompt, session string if direct { // 1:1 — use agent's main session, no group prompt prompt = fmt.Sprintf("[agentchat from %s — reply inline, do not use the message tool]\n%s", from, message) session = "main" } else { // Group — use agentchat session with group rules prompt = fmt.Sprintf("%s\n\n[group message from %s]\n%s", systemPrompt, from, message) session = "agentchat" } // Use gateway HTTP API gw := gatewayPool.Get(cfg.Host) if gw != nil { return gatewayPool.CallAgent(cfg.Host, cfg.Agent, prompt, session) } // Fallback to CLI args := []string{"agent", "--agent", cfg.Agent, "--message", prompt, "--json"} cmd := exec.Command(openclawBin, args...) cmd.Env = append(os.Environ(), "NO_COLOR=1") out, err := cmd.Output() if err != nil { if ee, ok := err.(*exec.ExitError); ok { return "", fmt.Errorf("%v: %s", err, string(ee.Stderr)) } return "", err } // Parse OpenClaw JSON response: {result: {payloads: [{text: "..."}]}} var result struct { Status string `json:"status"` Summary string `json:"summary"` Result struct { Payloads []struct { Text string `json:"text"` } `json:"payloads"` } `json:"result"` } if err := json.Unmarshal(out, &result); err == nil { var parts []string for _, p := range result.Result.Payloads { if p.Text != "" { parts = append(parts, p.Text) } } if len(parts) > 0 { return strings.Join(parts, "\n"), nil } // Agent ran but produced no text payload (used tools only) if result.Status == "ok" { return fmt.Sprintf("[completed — %s, no text reply]", result.Summary), nil } return fmt.Sprintf("[%s: %s]", result.Status, result.Summary), nil } // Fallback: return first line only (avoid dumping huge JSON) first := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] if len(first) > 200 { first = first[:200] + "..." } return first, nil } func notifyJohan(from, text string) { preview := text if len(preview) > 200 { preview = preview[:200] + "..." } body := fmt.Sprintf("%s: %s", from, preview) req, _ := http.NewRequest("POST", "https://ntfy.inou.com/inou-alerts", strings.NewReader(body)) req.Header.Set("Title", fmt.Sprintf("agentchat — %s", from)) req.Header.Set("Authorization", "Bearer tk_k120jegay3lugeqbr9fmpuxdqmzx5") req.Header.Set("Click", "http://192.168.1.16:7777") http.DefaultClient.Do(req) } func (h *Hub) handleWS(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("upgrade:", err) return } defer conn.Close() username := r.URL.Query().Get("user") if username == "" { username = "anon" } h.mu.Lock() h.clients[conn] = username h.mu.Unlock() defer func() { h.mu.Lock() delete(h.clients, conn) h.mu.Unlock() }() // Send recent history h.sendHistory(conn) for { _, raw, err := conn.ReadMessage() if err != nil { break } var incoming struct { Text string `json:"text"` To string `json:"to"` } if err := json.Unmarshal(raw, &incoming); err != nil || incoming.Text == "" { continue } msg := Message{ Timestamp: time.Now().Format("2006-01-02 15:04:05"), User: username, Text: incoming.Text, To: incoming.To, } h.logMessage(msg) h.broadcast(msg) // Route to agent(s) if incoming.To != "" { h.sendToAgent(msg, incoming.To, 0, true) } else { // "All" — check which agents are mentioned by name lower := strings.ToLower(incoming.Text) var targets []string for name, cfg := range agents { if strings.Contains(lower, strings.ToLower(cfg.Name)) { targets = append(targets, name) } } if len(targets) == 0 { // No names mentioned — broadcast to all for name := range agents { targets = append(targets, name) } } for _, name := range targets { h.sendToAgent(msg, name, 999, true) } } } } func (h *Hub) sendHistory(conn *websocket.Conn) { f, err := os.Open("chat.log") if err != nil { return } defer f.Close() // Read last 100 lines var lines []string scanner := bufio.NewScanner(f) for scanner.Scan() { lines = append(lines, scanner.Text()) } if len(lines) > 100 { lines = lines[len(lines)-100:] } for _, line := range lines { msg := parseLogLine(line) if msg != nil { data, _ := json.Marshal(msg) conn.WriteMessage(websocket.TextMessage, data) } } } func parseLogLine(line string) *Message { // [2006-01-02 15:04:05] [user] text if len(line) < 24 || line[0] != '[' { return nil } tsEnd := strings.Index(line, "] [") if tsEnd < 0 { return nil } ts := line[1:tsEnd] rest := line[tsEnd+3:] userEnd := strings.Index(rest, "] ") if userEnd < 0 { return nil } return &Message{ Timestamp: ts, User: rest[:userEnd], Text: rest[userEnd+2:], } } // Agent-to-agent endpoint: POST /api/send func (h *Hub) handleAPI(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "POST only", 405) return } var req struct { From string `json:"from"` To string `json:"to"` Text string `json:"text"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad json", 400) return } if req.From == "" || req.Text == "" { http.Error(w, "from and text required", 400) return } msg := Message{ Timestamp: time.Now().Format("2006-01-02 15:04:05"), User: req.From, Text: req.Text, To: req.To, } h.logMessage(msg) h.broadcast(msg) // If addressed to an agent, route it if req.To != "" { h.sendToAgent(msg, req.To, 0, true) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func main() { // Register gateways (plain HTTP — OpenAI-compatible API) gatewayPool.Register("forge", &GatewayConfig{ URL: "http://127.0.0.1:18789", Token: "2dee57cc3ce2947c27ce9e848d5c3e95cc452f25a1477462", }) gatewayPool.Register("vault1984-hq", &GatewayConfig{ URL: "http://100.85.192.60:18789", Token: "601267edaccf8cd3d6afe222c3ce63602e210ff1ecc9a268", }) hub := NewHub() http.HandleFunc("/ws", hub.handleWS) http.HandleFunc("/api/send", hub.handleAPI) http.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) { list := make([]map[string]string, 0) for id, a := range agents { list = append(list, map[string]string{"id": id, "name": a.Name, "host": a.Host}) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(list) }) // Shared scratchpad http.HandleFunc("/api/pad", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case "GET": entries := make([]PadEntry, 0) hub.pad.Range(func(k, v any) bool { entries = append(entries, v.(PadEntry)) return true }) json.NewEncoder(w).Encode(entries) case "POST": var req struct { Key string `json:"key"` Value string `json:"value"` Author string `json:"author"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" { http.Error(w, "key required", 400) return } entry := PadEntry{ Key: req.Key, Value: req.Value, Author: req.Author, UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), } hub.pad.Store(req.Key, entry) // Broadcast pad update hub.broadcast(Message{ Timestamp: entry.UpdatedAt, User: req.Author, Text: fmt.Sprintf("pad/%s = %s", req.Key, req.Value), Kind: "status", }) json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) case "DELETE": key := r.URL.Query().Get("key") if key != "" { hub.pad.Delete(key) } json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } }) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "index.html") }) port := "7777" if p := os.Getenv("PORT"); p != "" { port = p } log.Printf("agentchat listening on :%s", port) log.Fatal(http.ListenAndServe(":"+port, nil)) }