diff --git a/internal/cli/daemon_commands.go b/internal/cli/daemon_commands.go
index 52143b06..f3f143d6 100644
--- a/internal/cli/daemon_commands.go
+++ b/internal/cli/daemon_commands.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "io"
"net"
neturl "net/url"
"os"
@@ -25,6 +26,9 @@ var (
installHTTPDaemon = urlscheme.InstallHTTPDaemon
uninstallHTTPDaemon = urlscheme.UninstallHTTPDaemon
getHTTPDaemonStatus = urlscheme.GetHTTPDaemonStatus
+
+ daemonInstallJSONWriter io.Writer = os.Stdout
+ daemonInstallLogWriter io.Writer = os.Stderr
)
type daemonServeCommandOptions struct {
@@ -287,13 +291,26 @@ func defaultDaemonInstallCommandRunner(ctx context.Context, options daemonInstal
ListenAddress: options.ListenAddress,
})
if err != nil {
+ _, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon install failed: %v\n", err)
+ _, _ = fmt.Fprintf(daemonInstallLogWriter, "remedy: run `%s daemon install --listen %s`\n", executablePath, options.ListenAddress)
return err
}
- return encodeJSONLine(os.Stdout, map[string]any{
- "status": "ok",
- "listen_address": result.ListenAddress,
- "autostart_mode": result.AutostartMode,
- "hosts_warning": strings.TrimSpace(result.HostsWarning),
+ _, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon install succeeded: autostart=%s listen=%s\n", result.AutostartMode, result.ListenAddress)
+ if strings.TrimSpace(result.HostsWarning) != "" {
+ _, _ = fmt.Fprintf(daemonInstallLogWriter, "hosts warning: %s\n", strings.TrimSpace(result.HostsWarning))
+ }
+ if result.DaemonStarted {
+ _, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon started in background and is ready on %s\n", result.ListenAddress)
+ } else if strings.TrimSpace(result.DaemonStartWarning) != "" {
+ _, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon startup warning: %s\n", strings.TrimSpace(result.DaemonStartWarning))
+ }
+ return encodeJSONLine(daemonInstallJSONWriter, map[string]any{
+ "status": "ok",
+ "listen_address": result.ListenAddress,
+ "autostart_mode": result.AutostartMode,
+ "hosts_warning": strings.TrimSpace(result.HostsWarning),
+ "daemon_started": result.DaemonStarted,
+ "daemon_start_warning": strings.TrimSpace(result.DaemonStartWarning),
})
}
diff --git a/internal/cli/daemon_commands_test.go b/internal/cli/daemon_commands_test.go
index 4d9ddec2..0d2fd6d3 100644
--- a/internal/cli/daemon_commands_test.go
+++ b/internal/cli/daemon_commands_test.go
@@ -1,10 +1,11 @@
package cli
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"net/url"
- "os"
"strings"
"testing"
@@ -42,25 +43,34 @@ func TestDaemonInstallDefaultRunnerUsesCurrentExecutable(t *testing.T) {
originalRunner := runDaemonInstallCommand
originalResolveExecutablePath := resolveExecutablePath
originalInstall := installHTTPDaemon
+ originalJSONWriter := daemonInstallJSONWriter
+ originalLogWriter := daemonInstallLogWriter
t.Cleanup(func() { runDaemonInstallCommand = originalRunner })
t.Cleanup(func() { resolveExecutablePath = originalResolveExecutablePath })
t.Cleanup(func() { installHTTPDaemon = originalInstall })
+ t.Cleanup(func() { daemonInstallJSONWriter = originalJSONWriter })
+ t.Cleanup(func() { daemonInstallLogWriter = originalLogWriter })
runDaemonInstallCommand = defaultDaemonInstallCommandRunner
resolveExecutablePath = func() (string, error) {
return "/tmp/neocode", nil
}
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ daemonInstallJSONWriter = &stdout
+ daemonInstallLogWriter = &stderr
var captured urlscheme.HTTPDaemonInstallOptions
installHTTPDaemon = func(options urlscheme.HTTPDaemonInstallOptions) (urlscheme.HTTPDaemonInstallResult, error) {
captured = options
return urlscheme.HTTPDaemonInstallResult{
- ListenAddress: options.ListenAddress,
- AutostartMode: "test-mode",
+ ListenAddress: options.ListenAddress,
+ AutostartMode: "test-mode",
+ DaemonStarted: true,
+ DaemonStartWarning: "",
}, nil
}
command := NewRootCommand()
- command.SetOut(os.Stdout)
command.SetArgs([]string{"daemon", "install", "--listen", "127.0.0.1:19921"})
if err := command.ExecuteContext(context.Background()); err != nil {
t.Fatalf("ExecuteContext() error = %v", err)
@@ -71,6 +81,57 @@ func TestDaemonInstallDefaultRunnerUsesCurrentExecutable(t *testing.T) {
if captured.ListenAddress != "127.0.0.1:19921" {
t.Fatalf("listen address = %q, want %q", captured.ListenAddress, "127.0.0.1:19921")
}
+ var payload map[string]any
+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload); err != nil {
+ t.Fatalf("decode stdout json: %v", err)
+ }
+ if payload["status"] != "ok" {
+ t.Fatalf("status = %v, want ok", payload["status"])
+ }
+ if payload["daemon_started"] != true {
+ t.Fatalf("daemon_started = %v, want true", payload["daemon_started"])
+ }
+ if !strings.Contains(stderr.String(), "daemon install succeeded") {
+ t.Fatalf("stderr = %q, want success summary", stderr.String())
+ }
+}
+
+func TestDaemonInstallDefaultRunnerFailureWritesRemedy(t *testing.T) {
+ originalRunner := runDaemonInstallCommand
+ originalResolveExecutablePath := resolveExecutablePath
+ originalInstall := installHTTPDaemon
+ originalJSONWriter := daemonInstallJSONWriter
+ originalLogWriter := daemonInstallLogWriter
+ t.Cleanup(func() { runDaemonInstallCommand = originalRunner })
+ t.Cleanup(func() { resolveExecutablePath = originalResolveExecutablePath })
+ t.Cleanup(func() { installHTTPDaemon = originalInstall })
+ t.Cleanup(func() { daemonInstallJSONWriter = originalJSONWriter })
+ t.Cleanup(func() { daemonInstallLogWriter = originalLogWriter })
+
+ runDaemonInstallCommand = defaultDaemonInstallCommandRunner
+ resolveExecutablePath = func() (string, error) {
+ return "/tmp/neocode", nil
+ }
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ daemonInstallJSONWriter = &stdout
+ daemonInstallLogWriter = &stderr
+ installHTTPDaemon = func(options urlscheme.HTTPDaemonInstallOptions) (urlscheme.HTTPDaemonInstallResult, error) {
+ return urlscheme.HTTPDaemonInstallResult{}, errors.New("boom")
+ }
+
+ command := NewRootCommand()
+ command.SetArgs([]string{"daemon", "install", "--listen", "127.0.0.1:19921"})
+ err := command.ExecuteContext(context.Background())
+ if err == nil {
+ t.Fatal("expected install failure")
+ }
+ if !strings.Contains(stderr.String(), "remedy: run `/tmp/neocode daemon install --listen 127.0.0.1:19921`") {
+ t.Fatalf("stderr = %q, want remedy command", stderr.String())
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("stdout should be empty on failure, got %q", stdout.String())
+ }
}
func TestDaemonServeDoesNotExposeTokenFileFlag(t *testing.T) {
diff --git a/internal/gateway/adapters/urlscheme/daemon.go b/internal/gateway/adapters/urlscheme/daemon.go
index 12f2b3c1..cd3daa5f 100644
--- a/internal/gateway/adapters/urlscheme/daemon.go
+++ b/internal/gateway/adapters/urlscheme/daemon.go
@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"os"
+ "os/exec"
"path/filepath"
"runtime"
"strings"
@@ -30,9 +31,15 @@ const (
daemonAutostartModeDesktop = "desktop_autostart"
)
+const (
+ daemonHTTPPageTitle = "NeoCode Daemon"
+ daemonHTTPPageFaviconURL = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20120%20120'%3E%3Crect%20width='120'%20height='120'%20rx='22'%20fill='%230f172a'/%3E%3Cpath%20d='M36%2032h16l32%2056H68L36%2032z'%20fill='%2338bdf8'/%3E%3Cpath%20d='M84%2032H68L36%2088h16l32-56z'%20fill='%230ea5e9'/%3E%3C/svg%3E"
+)
+
var (
httpDaemonDispatchWakeFn = defaultHTTPDaemonDispatchWake
httpDaemonGetHTTPClient = defaultHTTPDaemonHTTPClient
+ httpDaemonStartProcessFn = startHTTPDaemonProcess
)
// HTTPDaemonServeOptions 定义 daemon serve 的启动参数。
@@ -49,9 +56,11 @@ type HTTPDaemonInstallOptions struct {
// HTTPDaemonInstallResult 返回 daemon install 的结果摘要。
type HTTPDaemonInstallResult struct {
- ListenAddress string `json:"listen_address"`
- AutostartMode string `json:"autostart_mode"`
- HostsWarning string `json:"hosts_warning,omitempty"`
+ ListenAddress string `json:"listen_address"`
+ AutostartMode string `json:"autostart_mode"`
+ HostsWarning string `json:"hosts_warning,omitempty"`
+ DaemonStarted bool `json:"daemon_started"`
+ DaemonStartWarning string `json:"daemon_start_warning,omitempty"`
}
// HTTPDaemonStatusOptions 定义 daemon status 的查询参数。
@@ -134,7 +143,12 @@ func InstallHTTPDaemon(options HTTPDaemonInstallOptions) (HTTPDaemonInstallResul
AutostartMode: mode,
}
if hostsErr := ensureDaemonHostsAlias(); hostsErr != nil {
- result.HostsWarning = hostsErr.Error()
+ result.HostsWarning = buildHostsAliasWarning(hostsErr)
+ }
+ if daemonStartErr := ensureHTTPDaemonProcessStarted(executablePath, listenAddress); daemonStartErr != nil {
+ result.DaemonStartWarning = daemonStartErr.Error()
+ } else {
+ result.DaemonStarted = true
}
return result, nil
}
@@ -220,7 +234,7 @@ func newHTTPDaemonHandler(
ListenAddress: gatewayListenAddress,
})
if err != nil {
- writeHTTPDaemonError(writer, http.StatusInternalServerError, "dispatch failed", err.Error())
+ writeHTTPDaemonDispatchError(writer, gatewayListenAddress, err)
return
}
writeHTTPDaemonSuccess(writer, request, result)
@@ -349,9 +363,51 @@ func writeHTTPDaemonError(writer http.ResponseWriter, statusCode int, title stri
writer.WriteHeader(statusCode)
escapedTitle := html.EscapeString(strings.TrimSpace(title))
escapedDetail := html.EscapeString(strings.TrimSpace(detail))
- _, _ = writer.Write([]byte(
- "
" + escapedTitle + "
" + escapedDetail + "
",
- ))
+ content := `` + escapedTitle + `
` + escapedDetail + `
`
+ _, _ = writer.Write([]byte(buildHTTPDaemonPageHTML(escapedTitle, content, "")))
+}
+
+// writeHTTPDaemonDispatchError 输出 wake 派发失败页面,并附带可执行补救指引。
+func writeHTTPDaemonDispatchError(writer http.ResponseWriter, gatewayListenAddress string, err error) {
+ title := "Dispatch failed"
+ detail := strings.TrimSpace(err.Error())
+ if detail == "" {
+ detail = "unknown dispatch error"
+ }
+ hints := []string{
+ "运行 `neocode daemon status` 检查 daemon 状态。",
+ }
+ startGatewayCommand := "neocode gateway"
+ if trimmedGatewayListenAddress := strings.TrimSpace(gatewayListenAddress); trimmedGatewayListenAddress != "" {
+ startGatewayCommand += " --listen " + trimmedGatewayListenAddress
+ }
+ hints = append(hints, "若 gateway 未运行,请手动执行 `"+startGatewayCommand+"`。")
+
+ var dispatchErr *DispatchError
+ if errors.As(err, &dispatchErr) {
+ switch dispatchErr.Code {
+ case ErrorCodeGatewayUnavailable:
+ title = "Gateway unavailable"
+ detail = "无法连接到 gateway,daemon 已尝试自动拉起,但当前不可达。"
+ if strings.Contains(strings.ToLower(dispatchErr.Message), "timed out") {
+ detail = "gateway 自动拉起后 10 秒内未就绪,无法继续派发。"
+ }
+ case ErrorCodeNotSupported:
+ title = "Terminal launch is not supported"
+ detail = strings.TrimSpace(dispatchErr.Message)
+ if detail == "" {
+ detail = "当前平台不支持自动拉起终端,请手动运行 neocode 续接会话。"
+ }
+ default:
+ if message := strings.TrimSpace(dispatchErr.Message); message != "" {
+ detail = message
+ }
+ }
+ }
+ if strings.TrimSpace(err.Error()) != "" {
+ hints = append(hints, "原始错误: "+strings.TrimSpace(err.Error()))
+ }
+ writeHTTPDaemonError(writer, http.StatusInternalServerError, title, strings.Join(hintsWithLead(detail, hints), "\n"))
}
// writeHTTPDaemonSuccess 输出浏览器可读的成功页面,并提供可复用的 session 链接。
@@ -362,11 +418,81 @@ func writeHTTPDaemonSuccess(writer http.ResponseWriter, request *http.Request, r
sessionID := html.EscapeString(strings.TrimSpace(result.SessionID))
reusableURL := buildHTTPDaemonReusableURL(request, result.SessionID)
escapedReusableURL := html.EscapeString(reusableURL)
- _, _ = writer.Write([]byte(
- "OK
action=" + action + "
session_id=" + sessionID +
- "
reusable_url=" + escapedReusableURL +
- "
tip=后续若要续接同一会话,请使用带 session_id 的链接。
",
- ))
+ content := `Wake Accepted
` +
+ `action=` + action + `
` +
+ `session_id=` + sessionID + `
` +
+ `reusable_url
` +
+ `` + escapedReusableURL + `` +
+ `` +
+ `点击后自动复制 reusable_url
` +
+ `后续若要续接同一会话,请使用带 session_id 的链接。
`
+ _, _ = writer.Write([]byte(buildHTTPDaemonPageHTML("Wake Accepted", content, daemonCopyScript())))
+}
+
+// buildHTTPDaemonPageHTML 渲染统一的 daemon HTML 页面骨架与基础样式。
+func buildHTTPDaemonPageHTML(title string, contentHTML string, script string) string {
+ escapedTitle := html.EscapeString(strings.TrimSpace(title))
+ if escapedTitle == "" {
+ escapedTitle = daemonHTTPPageTitle
+ }
+ if strings.TrimSpace(contentHTML) == "" {
+ contentHTML = `empty content
`
+ }
+ return "" +
+ "" +
+ "" + escapedTitle + " - " + daemonHTTPPageTitle + "" +
+ "" +
+ "" + contentHTML +
+ "" + script + ""
+}
+
+// daemonHTTPPageStyle 返回 daemon 页面使用的基础视觉样式。
+func daemonHTTPPageStyle() string {
+ return "body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;" +
+ "background:radial-gradient(circle at top,#e0f2fe 0%,#f8fafc 45%,#f1f5f9 100%);" +
+ "font-family:'Segoe UI',Tahoma,Helvetica,Arial,sans-serif;color:#0f172a;padding:24px;}" +
+ ".card{width:min(760px,100%);background:#ffffff;border:1px solid #dbeafe;border-radius:18px;" +
+ "box-shadow:0 18px 40px rgba(15,23,42,.12);padding:28px;}" +
+ ".status{margin:0 0 12px;font-size:28px;line-height:1.2;}" +
+ ".status.ok{color:#0284c7;}.status.error{color:#b91c1c;}" +
+ ".detail{margin:8px 0;font-size:15px;line-height:1.6;white-space:pre-line;}" +
+ ".label{margin:18px 0 8px;font-weight:600;font-size:14px;color:#334155;}" +
+ ".link{display:block;word-break:break-all;color:#0f766e;text-decoration:none;background:#ecfeff;" +
+ "padding:10px 12px;border-radius:10px;border:1px solid #bae6fd;}" +
+ ".copy-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:12px;}" +
+ ".copy-btn{border:none;border-radius:10px;padding:10px 14px;background:#0ea5e9;color:#fff;cursor:pointer;" +
+ "font-weight:600;}" +
+ ".copy-btn:hover{background:#0284c7;}" +
+ ".copy-feedback{font-size:13px;color:#475569;}" +
+ ".tip{margin-top:14px;font-size:14px;color:#334155;}"
+}
+
+// daemonCopyScript 返回成功页的链接复制脚本,优先使用 Clipboard API,
+// 在非安全上下文(如 http://neocode)中回退到 execCommand。
+func daemonCopyScript() string {
+ return ``
+}
+
+// hintsWithLead 拼装错误正文与补救建议列表。
+func hintsWithLead(detail string, hints []string) []string {
+ lines := []string{strings.TrimSpace(detail)}
+ for _, hint := range hints {
+ trimmedHint := strings.TrimSpace(hint)
+ if trimmedHint == "" {
+ continue
+ }
+ lines = append(lines, "- "+trimmedHint)
+ }
+ return lines
}
// buildHTTPDaemonReusableURL 基于当前请求地址生成包含 session_id 的可复用链接。
@@ -426,6 +552,58 @@ func probeHTTPDaemonRunning(ctx context.Context, listenAddress string) bool {
return response.StatusCode == http.StatusOK
}
+// ensureHTTPDaemonProcessStarted 尝试在 install 完成后立刻拉起 daemon,并返回可读告警。
+func ensureHTTPDaemonProcessStarted(executablePath string, listenAddress string) error {
+ normalizedListenAddress := normalizeHTTPDaemonListenAddress(listenAddress)
+ probeCtx, cancel := context.WithTimeout(context.Background(), 1400*time.Millisecond)
+ alreadyRunning := probeHTTPDaemonRunning(probeCtx, normalizedListenAddress)
+ cancel()
+ if alreadyRunning {
+ return nil
+ }
+
+ startFn := httpDaemonStartProcessFn
+ if startFn == nil {
+ startFn = startHTTPDaemonProcess
+ }
+ if err := startFn(executablePath, normalizedListenAddress); err != nil {
+ return fmt.Errorf("failed to start daemon in background: %w; run `%s daemon serve --listen %s` manually", err, executablePath, normalizedListenAddress)
+ }
+
+ readyCtx, readyCancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer readyCancel()
+ for {
+ if probeHTTPDaemonRunning(readyCtx, normalizedListenAddress) {
+ return nil
+ }
+ if readyCtx.Err() != nil {
+ return fmt.Errorf("daemon background process started but not ready within 3s; run `%s daemon serve --listen %s` manually", executablePath, normalizedListenAddress)
+ }
+ time.Sleep(120 * time.Millisecond)
+ }
+}
+
+// startHTTPDaemonProcess 以后台进程方式启动 daemon serve。
+func startHTTPDaemonProcess(executablePath string, listenAddress string) error {
+ command := exec.Command(executablePath, "daemon", "serve", "--listen", strings.TrimSpace(listenAddress))
+ command.Stdin = nil
+ command.Stdout = nil
+ command.Stderr = nil
+ if err := command.Start(); err != nil {
+ return err
+ }
+ return command.Process.Release()
+}
+
+// buildHostsAliasWarning 构建 hosts 自动写入失败时的手动修复提示。
+func buildHostsAliasWarning(err error) string {
+ base := fmt.Sprintf("failed to update hosts alias automatically: %v", err)
+ if runtime.GOOS == "windows" {
+ return base + "; please run as Administrator: echo 127.0.0.1 neocode >> C:\\Windows\\System32\\drivers\\etc\\hosts"
+ }
+ return base + "; please run with sudo: sudo echo '127.0.0.1 neocode' >> /etc/hosts"
+}
+
// ensureDaemonHostsAlias 以 best-effort 方式确保 hosts 文件存在 neocode 别名。
func ensureDaemonHostsAlias() error {
hostsPath := daemonHostsFilePath()
diff --git a/internal/gateway/adapters/urlscheme/daemon_test.go b/internal/gateway/adapters/urlscheme/daemon_test.go
index 1817e072..1380b41e 100644
--- a/internal/gateway/adapters/urlscheme/daemon_test.go
+++ b/internal/gateway/adapters/urlscheme/daemon_test.go
@@ -7,6 +7,7 @@ import (
"net"
"net/http"
"net/http/httptest"
+ "runtime"
"strings"
"testing"
@@ -144,11 +145,26 @@ func TestHTTPDaemonHandlerDispatchesIntent(t *testing.T) {
if !strings.Contains(responseBody, "session_id=session-from-runtime") {
t.Fatalf("response body = %q, want contains session_id", responseBody)
}
- if !strings.Contains(responseBody, "reusable_url=") {
+ if !strings.Contains(responseBody, "reusable_url") {
t.Fatalf("response body = %q, want contains reusable_url", responseBody)
}
- if !strings.Contains(responseBody, "tip=") {
- t.Fatalf("response body = %q, want contains tip", responseBody)
+ if !strings.Contains(responseBody, "copy-reusable-btn") {
+ t.Fatalf("response body = %q, want contains copy button", responseBody)
+ }
+ if !strings.Contains(responseBody, "navigator.clipboard.writeText") {
+ t.Fatalf("response body = %q, want contains copy script", responseBody)
+ }
+ if !strings.Contains(responseBody, "name=\"viewport\"") {
+ t.Fatalf("response body = %q, want contains viewport meta", responseBody)
+ }
+ if !strings.Contains(responseBody, "Wake Accepted - NeoCode Daemon") {
+ t.Fatalf("response body = %q, want contains title", responseBody)
+ }
+ if !strings.Contains(responseBody, "rel=\"icon\"") {
+ t.Fatalf("response body = %q, want contains favicon", responseBody)
+ }
+ if !strings.Contains(responseBody, ".copy-btn") {
+ t.Fatalf("response body = %q, want contains css", responseBody)
}
if captured.Intent.Action != protocol.WakeActionRun {
t.Fatalf("captured action = %q, want %q", captured.Intent.Action, protocol.WakeActionRun)
@@ -161,6 +177,53 @@ func TestHTTPDaemonHandlerDispatchesIntent(t *testing.T) {
}
}
+func TestHTTPDaemonHandlerDispatchErrorIsFriendly(t *testing.T) {
+ handler := newHTTPDaemonHandler(
+ func(context.Context, daemonWakeDispatchRequest) (daemonWakeDispatchResult, error) {
+ return daemonWakeDispatchResult{}, newDispatchError(
+ ErrorCodeGatewayUnavailable,
+ "gateway auto-start timed out after 10s",
+ )
+ },
+ "/tmp/gateway.sock",
+ )
+
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "http://neocode:18921/run?prompt=hello", http.NoBody)
+ request.Host = "neocode:18921"
+ handler.ServeHTTP(recorder, request)
+
+ if recorder.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
+ }
+ responseBody := recorder.Body.String()
+ if !strings.Contains(responseBody, "Gateway unavailable") {
+ t.Fatalf("response body = %q, want gateway unavailable title", responseBody)
+ }
+ if !strings.Contains(responseBody, "10 秒内未就绪") {
+ t.Fatalf("response body = %q, want startup timeout hint", responseBody)
+ }
+ if !strings.Contains(responseBody, "neocode daemon status") {
+ t.Fatalf("response body = %q, want daemon status remedy", responseBody)
+ }
+ if !strings.Contains(responseBody, "neocode gateway --listen /tmp/gateway.sock") {
+ t.Fatalf("response body = %q, want gateway remedy command", responseBody)
+ }
+}
+
+func TestBuildHostsAliasWarningIncludesManualCommands(t *testing.T) {
+ warning := buildHostsAliasWarning(errors.New("permission denied"))
+ if runtime.GOOS == "windows" {
+ if !strings.Contains(warning, `echo 127.0.0.1 neocode >> C:\Windows\System32\drivers\etc\hosts`) {
+ t.Fatalf("warning = %q, want windows hosts command", warning)
+ }
+ return
+ }
+ if !strings.Contains(warning, `sudo echo '127.0.0.1 neocode' >> /etc/hosts`) {
+ t.Fatalf("warning = %q, want unix hosts command", warning)
+ }
+}
+
func TestBuildHTTPDaemonReusableURL(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "http://neocode:18921/review?path=README.md&workdir=/repo", http.NoBody)
reusableURL := buildHTTPDaemonReusableURL(request, "session-42")
diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go
index 8a517380..dd98994e 100644
--- a/internal/gateway/adapters/urlscheme/dispatcher.go
+++ b/internal/gateway/adapters/urlscheme/dispatcher.go
@@ -32,7 +32,7 @@ const (
// defaultDispatchIOTimeout 是 URL 派发读写超时时间。
defaultDispatchIOTimeout = 10 * time.Second
// defaultGatewayLaunchTimeout 是自动拉起网关后的就绪等待时间。
- defaultGatewayLaunchTimeout = 3 * time.Second
+ defaultGatewayLaunchTimeout = 10 * time.Second
// defaultGatewayLaunchRetryInterval 是拉起后拨号重试间隔。
defaultGatewayLaunchRetryInterval = 100 * time.Millisecond
wakeReviewStartupPromptTemplate = "请审查文件 %s"
@@ -416,14 +416,14 @@ func (d *Dispatcher) dialGatewayWithFallback(
if launchErr := d.launchGateway(ctx, listenAddress, requestID, authToken); launchErr != nil {
return nil, newDispatchError(
ErrorCodeGatewayUnavailable,
- fmt.Sprintf("dial gateway failed: %v; launch gateway failed: %v", err, launchErr),
+ fmt.Sprintf("dial gateway failed: %v; automatic gateway startup failed: %v", err, launchErr),
)
}
retriedConnection, retryErr := dialFn(listenAddress)
if retryErr != nil {
return nil, newDispatchError(
ErrorCodeGatewayUnavailable,
- fmt.Sprintf("dial gateway failed after single fallback: %v", retryErr),
+ fmt.Sprintf("dial gateway failed after automatic startup: %v", retryErr),
)
}
return retriedConnection, nil
@@ -566,7 +566,7 @@ func (d *Dispatcher) waitGatewayReady(ctx context.Context, listenAddress string)
return nil
}
if !nowFn().Before(deadline) {
- return fmt.Errorf("gateway did not become reachable within %s", effectiveTimeout)
+ return fmt.Errorf("gateway auto-start timed out after %s", effectiveTimeout)
}
sleepFn(defaultGatewayLaunchRetryInterval)
}
diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go
index 6c9c7f71..0e364431 100644
--- a/internal/gateway/adapters/urlscheme/dispatcher_test.go
+++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go
@@ -7,6 +7,7 @@ import (
"net"
"strings"
"testing"
+ "time"
"neo-code/internal/gateway"
"neo-code/internal/gateway/protocol"
@@ -510,3 +511,34 @@ func TestDispatchWakeIntentReturnsGatewayFrameError(t *testing.T) {
t.Fatalf("error code = %q, want %q", dispatchErr.Code, gateway.ErrorCodeInvalidAction.String())
}
}
+
+func TestGatewayLaunchTimeoutIsTenSeconds(t *testing.T) {
+ if defaultGatewayLaunchTimeout != 10*time.Second {
+ t.Fatalf("defaultGatewayLaunchTimeout = %s, want %s", defaultGatewayLaunchTimeout, 10*time.Second)
+ }
+}
+
+func TestWaitGatewayReadyTimeoutMessage(t *testing.T) {
+ dispatcher := NewDispatcher()
+ start := time.Unix(1700000000, 0)
+ nowCalls := 0
+ dispatcher.nowFn = func() time.Time {
+ nowCalls++
+ if nowCalls <= 2 {
+ return start
+ }
+ return start.Add(11 * time.Second)
+ }
+ dispatcher.sleepFn = func(time.Duration) {}
+ dispatcher.dialFn = func(string) (net.Conn, error) {
+ return nil, errors.New("dial failed")
+ }
+
+ err := dispatcher.waitGatewayReady(context.Background(), "inmemory")
+ if err == nil {
+ t.Fatal("expected wait timeout")
+ }
+ if !strings.Contains(err.Error(), "gateway auto-start timed out after") {
+ t.Fatalf("error = %v, want timeout message", err)
+ }
+}
diff --git a/internal/gateway/launcher/launcher_test.go b/internal/gateway/launcher/launcher_test.go
index 6e53d35c..74795587 100644
--- a/internal/gateway/launcher/launcher_test.go
+++ b/internal/gateway/launcher/launcher_test.go
@@ -284,7 +284,7 @@ func TestLaunchTerminalBranches(t *testing.T) {
})
t.Run("unsupported platform returns sentinel", func(t *testing.T) {
- if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
+ if runtime.GOOS == "windows" || runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
t.Skip("unsupported branch only applies to non-windows/non-darwin")
}
err := LaunchTerminal("neocode --session session-1")
diff --git a/internal/gateway/launcher/terminal_linux.go b/internal/gateway/launcher/terminal_linux.go
index 4f1b7feb..71f4a439 100644
--- a/internal/gateway/launcher/terminal_linux.go
+++ b/internal/gateway/launcher/terminal_linux.go
@@ -2,9 +2,36 @@
package launcher
-import "fmt"
+import (
+ "fmt"
+ "os/exec"
+ "runtime"
+)
-// launchTerminal 在 Linux/其他平台先返回未实现错误,后续再接入具体终端适配。
+var (
+ lookupPathForTerminalLinux = exec.LookPath
+ execCommandForTerminalLinux = exec.Command
+)
+
+// launchTerminal 在 Linux 上优先尝试 gnome-terminal,再回退到 x-terminal-emulator。
func launchTerminal(command string) error {
- return fmt.Errorf("%w: run `%s` manually in your terminal", ErrTerminalUnsupported, command)
+ if runtime.GOOS != "linux" {
+ return fmt.Errorf("%w: run `%s` manually in your terminal", ErrTerminalUnsupported, command)
+ }
+ if err := launchWithLinuxTerminal("gnome-terminal", []string{"--", "bash", "-lc", command}); err == nil {
+ return nil
+ }
+ if err := launchWithLinuxTerminal("x-terminal-emulator", []string{"-e", "bash", "-lc", command}); err == nil {
+ return nil
+ }
+ return fmt.Errorf("%w: install gnome-terminal/x-terminal-emulator or run `%s` manually", ErrTerminalUnsupported, command)
+}
+
+// launchWithLinuxTerminal 通过指定终端模拟器拉起命令。
+func launchWithLinuxTerminal(binary string, args []string) error {
+ if _, err := lookupPathForTerminalLinux(binary); err != nil {
+ return err
+ }
+ cmd := execCommandForTerminalLinux(binary, args...)
+ return cmd.Run()
}
diff --git a/internal/gateway/launcher/terminal_linux_test.go b/internal/gateway/launcher/terminal_linux_test.go
new file mode 100644
index 00000000..f05cd3a4
--- /dev/null
+++ b/internal/gateway/launcher/terminal_linux_test.go
@@ -0,0 +1,96 @@
+//go:build !windows && !darwin
+
+package launcher
+
+import (
+ "errors"
+ "os/exec"
+ "runtime"
+ "testing"
+)
+
+func TestLaunchTerminalLinuxUsesGnomeFirst(t *testing.T) {
+ if runtime.GOOS != "linux" {
+ t.Skip("linux-specific behavior")
+ }
+ originalLookup := lookupPathForTerminalLinux
+ originalExec := execCommandForTerminalLinux
+ t.Cleanup(func() {
+ lookupPathForTerminalLinux = originalLookup
+ execCommandForTerminalLinux = originalExec
+ })
+
+ lookupPathForTerminalLinux = func(binary string) (string, error) {
+ if binary == "gnome-terminal" {
+ return "/usr/bin/gnome-terminal", nil
+ }
+ return "", errors.New("not found")
+ }
+ called := make([]string, 0, 1)
+ execCommandForTerminalLinux = func(name string, args ...string) *exec.Cmd {
+ called = append(called, name)
+ return exec.Command("sh", "-c", "exit 0")
+ }
+
+ if err := launchTerminal("neocode --session s-1"); err != nil {
+ t.Fatalf("launchTerminal() error = %v", err)
+ }
+ if len(called) != 1 || called[0] != "gnome-terminal" {
+ t.Fatalf("called = %#v, want [gnome-terminal]", called)
+ }
+}
+
+func TestLaunchTerminalLinuxFallsBackToXTerminalEmulator(t *testing.T) {
+ if runtime.GOOS != "linux" {
+ t.Skip("linux-specific behavior")
+ }
+ originalLookup := lookupPathForTerminalLinux
+ originalExec := execCommandForTerminalLinux
+ t.Cleanup(func() {
+ lookupPathForTerminalLinux = originalLookup
+ execCommandForTerminalLinux = originalExec
+ })
+
+ lookupPathForTerminalLinux = func(binary string) (string, error) {
+ if binary == "x-terminal-emulator" {
+ return "/usr/bin/x-terminal-emulator", nil
+ }
+ return "", errors.New("not found")
+ }
+ called := make([]string, 0, 2)
+ execCommandForTerminalLinux = func(name string, args ...string) *exec.Cmd {
+ called = append(called, name)
+ return exec.Command("sh", "-c", "exit 0")
+ }
+
+ if err := launchTerminal("neocode --session s-2"); err != nil {
+ t.Fatalf("launchTerminal() error = %v", err)
+ }
+ if len(called) != 1 || called[0] != "x-terminal-emulator" {
+ t.Fatalf("called = %#v, want [x-terminal-emulator]", called)
+ }
+}
+
+func TestLaunchTerminalLinuxUnsupportedWhenNoTerminal(t *testing.T) {
+ if runtime.GOOS != "linux" {
+ t.Skip("linux-specific behavior")
+ }
+ originalLookup := lookupPathForTerminalLinux
+ originalExec := execCommandForTerminalLinux
+ t.Cleanup(func() {
+ lookupPathForTerminalLinux = originalLookup
+ execCommandForTerminalLinux = originalExec
+ })
+
+ lookupPathForTerminalLinux = func(binary string) (string, error) {
+ return "", errors.New("not found")
+ }
+ execCommandForTerminalLinux = func(name string, args ...string) *exec.Cmd {
+ return exec.Command("sh", "-c", "exit 0")
+ }
+
+ err := launchTerminal("neocode --session s-3")
+ if !errors.Is(err, ErrTerminalUnsupported) {
+ t.Fatalf("error = %v, want wrapped %v", err, ErrTerminalUnsupported)
+ }
+}
diff --git a/internal/gateway/launcher/terminal_windows.go b/internal/gateway/launcher/terminal_windows.go
index a90b25ab..bab04d81 100644
--- a/internal/gateway/launcher/terminal_windows.go
+++ b/internal/gateway/launcher/terminal_windows.go
@@ -5,16 +5,22 @@ package launcher
import (
"fmt"
"os/exec"
- "strings"
)
-// launchTerminal 在 Windows 上通过 `cmd /c start` 拉起独立终端窗口执行命令。
+var (
+ lookupPathForTerminalWindows = exec.LookPath
+ execCommandForTerminalWindows = exec.Command
+)
+
+// launchTerminal 在 Windows 上优先使用 Windows Terminal,失败时回退 cmd /c start。
func launchTerminal(command string) error {
- args := append([]string{"/c", "start"}, strings.Fields(command)...)
- if len(args) <= 2 {
- return fmt.Errorf("empty terminal command")
+ if _, err := lookupPathForTerminalWindows("wt.exe"); err == nil {
+ wtCommand := execCommandForTerminalWindows("wt.exe", "new-tab", "cmd", "/k", command)
+ if runErr := wtCommand.Run(); runErr == nil {
+ return nil
+ }
}
- cmd := exec.Command("cmd", args...)
+ cmd := execCommandForTerminalWindows("cmd", "/c", "start", "", "cmd", "/k", command)
if err := cmd.Run(); err != nil {
return fmt.Errorf("launch terminal on windows: %w", err)
}
diff --git a/internal/gateway/launcher/terminal_windows_test.go b/internal/gateway/launcher/terminal_windows_test.go
new file mode 100644
index 00000000..62ab4874
--- /dev/null
+++ b/internal/gateway/launcher/terminal_windows_test.go
@@ -0,0 +1,108 @@
+//go:build windows
+
+package launcher
+
+import (
+ "errors"
+ "os/exec"
+ "reflect"
+ "testing"
+)
+
+func TestLaunchTerminalWindowsPrefersWT(t *testing.T) {
+ originalLookup := lookupPathForTerminalWindows
+ originalExec := execCommandForTerminalWindows
+ t.Cleanup(func() {
+ lookupPathForTerminalWindows = originalLookup
+ execCommandForTerminalWindows = originalExec
+ })
+
+ lookupPathForTerminalWindows = func(file string) (string, error) {
+ if file == "wt.exe" {
+ return `C:\\Windows\\System32\\wt.exe`, nil
+ }
+ return "", errors.New("unexpected binary")
+ }
+
+ called := make([][]string, 0, 2)
+ execCommandForTerminalWindows = func(name string, args ...string) *exec.Cmd {
+ called = append(called, append([]string{name}, args...))
+ return exec.Command("cmd", "/c", "exit", "0")
+ }
+
+ if err := launchTerminal("neocode --session s-1"); err != nil {
+ t.Fatalf("launchTerminal() error = %v", err)
+ }
+ if len(called) != 1 {
+ t.Fatalf("call count = %d, want 1", len(called))
+ }
+ want := []string{"wt.exe", "new-tab", "cmd", "/k", "neocode --session s-1"}
+ if !reflect.DeepEqual(called[0], want) {
+ t.Fatalf("called[0] = %#v, want %#v", called[0], want)
+ }
+}
+
+func TestLaunchTerminalWindowsFallsBackToCmdStart(t *testing.T) {
+ originalLookup := lookupPathForTerminalWindows
+ originalExec := execCommandForTerminalWindows
+ t.Cleanup(func() {
+ lookupPathForTerminalWindows = originalLookup
+ execCommandForTerminalWindows = originalExec
+ })
+
+ lookupPathForTerminalWindows = func(file string) (string, error) {
+ return "", errors.New("not found")
+ }
+
+ called := make([][]string, 0, 2)
+ execCommandForTerminalWindows = func(name string, args ...string) *exec.Cmd {
+ called = append(called, append([]string{name}, args...))
+ return exec.Command("cmd", "/c", "exit", "0")
+ }
+
+ if err := launchTerminal("neocode --session s-2"); err != nil {
+ t.Fatalf("launchTerminal() error = %v", err)
+ }
+ if len(called) != 1 {
+ t.Fatalf("call count = %d, want 1", len(called))
+ }
+ want := []string{"cmd", "/c", "start", "", "cmd", "/k", "neocode --session s-2"}
+ if !reflect.DeepEqual(called[0], want) {
+ t.Fatalf("called[0] = %#v, want %#v", called[0], want)
+ }
+}
+
+func TestLaunchTerminalWindowsFallsBackWhenWTFails(t *testing.T) {
+ originalLookup := lookupPathForTerminalWindows
+ originalExec := execCommandForTerminalWindows
+ t.Cleanup(func() {
+ lookupPathForTerminalWindows = originalLookup
+ execCommandForTerminalWindows = originalExec
+ })
+
+ lookupPathForTerminalWindows = func(file string) (string, error) {
+ if file == "wt.exe" {
+ return `C:\\Windows\\System32\\wt.exe`, nil
+ }
+ return "", errors.New("not found")
+ }
+
+ called := make([][]string, 0, 3)
+ execCommandForTerminalWindows = func(name string, args ...string) *exec.Cmd {
+ called = append(called, append([]string{name}, args...))
+ if name == "wt.exe" {
+ return exec.Command("cmd", "/c", "exit", "1")
+ }
+ return exec.Command("cmd", "/c", "exit", "0")
+ }
+
+ if err := launchTerminal("neocode --session s-3"); err != nil {
+ t.Fatalf("launchTerminal() error = %v", err)
+ }
+ if len(called) != 2 {
+ t.Fatalf("call count = %d, want 2", len(called))
+ }
+ if called[0][0] != "wt.exe" || called[1][0] != "cmd" {
+ t.Fatalf("call order = %#v, want wt.exe then cmd", called)
+ }
+}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index d67e5a95..e0d690b9 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -22,6 +22,13 @@ switch ($Flavor) {
}
}
+function Write-FullInstallRemedy {
+ Write-Warning "Remedy commands:"
+ Write-Warning " `"$env:LOCALAPPDATA\\NeoCode\\neocode.exe`" daemon install"
+ Write-Warning " echo 127.0.0.1 neocode >> C:\\Windows\\System32\\drivers\\etc\\hosts"
+ Write-Warning " `"$env:LOCALAPPDATA\\NeoCode\\neocode.exe`" daemon status"
+}
+
# 1. 识别物理架构(优先考虑 64 位重定向环境)
$RawArch = $env:PROCESSOR_ARCHITEW6432
if ([string]::IsNullOrWhiteSpace($RawArch)) {
@@ -122,11 +129,19 @@ try {
}
catch {
Write-Warning "Failed to install HTTP daemon autostart automatically."
- Write-Warning "Run '$NeoCodeExecutablePath daemon install' manually after installation."
+ Write-FullInstallRemedy
}
}
Write-Host "Installed $BinaryName ($Flavor) from $LatestTag." -ForegroundColor Green
}
+catch {
+ Write-Error "Installation failed: $($_.Exception.Message)"
+ Write-Warning "Retry command: powershell -ExecutionPolicy Bypass -File scripts\\install.ps1 -Flavor $Flavor"
+ if ($Flavor -eq "full") {
+ Write-FullInstallRemedy
+ }
+ throw
+}
finally {
if (Test-Path $TempDir) {
Remove-Item -Path $TempDir -Recurse -Force
diff --git a/scripts/install.sh b/scripts/install.sh
index 26e5093d..c6a8c1d9 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -7,6 +7,15 @@ DEFAULT_FLAVOR="full"
flavor="$DEFAULT_FLAVOR"
dry_run=0
+print_full_install_remedy() {
+ cat >&2 <<'REMEDY'
+Remedy commands:
+ /usr/local/bin/neocode daemon install
+ sudo echo '127.0.0.1 neocode' >> /etc/hosts
+ /usr/local/bin/neocode daemon status
+REMEDY
+}
+
usage() {
cat <<'USAGE'
Usage: install.sh [--flavor full|gateway] [--dry-run]
@@ -59,6 +68,8 @@ case "$flavor" in
;;
esac
+trap 'status=$?; if [[ $status -ne 0 ]]; then echo "Installation failed (exit ${status})." >&2; echo "Retry: bash scripts/install.sh --flavor ${flavor}" >&2; if [[ "${flavor}" == "full" ]]; then print_full_install_remedy; fi; fi' ERR
+
os="$(uname -s)"
arch="$(uname -m)"
@@ -152,6 +163,6 @@ if [[ "${flavor}" == "full" ]]; then
echo "Installing HTTP daemon autostart..."
if ! /usr/local/bin/neocode daemon install >/dev/null 2>&1; then
echo "Warning: failed to install HTTP daemon autostart automatically." >&2
- echo "Run '/usr/local/bin/neocode daemon install' manually after installation." >&2
+ print_full_install_remedy
fi
-fi
\ No newline at end of file
+fi