diff --git a/README.md b/README.md
index 6d74a0a..559f9b9 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,8 @@
- ✅ **多模型并行** - Codex ∥ Gemini 同时分析,交叉验证减少错误
- ✅ **零写入权限** - 外部模型只返回 Patch,Claude 保持代码主权
- ✅ **Token 优化** - ROLE_FILE 动态注入,专家提示词零消耗
-- ✅ **一键安装** - npx 运行,自动安装全部 14 个命令
+- ✅ **Web UI 实时输出** - 自动打开浏览器,流式显示思考过程、命令执行、生成结果
+- ✅ **一键安装** - npx 运行,自动安装全部 15 个命令
---
@@ -57,7 +58,7 @@ npx ccg-workflow
**安装流程**:
1. 选择是否配置 ace-tool MCP(可跳过)
2. 确认安装
-3. 自动安装全部 14 个命令
+3. 自动安装全部 15 个命令
4. 配置 PATH(如需要)
就这么简单!无需选择语言、模型、命令预设。
@@ -69,7 +70,7 @@ npx ccg-workflow
| 前端模型 | **Gemini** |
| 后端模型 | **Codex** |
| 协作模式 | **smart** |
-| 命令数量 | **14 个**(全部安装) |
+| 命令数量 | **15 个**(全部安装) |
### 第一个命令
@@ -99,7 +100,7 @@ npx ccg-workflow
/ccg:backend # 纯后端任务(Codex 主导,更快)
```
-### 完整命令列表(14 个)
+### 完整命令列表(15 个)
#### 开发工作流
@@ -109,6 +110,7 @@ npx ccg-workflow
| `/ccg:frontend` | 前端专项(快速模式) | Gemini |
| `/ccg:backend` | 后端专项(快速模式) | Codex |
| `/ccg:feat` | 智能功能开发 | 规划 → 实施 |
+| `/ccg:enhance` | Prompt 增强(ace-tool) | MCP |
| `/ccg:analyze` | 技术分析(仅分析不改代码) | Codex ∥ Gemini |
| `/ccg:debug` | 问题诊断 + 修复 | Codex ∥ Gemini |
| `/ccg:optimize` | 性能优化 | Codex ∥ Gemini |
@@ -166,7 +168,7 @@ npx ccg-workflow
```
~/.claude/
-├── commands/ccg/ # 14 个斜杠命令
+├── commands/ccg/ # 15 个斜杠命令
├── agents/ccg/ # 4 个子智能体
├── skills/ # 暂无
├── bin/
@@ -350,7 +352,7 @@ Copyright (c) 2025 fengshao1227
-**版本**: v1.7.0 | **最后更新**: 2026-01-08
+**版本**: v1.7.3 | **最后更新**: 2026-01-09
Made with ❤️ by the CCG Community
diff --git a/codeagent-wrapper/executor.go b/codeagent-wrapper/executor.go
index 8062f8b..906bb70 100644
--- a/codeagent-wrapper/executor.go
+++ b/codeagent-wrapper/executor.go
@@ -2,6 +2,8 @@ package main
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"errors"
"fmt"
"io"
@@ -832,6 +834,23 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
codexArgs = argsBuilder(cfg, targetArg)
}
+ // Start WebServer for this task (single-panel, random port, short-lived)
+ var webSessionID string
+ if globalWebServer == nil {
+ globalWebServer = NewWebServer(cfg.Backend)
+ if err := globalWebServer.Start(); err != nil {
+ logWarn(fmt.Sprintf("Failed to start web server: %v", err))
+ }
+ }
+
+ // Generate a unique session ID for WebServer tracking
+ if globalWebServer != nil {
+ randBytes := make([]byte, 4)
+ rand.Read(randBytes)
+ webSessionID = fmt.Sprintf("%s-%d-%s", cfg.Backend, time.Now().UnixMilli(), hex.EncodeToString(randBytes))
+ globalWebServer.StartSession(webSessionID, cfg.Backend, taskSpec.Task)
+ }
+
prefixMsg := func(msg string) string {
if taskSpec.ID == "" {
return msg
@@ -935,16 +954,12 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
stderrWriters = append(stderrWriters, stderrLogger)
}
- // For gemini backend, filter noisy stderr output
+ // Filter noisy stderr output for all backends
var stderrFilter *filteringWriter
if !silent {
- stderrOut := io.Writer(os.Stderr)
- if cfg.Backend == "gemini" {
- stderrFilter = newFilteringWriter(os.Stderr, geminiNoisePatterns)
- stderrOut = stderrFilter
- defer stderrFilter.Flush()
- }
- stderrWriters = append([]io.Writer{stderrOut}, stderrWriters...)
+ stderrFilter = newFilteringWriter(os.Stderr, noisePatterns)
+ defer stderrFilter.Flush()
+ stderrWriters = append([]io.Writer{stderrFilter}, stderrWriters...)
}
if len(stderrWriters) == 1 {
cmd.SetStderr(stderrWriters[0])
@@ -982,8 +997,19 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
messageSeen := make(chan struct{}, 1)
completeSeen := make(chan struct{}, 1)
parseCh := make(chan parseResult, 1)
+
+ // Create onContent callback for streaming to WebServer
+ var onContentCallback func(content, contentType string)
+ if globalWebServer != nil && webSessionID != "" {
+ sessionID := webSessionID
+ backendName := cfg.Backend
+ onContentCallback = func(content, contentType string) {
+ globalWebServer.SendContentWithType(sessionID, backendName, content, contentType)
+ }
+ }
+
go func() {
- msg, tid := parseJSONStreamInternal(stdoutReader, logWarnFn, logInfoFn, func() {
+ msg, tid := parseJSONStreamInternalWithContent(stdoutReader, logWarnFn, logInfoFn, func() {
select {
case messageSeen <- struct{}{}:
default:
@@ -993,7 +1019,11 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
case completeSeen <- struct{}{}:
default:
}
- })
+ // Notify WebServer that session is complete
+ if globalWebServer != nil && webSessionID != "" {
+ globalWebServer.EndSession(webSessionID, cfg.Backend)
+ }
+ }, onContentCallback)
select {
case completeSeen <- struct{}{}:
default:
diff --git a/codeagent-wrapper/filter.go b/codeagent-wrapper/filter.go
index ab5c522..7f065b0 100644
--- a/codeagent-wrapper/filter.go
+++ b/codeagent-wrapper/filter.go
@@ -6,8 +6,9 @@ import (
"strings"
)
-// geminiNoisePatterns contains stderr patterns to filter for gemini backend
-var geminiNoisePatterns = []string{
+// noisePatterns contains stderr patterns to filter for all backends
+var noisePatterns = []string{
+ // Gemini specific
"[STARTUP]",
"Session cleanup disabled",
"Warning:",
@@ -16,6 +17,9 @@ var geminiNoisePatterns = []string{
"Loaded cached credentials",
"Loading extension:",
"YOLO mode is enabled",
+ // Common noise
+ "[WARN] Skipping unreadable directory",
+ "supports tool updates. Listening for changes",
}
// filteringWriter wraps an io.Writer and filters out lines matching patterns
diff --git a/codeagent-wrapper/filter_test.go b/codeagent-wrapper/filter_test.go
index 12042f8..0881ff8 100644
--- a/codeagent-wrapper/filter_test.go
+++ b/codeagent-wrapper/filter_test.go
@@ -14,31 +14,31 @@ func TestFilteringWriter(t *testing.T) {
}{
{
name: "filter STARTUP lines",
- patterns: geminiNoisePatterns,
+ patterns: noisePatterns,
input: "[STARTUP] Recording metric\nHello World\n[STARTUP] Another line\n",
want: "Hello World\n",
},
{
name: "filter Warning lines",
- patterns: geminiNoisePatterns,
+ patterns: noisePatterns,
input: "Warning: something bad\nActual output\n",
want: "Actual output\n",
},
{
name: "filter multiple patterns",
- patterns: geminiNoisePatterns,
+ patterns: noisePatterns,
input: "YOLO mode is enabled\nSession cleanup disabled\nReal content\nLoading extension: foo\n",
want: "Real content\n",
},
{
name: "no filtering needed",
- patterns: geminiNoisePatterns,
+ patterns: noisePatterns,
input: "Line 1\nLine 2\nLine 3\n",
want: "Line 1\nLine 2\nLine 3\n",
},
{
name: "empty input",
- patterns: geminiNoisePatterns,
+ patterns: noisePatterns,
input: "",
want: "",
},
@@ -60,7 +60,7 @@ func TestFilteringWriter(t *testing.T) {
func TestFilteringWriterPartialLines(t *testing.T) {
var buf bytes.Buffer
- fw := newFilteringWriter(&buf, geminiNoisePatterns)
+ fw := newFilteringWriter(&buf, noisePatterns)
// Write partial line
fw.Write([]byte("Hello "))
diff --git a/codeagent-wrapper/main.go b/codeagent-wrapper/main.go
index 5ad2107..53e3da3 100644
--- a/codeagent-wrapper/main.go
+++ b/codeagent-wrapper/main.go
@@ -56,6 +56,9 @@ var (
var forceKillDelay atomic.Int32
+// globalWebServer is the SSE web server for live streaming output
+var globalWebServer *WebServer
+
func init() {
forceKillDelay.Store(5) // seconds - default value
}
diff --git a/codeagent-wrapper/parser.go b/codeagent-wrapper/parser.go
index 7f6e11a..02b30f1 100644
--- a/codeagent-wrapper/parser.go
+++ b/codeagent-wrapper/parser.go
@@ -96,6 +96,10 @@ type ItemContent struct {
}
func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func()) (message, threadID string) {
+ return parseJSONStreamInternalWithContent(r, warnFn, infoFn, onMessage, onComplete, nil)
+}
+
+func parseJSONStreamInternalWithContent(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func(), onContent func(content, contentType string)) (message, threadID string) {
reader := bufio.NewReaderSize(r, jsonLineReaderSize)
if warnFn == nil {
@@ -159,7 +163,7 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
}
// Detect backend type by field presence
- isCodex := event.ThreadID != ""
+ isCodex := event.ThreadID != "" || event.Type == "turn.completed" || event.Type == "turn.started"
if !isCodex && len(event.Item) > 0 {
var itemHeader struct {
Type string `json:"type"`
@@ -193,11 +197,11 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
threadID = event.ThreadID
infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID))
- case "thread.completed":
+ case "thread.completed", "turn.completed":
if event.ThreadID != "" && threadID == "" {
threadID = event.ThreadID
}
- infoFn(fmt.Sprintf("thread.completed event thread_id=%s", event.ThreadID))
+ infoFn(fmt.Sprintf("%s event thread_id=%s", event.Type, event.ThreadID))
notifyComplete()
case "item.completed":
@@ -211,20 +215,49 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
}
}
- if itemType == "agent_message" && len(event.Item) > 0 {
- // Lazy parse: only parse item content when needed
+ switch itemType {
+ case "agent_message", "reasoning":
+ // Parse text content
var item ItemContent
if err := json.Unmarshal(event.Item, &item); err == nil {
normalized := normalizeText(item.Text)
infoFn(fmt.Sprintf("item.completed event item_type=%s message_len=%d", itemType, len(normalized)))
if normalized != "" {
- codexMessage = normalized
- notifyMessage()
+ if itemType == "agent_message" {
+ codexMessage = normalized
+ notifyMessage()
+ }
+ // Send content (Codex outputs complete blocks, not streaming deltas)
+ if onContent != nil {
+ onContent(normalized, itemType)
+ }
}
} else {
warnFn(fmt.Sprintf("Failed to parse item content: %s", err.Error()))
}
- } else {
+
+ case "command_execution":
+ // Parse command execution
+ var cmdItem struct {
+ Command string `json:"command"`
+ AggregatedOutput string `json:"aggregated_output"`
+ ExitCode *int `json:"exit_code"`
+ Status string `json:"status"`
+ }
+ if err := json.Unmarshal(event.Item, &cmdItem); err == nil {
+ // Format command execution info
+ var content strings.Builder
+ content.WriteString(fmt.Sprintf("$ %s\n", cmdItem.Command))
+ if cmdItem.AggregatedOutput != "" {
+ content.WriteString(cmdItem.AggregatedOutput)
+ }
+ infoFn(fmt.Sprintf("item.completed event item_type=command_execution cmd=%s exit=%v", cmdItem.Command, cmdItem.ExitCode))
+ if onContent != nil && content.Len() > 0 {
+ onContent(content.String(), "command")
+ }
+ }
+
+ default:
infoFn(fmt.Sprintf("item.completed event item_type=%s", itemType))
}
}
@@ -242,6 +275,10 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
if event.Result != "" {
claudeMessage = event.Result
notifyMessage()
+ // Stream content to callback
+ if onContent != nil {
+ onContent(event.Result, "message")
+ }
}
if event.Type == "result" {
@@ -258,6 +295,10 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
if event.Content != "" {
geminiBuffer.WriteString(event.Content)
+ // Stream content to callback
+ if onContent != nil {
+ onContent(event.Content, "message")
+ }
}
if event.Status != "" {
diff --git a/codeagent-wrapper/server.go b/codeagent-wrapper/server.go
new file mode 100644
index 0000000..7666adf
--- /dev/null
+++ b/codeagent-wrapper/server.go
@@ -0,0 +1,488 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "runtime"
+ "sync"
+ "time"
+)
+
+// WebServer manages SSE connections for real-time output streaming
+type WebServer struct {
+ mu sync.RWMutex
+ clients map[string][]chan ContentEvent
+ sessions map[string]*SessionState
+ server *http.Server
+ port int
+ backend string // Current backend name for single-panel display
+}
+
+// SessionState tracks a running session
+type SessionState struct {
+ ID string `json:"id"`
+ Backend string `json:"backend"`
+ Task string `json:"task"`
+ StartTime time.Time `json:"start_time"`
+ Content string `json:"content"`
+ Done bool `json:"done"`
+}
+
+// ContentEvent is sent to SSE clients
+type ContentEvent struct {
+ SessionID string `json:"session_id"`
+ Backend string `json:"backend"`
+ Content string `json:"content,omitempty"`
+ ContentType string `json:"content_type,omitempty"` // "reasoning", "command", "message"
+ Done bool `json:"done,omitempty"`
+}
+
+// NewWebServer creates a new web server
+func NewWebServer(backend string) *WebServer {
+ return &WebServer{
+ clients: make(map[string][]chan ContentEvent),
+ sessions: make(map[string]*SessionState),
+ backend: backend,
+ }
+}
+
+// Start starts the web server on a random available port
+func (ws *WebServer) Start() error {
+ mux := http.NewServeMux()
+
+ // Serve the main page
+ mux.HandleFunc("/", ws.handleIndex)
+
+ // API endpoints
+ mux.HandleFunc("/api/sessions", ws.handleSessions)
+ mux.HandleFunc("/api/stream/", ws.handleStream)
+
+ // Listen on port 0 to get a random available port
+ listener, err := net.Listen("tcp", ":0")
+ if err != nil {
+ return err
+ }
+
+ ws.port = listener.Addr().(*net.TCPAddr).Port
+
+ ws.server = &http.Server{
+ Handler: mux,
+ }
+
+ url := fmt.Sprintf("http://localhost:%d", ws.port)
+ fmt.Fprintf(os.Stderr, " Web UI: %s\n", url)
+
+ go func() {
+ if err := ws.server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ logWarn(fmt.Sprintf("Web server error: %v", err))
+ }
+ }()
+
+ // Auto-open browser
+ go openBrowser(url)
+
+ return nil
+}
+
+// openBrowser opens the specified URL in the default browser
+func openBrowser(url string) {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "linux":
+ cmd = exec.Command("xdg-open", url)
+ case "windows":
+ cmd = exec.Command("cmd", "/c", "start", url)
+ default:
+ return
+ }
+ _ = cmd.Start()
+}
+
+// StartSession starts tracking a new session
+func (ws *WebServer) StartSession(id, backend, task string) {
+ ws.mu.Lock()
+ defer ws.mu.Unlock()
+
+ ws.sessions[id] = &SessionState{
+ ID: id,
+ Backend: backend,
+ Task: task,
+ StartTime: time.Now(),
+ }
+}
+
+// SendContent sends content to all subscribers of a session
+func (ws *WebServer) SendContent(sessionID, backend, content string) {
+ ws.SendContentWithType(sessionID, backend, content, "message")
+}
+
+// SendContentWithType sends content with a specific type to all subscribers
+func (ws *WebServer) SendContentWithType(sessionID, backend, content, contentType string) {
+ ws.mu.Lock()
+ defer ws.mu.Unlock()
+
+ // Update session state
+ if session, ok := ws.sessions[sessionID]; ok {
+ session.Content += content
+ }
+
+ // Send to all subscribers
+ event := ContentEvent{
+ SessionID: sessionID,
+ Backend: backend,
+ Content: content,
+ ContentType: contentType,
+ }
+
+ for _, ch := range ws.clients[sessionID] {
+ select {
+ case ch <- event:
+ default:
+ // Skip if channel is full
+ }
+ }
+}
+
+// EndSession marks a session as complete
+func (ws *WebServer) EndSession(sessionID, backend string) {
+ ws.mu.Lock()
+ defer ws.mu.Unlock()
+
+ if session, ok := ws.sessions[sessionID]; ok {
+ session.Done = true
+ }
+
+ event := ContentEvent{
+ SessionID: sessionID,
+ Backend: backend,
+ Done: true,
+ }
+
+ for _, ch := range ws.clients[sessionID] {
+ select {
+ case ch <- event:
+ default:
+ }
+ }
+}
+
+// handleIndex serves the main HTML page
+func (ws *WebServer) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Generate single-panel HTML based on backend
+ html := ws.generateIndexHTML()
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(html))
+}
+
+// generateIndexHTML creates a single-panel HTML page for the current backend
+func (ws *WebServer) generateIndexHTML() string {
+ backend := ws.backend
+ if backend == "" {
+ backend = "Agent"
+ }
+
+ // Determine colors based on backend
+ iconBg := "#238636"
+ titleColor := "#3fb950"
+ iconText := "AGT"
+
+ switch backend {
+ case "codex":
+ iconBg = "#238636"
+ titleColor = "#3fb950"
+ iconText = "CDX"
+ case "gemini":
+ iconBg = "#8957e5"
+ titleColor = "#a371f7"
+ iconText = "GEM"
+ case "claude":
+ iconBg = "#d97706"
+ titleColor = "#fbbf24"
+ iconText = "CLD"
+ }
+
+ return fmt.Sprintf(`
+
+
+
+
+
%s - Live Output
+
+
+
+
+
+
+
+`, backend, iconBg, titleColor, iconText, backend)
+}
+
+// handleSessions returns all active sessions
+func (ws *WebServer) handleSessions(w http.ResponseWriter, r *http.Request) {
+ ws.mu.RLock()
+ sessions := make([]*SessionState, 0, len(ws.sessions))
+ for _, s := range ws.sessions {
+ sessions = append(sessions, s)
+ }
+ ws.mu.RUnlock()
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(sessions)
+}
+
+// handleStream handles SSE connections for a session
+func (ws *WebServer) handleStream(w http.ResponseWriter, r *http.Request) {
+ sessionID := r.URL.Path[len("/api/stream/"):]
+ if sessionID == "" {
+ http.Error(w, "Session ID required", http.StatusBadRequest)
+ return
+ }
+
+ // Set SSE headers
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "SSE not supported", http.StatusInternalServerError)
+ return
+ }
+
+ // Create channel for this client
+ ch := make(chan ContentEvent, 100)
+
+ // Register client
+ ws.mu.Lock()
+ ws.clients[sessionID] = append(ws.clients[sessionID], ch)
+ // Only send done state if session is already complete (no historical content)
+ if session, ok := ws.sessions[sessionID]; ok && session.Done {
+ ch <- ContentEvent{
+ SessionID: sessionID,
+ Backend: session.Backend,
+ Done: true,
+ }
+ }
+ ws.mu.Unlock()
+
+ // Cleanup on disconnect
+ defer func() {
+ ws.mu.Lock()
+ clients := ws.clients[sessionID]
+ for i, c := range clients {
+ if c == ch {
+ ws.clients[sessionID] = append(clients[:i], clients[i+1:]...)
+ break
+ }
+ }
+ ws.mu.Unlock()
+ close(ch)
+ }()
+
+ // Stream events
+ for {
+ select {
+ case event := <-ch:
+ data, _ := json.Marshal(event)
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ flusher.Flush()
+
+ if event.Done {
+ return
+ }
+ case <-r.Context().Done():
+ return
+ }
+ }
+}
diff --git a/package.json b/package.json
index be5a48c..c0f06fd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ccg-workflow",
- "version": "1.7.2",
+ "version": "1.7.3",
"description": "Claude-Codex-Gemini 多模型协作系统 - 智能路由多模型开发工作流",
"type": "module",
"packageManager": "pnpm@10.17.1",
diff --git a/templates/commands/enhance.md b/templates/commands/enhance.md
new file mode 100644
index 0000000..613c577
--- /dev/null
+++ b/templates/commands/enhance.md
@@ -0,0 +1,36 @@
+---
+description: 使用 ace-tool enhance_prompt 优化 Prompt,展示原始与增强版本供确认
+---
+
+## Usage
+`/ccg:enhance
`
+
+## Context
+- Original prompt: $ARGUMENTS
+
+## Your Role
+You are the **Prompt Enhancer** that optimizes user prompts for better AI task execution.
+
+## Process
+
+### Step 1: 调用 Prompt 增强工具
+
+调用 `mcp__ace-tool__enhance_prompt` 工具:
+- `prompt`: 用户原始输入
+- `conversation_history`: 最近 5-10 轮对话历史
+- `project_root_path`: 当前项目根目录(可选)
+
+### Step 2: 处理响应
+
+根据工具返回的结果:
+- **增强后的 prompt**: 展示增强版本,询问用户是否使用
+- **`__END_CONVERSATION__`**: 停止对话,不执行任何任务
+- **工具调用失败**: 直接使用原始 prompt
+
+### Step 3: 执行任务
+
+根据用户选择执行相应操作。
+
+## Notes
+- 支持自动语言检测(中文输入 → 中文输出)
+- 也可通过在消息末尾添加 `-enhance` 或 `-Enhancer` 触发