magic-agent 2.4.3 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CLIENT-CONTRACT.md +284 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +6 -0
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +3 -3
  105. package/internal/client/quotas_merge_test.go +107 -0
  106. package/internal/client/requirements.go +198 -1
  107. package/internal/client/requirements_shots_test.go +200 -0
  108. package/internal/client/restart.go +134 -0
  109. package/internal/client/restart_test.go +151 -0
  110. package/internal/client/scripts.go +1047 -0
  111. package/internal/client/scripts_decode_other.go +27 -0
  112. package/internal/client/scripts_decode_test.go +125 -0
  113. package/internal/client/scripts_decode_windows.go +206 -0
  114. package/internal/client/scripts_test.go +726 -0
  115. package/internal/client/service.go +322 -18
  116. package/internal/client/service_test.go +51 -0
  117. package/internal/client/suggest.go +105 -2
  118. package/internal/client/suggest_test.go +86 -0
  119. package/internal/client/team_flags.go +25 -11
  120. package/internal/client/team_flags_test.go +39 -0
  121. package/internal/requirements/bootstrap.go +16 -0
  122. package/internal/requirements/consolidate.go +100 -9
  123. package/internal/requirements/doc.go +154 -3
  124. package/internal/requirements/graph.go +138 -0
  125. package/internal/requirements/memory.go +7 -0
  126. package/internal/requirements/modules.go +312 -0
  127. package/internal/requirements/modules_test.go +373 -0
  128. package/internal/requirements/relations.go +155 -0
  129. package/internal/requirements/relations_test.go +231 -0
  130. package/internal/requirements/requirements.go +10 -0
  131. package/internal/requirements/shots.go +418 -0
  132. package/internal/requirements/shots_test.go +387 -0
  133. package/internal/session/session.go +92 -0
  134. package/internal/suggest/suggest.go +23 -1
  135. package/internal/team/event.go +3 -1
  136. package/internal/team/execute.go +21 -6
  137. package/internal/team/fallback_route_test.go +80 -0
  138. package/internal/team/planner.go +15 -3
  139. package/internal/team/route.go +89 -17
  140. package/internal/team/team.go +18 -4
  141. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  142. package/npm/apk/version.json +1 -5
  143. package/npm/dist/darwin-arm64/magic-agent +0 -0
  144. package/npm/dist/darwin-arm64/magic-client +0 -0
  145. package/npm/dist/darwin-x64/magic-agent +0 -0
  146. package/npm/dist/darwin-x64/magic-client +0 -0
  147. package/npm/dist/linux-arm64/magic-agent +0 -0
  148. package/npm/dist/linux-arm64/magic-client +0 -0
  149. package/npm/dist/linux-x64/magic-agent +0 -0
  150. package/npm/dist/linux-x64/magic-client +0 -0
  151. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  152. package/npm/dist/win32-x64/magic-client.exe +0 -0
  153. package/package.json +8 -2
@@ -0,0 +1,216 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "log"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "net/url"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/gorilla/websocket"
15
+ )
16
+
17
+ // TestRelayJSServed /b/relay.js 是我们自己的资源,必须原样可取(不能当主机名去抓)。
18
+ func TestRelayJSServed(t *testing.T) {
19
+ s := &Service{}
20
+ proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowse))
21
+ defer proxy.Close()
22
+
23
+ resp, err := http.Get(proxy.URL + "/b/relay.js")
24
+ if err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ defer resp.Body.Close()
28
+ if resp.StatusCode != http.StatusOK {
29
+ t.Fatalf("relay.js 应 200,实际 %d", resp.StatusCode)
30
+ }
31
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/javascript") {
32
+ t.Errorf("relay.js Content-Type 应为 text/javascript,实际 %q", ct)
33
+ }
34
+ body, _ := io.ReadAll(resp.Body)
35
+ if !strings.Contains(string(body), "browse-input") {
36
+ t.Error("relay.js 应包含 /browse-input 连接逻辑")
37
+ }
38
+ }
39
+
40
+ // TestRelayInjected 改写后的 HTML 应注入 relay 脚本(与 <base> 同位置)。
41
+ func TestRelayInjected(t *testing.T) {
42
+ page := mustURL(t, "https://example.com/a")
43
+ out := string(rewriteBrowseHTML([]byte(`<html><head><title>x</title></head><body></body></html>`),
44
+ page, testProxyBase, ""))
45
+ if !strings.Contains(out, `<script src="http://p/b/relay.js" defer></script>`) {
46
+ t.Errorf("应注入 relay 脚本,实际:%s", out)
47
+ }
48
+ if !strings.Contains(out, `<base href="http://p/b/https/example.com/a">`) {
49
+ t.Errorf("<base> 注入不应被破坏,实际:%s", out)
50
+ }
51
+ }
52
+
53
+ func dialWS(t *testing.T, urlStr string) *websocket.Conn {
54
+ t.Helper()
55
+ u, err := url.Parse(urlStr)
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ u.Scheme = "ws"
60
+ c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
61
+ if err != nil {
62
+ t.Fatalf("连接 %s 失败:%v", u, err)
63
+ }
64
+ t.Cleanup(func() { _ = c.Close() })
65
+ return c
66
+ }
67
+
68
+ func readMsg(t *testing.T, c *websocket.Conn) browseMsg {
69
+ t.Helper()
70
+ _ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
71
+ var m browseMsg
72
+ if err := c.ReadJSON(&m); err != nil {
73
+ t.Fatalf("读帧失败:%v", err)
74
+ }
75
+ return m
76
+ }
77
+
78
+ // TestBrowseInputAuth 有令牌的服务:握手不带令牌必须被拒。
79
+ func TestBrowseInputAuth(t *testing.T) {
80
+ s := &Service{opts: Options{Token: "sekret"}}
81
+ proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowseInput))
82
+ defer proxy.Close()
83
+
84
+ u, _ := url.Parse(proxy.URL)
85
+ u.Scheme = "ws"
86
+ _, resp, err := websocket.DefaultDialer.Dial(u.String(), nil)
87
+ if err == nil {
88
+ t.Fatal("缺令牌的握手应失败")
89
+ }
90
+ if resp == nil || resp.StatusCode != http.StatusUnauthorized {
91
+ t.Errorf("缺令牌应 401,实际:%v", err)
92
+ }
93
+ }
94
+
95
+ // TestBrowseInputDispatch 端到端:driver 发 input → viewer 收到(seq 由 server 重编),
96
+ // viewer 回 ack → driver 收到。也验证无 viewer 时 ack 失败、未知动作被拦。
97
+ func TestBrowseInputDispatch(t *testing.T) {
98
+ // log 必须给:viewer 循环会打连接日志,nil logger 会 panic(被 net/http recover
99
+ // 吃掉后 defer 又把 viewer 清掉,表象是「viewer 永远离线」)。
100
+ s := &Service{log: log.New(io.Discard, "", 0)}
101
+ proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowseInput))
102
+ defer proxy.Close()
103
+
104
+ viewer := dialWS(t, proxy.URL+"/browse-input?role=viewer")
105
+ time.Sleep(100 * time.Millisecond) // 等服务端把 viewer 登记进 hub(dial 返回与 setViewer 有竞态)
106
+ driver := dialWS(t, proxy.URL+"/browse-input?role=driver")
107
+
108
+ // 新 driver 上线会先收到 viewer 状态帧(此时 viewer 已连 → online)。
109
+ if m := readMsg(t, driver); m.Type != "viewer" || !m.Online {
110
+ t.Fatalf("driver 首帧应是 viewer 上线通知,实际:%+v", m)
111
+ }
112
+
113
+ // 未知动作:直接 ack 失败,不往页面派。
114
+ _ = driver.WriteJSON(browseMsg{Type: "input", Action: "hack"})
115
+ if m := readMsg(t, driver); m.Type != "ack" || m.OK == nil || *m.OK {
116
+ t.Fatalf("未知动作应 ack 失败,实际:%+v", m)
117
+ }
118
+
119
+ // 正常 click:viewer 收到重编过 seq 的 input,回 ack,driver 收到。
120
+ _ = driver.WriteJSON(browseMsg{Type: "input", Action: "click", X: 10, Y: 20})
121
+ in := readMsg(t, viewer)
122
+ if in.Type != "input" || in.Action != "click" || in.X != 10 || in.Y != 20 || in.Seq == 0 {
123
+ t.Fatalf("viewer 应收到 input 帧,实际:%+v", in)
124
+ }
125
+ _ = viewer.WriteJSON(browseMsg{Type: "ack", Seq: in.Seq, OK: boolPtr(true)})
126
+ if m := readMsg(t, driver); m.Type != "ack" || m.OK == nil || !*m.OK || m.Seq != in.Seq {
127
+ t.Fatalf("driver 应收到 ack,实际:%+v", m)
128
+ }
129
+
130
+ // viewer 上报 state → driver 收到。
131
+ _ = viewer.WriteJSON(browseMsg{Type: "state", Title: "T", W: 390, H: 844})
132
+ if m := readMsg(t, driver); m.Type != "state" || m.Title != "T" || m.W != 390 {
133
+ t.Fatalf("driver 应收到 state,实际:%+v", m)
134
+ }
135
+
136
+ // viewer 掉线后再派发:ack 失败。
137
+ _ = viewer.Close()
138
+ time.Sleep(100 * time.Millisecond) // 给服务端读循环时间清理 viewer
139
+ // driver 会先收到 viewer 下线通知帧。
140
+ if m := readMsg(t, driver); m.Type != "viewer" || m.Online {
141
+ t.Fatalf("应先收到 viewer 下线通知,实际:%+v", m)
142
+ }
143
+ _ = driver.WriteJSON(browseMsg{Type: "input", Action: "click"})
144
+ if m := readMsg(t, driver); m.Type != "ack" || m.OK == nil || *m.OK {
145
+ t.Fatalf("viewer 离线后派发应 ack 失败,实际:%+v", m)
146
+ }
147
+ }
148
+
149
+ // TestBrowseRelayJSON 形状自检:ack/OK 等字段按 omitempty 序列化,不能互相污染。
150
+ func TestBrowseRelayJSON(t *testing.T) {
151
+ b, err := json.Marshal(browseMsg{Type: "input", Seq: 3, Action: "click", X: 1.5, Y: 2})
152
+ if err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ var m map[string]any
156
+ if err := json.Unmarshal(b, &m); err != nil {
157
+ t.Fatal(err)
158
+ }
159
+ if m["type"] != "input" || m["action"] != "click" {
160
+ t.Errorf("序列化形状不对:%s", b)
161
+ }
162
+ if _, ok := m["ok"]; ok {
163
+ t.Errorf("ack 专用的 ok 字段不应出现在 input 帧里:%s", b)
164
+ }
165
+ if b2, _ := json.Marshal(browseMsg{Type: "ack", OK: boolPtr(false), Reason: "x"}); !strings.Contains(string(b2), `"ok":false`) {
166
+ t.Errorf("ack 的 ok=false 不能被 omitempty 吞掉:%s", b2)
167
+ }
168
+ }
169
+
170
+ // TestRelayJSHasNoShotBranch relay 不再承担截图(2026-10-25 改基 agent-browser):
171
+ // 页面内栅格化的代码必须删干净,不然页面里还会去加载已经不存在的 /b/shotlib.js。
172
+ func TestRelayJSHasNoShotBranch(t *testing.T) {
173
+ for _, banned := range []string{
174
+ "doShot", "/b/shotlib.js", "/desk/upload", "html2canvas",
175
+ } {
176
+ if strings.Contains(browseRelayJS, banned) {
177
+ t.Errorf("relay 脚本不应再含 %q —— 页面内截图已退役", banned)
178
+ }
179
+ }
180
+ }
181
+
182
+ // TestShotLibGone /b/shotlib.js 已随页面内截图退役,必须 404(不能吐空文件假装成功)。
183
+ func TestShotLibGone(t *testing.T) {
184
+ s := &Service{}
185
+ proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowse))
186
+ defer proxy.Close()
187
+
188
+ resp, err := http.Get(proxy.URL + "/b/shotlib.js")
189
+ if err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ defer resp.Body.Close()
193
+ // 不走中转特判后,"shotlib.js" 会被当主机名去抓 —— 无论哪种结果都不该 200。
194
+ if resp.StatusCode == http.StatusOK {
195
+ body, _ := io.ReadAll(resp.Body)
196
+ t.Errorf("shotlib.js 应已下线,实际 200(内容前 80 字节: %.80q)", body)
197
+ }
198
+ }
199
+
200
+ // TestShotActionRejected shot 动作已从白名单移除(截图走 agent-browser):
201
+ // driver 发 shot 必须立刻 ack 失败「未知动作」,而不是悄悄派给页面。
202
+ func TestShotActionRejected(t *testing.T) {
203
+ s := &Service{log: log.New(io.Discard, "", 0)}
204
+ proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowseInput))
205
+ defer proxy.Close()
206
+
207
+ driver := dialWS(t, proxy.URL+"/browse-input?role=driver")
208
+ if m := readMsg(t, driver); m.Type != "viewer" || m.Online {
209
+ t.Fatalf("首帧应是 viewer 状态(离线),实际:%+v", m)
210
+ }
211
+ _ = driver.WriteJSON(browseMsg{Type: "input", Action: "shot"})
212
+ m := readMsg(t, driver)
213
+ if m.Type != "ack" || m.OK == nil || *m.OK {
214
+ t.Fatalf("shot 应 ack 失败(动作已退役),实际:%+v", m)
215
+ }
216
+ }
@@ -0,0 +1,23 @@
1
+ package client
2
+
3
+ import (
4
+ "context"
5
+ "os/exec"
6
+ )
7
+
8
+ // newCoreCmd 起一条 core 子进程(插件侧唯一入口)。
9
+ //
10
+ // 为什么统一走这里(2026-10-24 用户:「返回会话列表很忙,需求很快」):
11
+ // `magic-client start` 把服务以 **DETACHED_PROCESS** 放到后台(见 detach_windows.go),
12
+ // 那个进程**没有控制台**。此后它每 spawn 一次 core(`--sessions` / `--contract` /
13
+ // `--requirements` / 归纳标题 / 真开跑),Windows 都要为这个控制台程序新建一个
14
+ // conhost —— 实测每次多花 **3.1 秒**(前台 serve 260ms,后台 3.4s,同一台机器、
15
+ // 同一个 core 二进制)。手机上就是「进出会话列表很忙」、发任务起步慢。
16
+ //
17
+ // 加 CREATE_NO_WINDOW 之后子进程不再分配控制台:stdin/stdout 管道照常工作,
18
+ // 只是没有那个看不见的窗口。
19
+ func newCoreCmd(ctx context.Context, exe string, args ...string) *exec.Cmd {
20
+ cmd := exec.CommandContext(ctx, exe, args...)
21
+ noConsole(cmd)
22
+ return cmd
23
+ }
@@ -0,0 +1,8 @@
1
+ //go:build !windows
2
+
3
+ package client
4
+
5
+ import "os/exec"
6
+
7
+ // noConsole 非 Windows 平台无需处理(没有「控制台分配」这回事)。
8
+ func noConsole(cmd *exec.Cmd) {}
@@ -0,0 +1,24 @@
1
+ //go:build windows
2
+
3
+ package client
4
+
5
+ import (
6
+ "os/exec"
7
+ "syscall"
8
+ )
9
+
10
+ // createNoWindow 不分配控制台窗口(CreationFlags)。
11
+ // 后台服务(DETACHED_PROCESS)本身没有控制台,子进程每起一次都要新建 conhost —— 实测 3.1s。
12
+ const createNoWindow = 0x08000000
13
+
14
+ // noConsole 给 core 子进程关掉控制台分配(保留调用方可能设过的其它标志)。
15
+ func noConsole(cmd *exec.Cmd) {
16
+ if cmd == nil {
17
+ return
18
+ }
19
+ if cmd.SysProcAttr == nil {
20
+ cmd.SysProcAttr = &syscall.SysProcAttr{}
21
+ }
22
+ cmd.SysProcAttr.CreationFlags |= createNoWindow
23
+ cmd.SysProcAttr.HideWindow = true
24
+ }
@@ -3,11 +3,12 @@ package client
3
3
  /* 后台常驻(`magic-client start|stop|status`)的落地层。
4
4
  *
5
5
  * ── 为什么需要这一层 ──
6
- * 手机的连接入口是「一个还在跑的 serve 进程 + 它的端口 + 令牌」。手工 `serve --lan`
7
- * 是**前台**进程:关掉终端就没了;而端口是 0(系统分配),下次启动又是另一个端口 ——
8
- * 用户既记不住,也没法把它抄进手机。所以这里把
6
+ * 手机的连接入口是「一个还在跑的 serve 进程 + 它的端口 + 令牌」。手工 `serve`
7
+ * 是**前台**进程:关掉终端就没了;所以这里把
9
8
  * 「pid + 端口 + 令牌 + 局域网地址」落成一份 0600 的状态文件,
10
9
  * start / status / stop 三个动作围绕同一份事实工作,谁也不再猜。
10
+ * 端口本身也稳了:默认(局域网)未显式给 --addr 时固定绑 DefaultLanAddr(9100)——
11
+ * 手机里存的是「地址 + 令牌」,端口一变配对就作废。
11
12
  *
12
13
  * ── 为什么由 serve 自己写 ready 文件,而不是父进程猜端口 ──
13
14
  * 端口在 bind 之后才知道(`net.Listen("tcp","0.0.0.0:0")`),父进程只能事后读日志正则 ——
@@ -41,6 +42,13 @@ const (
41
42
  EnvLog = "MAGIC_CLIENT_LOG"
42
43
  )
43
44
 
45
+ // DefaultLanAddr 默认(局域网)且未显式给 --addr 时的监听地址:固定 9100。
46
+ //
47
+ // 为什么不用 :0 随机:手机里存的是「地址 + 令牌」,端口一变配对就作废,
48
+ // 用户侧现象是「每次重启都得重新配对」。固定端口的代价是可能被占用 ——
49
+ // 那就让 serve 明确报错(--addr 换一个),绝不悄悄换回随机端口骗人。
50
+ const DefaultLanAddr = "0.0.0.0:9100"
51
+
44
52
  const (
45
53
  // defaultReadyWait 等 serve 报端口的时长。第一次启动要跑 `--contract` 探测引擎,
46
54
  // 慢机器上 3~5s 很常见,给到 15s 留余量。
@@ -164,6 +172,12 @@ type DaemonState struct {
164
172
  LogPath string `json:"logPath"`
165
173
  StatePath string `json:"statePath"`
166
174
  Exe string `json:"exe"`
175
+ /* 以下三个是 `restart` 重建启动参数用的(2026-10-24,移动端「我的 → 重启服务」)。
176
+ 只增字段:老状态文件没有它们 → 空串,重启时各走自己的默认(地址按 Lan 推、UA 内置、
177
+ core 从 PATH 找),行为与旧版 start 一致。 */
178
+ Addr string `json:"addr,omitempty"`
179
+ BrowseUA string `json:"browseUA,omitempty"`
180
+ MagicAgent string `json:"magicAgent,omitempty"`
167
181
  }
168
182
 
169
183
  // Base 本机根地址(不带令牌)。
@@ -382,6 +396,9 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
382
396
  appendIf("--magic-agent", o.MagicAgent)
383
397
  if o.Lan {
384
398
  args = append(args, "--lan")
399
+ } else {
400
+ // serve 的默认已改为局域网;显式传 --local 才能关掉。
401
+ args = append(args, "--local")
385
402
  }
386
403
 
387
404
  cmd := exec.Command(exe, args...)
@@ -422,20 +439,23 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
422
439
  _ = os.Remove(readyPath)
423
440
 
424
441
  st := &DaemonState{
425
- PID: info.PID,
426
- Port: info.Port,
427
- URL: info.URL,
428
- LanAddrs: info.LanAddrs,
429
- Lan: info.Lan,
430
- Profile: o.Profile,
431
- UIRoot: o.UIRoot,
432
- MobileDir: o.MobileDir,
433
- ProxyAddr: info.ProxyAddr,
434
- Token: tok,
435
- StartedAt: time.Now(),
436
- LogPath: logPath,
437
- StatePath: statePath,
438
- Exe: exe,
442
+ PID: info.PID,
443
+ Port: info.Port,
444
+ URL: info.URL,
445
+ LanAddrs: info.LanAddrs,
446
+ Lan: info.Lan,
447
+ Profile: o.Profile,
448
+ UIRoot: o.UIRoot,
449
+ MobileDir: o.MobileDir,
450
+ ProxyAddr: info.ProxyAddr,
451
+ Token: tok,
452
+ StartedAt: time.Now(),
453
+ LogPath: logPath,
454
+ StatePath: statePath,
455
+ Exe: exe,
456
+ Addr: o.Addr,
457
+ BrowseUA: o.BrowseUA,
458
+ MagicAgent: o.MagicAgent,
439
459
  }
440
460
  if st.PID == 0 {
441
461
  st.PID = cmd.Process.Pid
@@ -486,6 +506,59 @@ func StopDaemon(o StopOptions) (bool, *DaemonState, error) {
486
506
  return true, st, nil
487
507
  }
488
508
 
509
+ // RestartOptions `restart` 的参数。
510
+ type RestartOptions struct {
511
+ StatePath string
512
+ // Delay 停旧实例之前等多久。服务端「自重启」用它先把 HTTP 响应写回手机
513
+ // (响应一发完,本进程就会在 Delay 之后被 TerminatePID 带走)。
514
+ Delay time.Duration
515
+ }
516
+
517
+ // RestartDaemon 按**当前实例自己的启动参数**重启:读状态文件 → 停旧实例 → 拉起新实例。
518
+ //
519
+ // 为什么参数要从状态文件重建(2026-10-24,移动端「我的」里加了「重启服务」):
520
+ // 发起重启的是手机,它只知道「连着的那个实例」,不知道这台机器当初是怎么起来的
521
+ // (端口、令牌、界面目录、profile、SOCKS5…)。状态文件里都记着,照着起即可 ——
522
+ // 令牌沿用,手机里存的「地址 + 令牌」不会因为重启作废。
523
+ //
524
+ // 只能用于后台实例(`magic-client start`):前台 `serve` 不写状态文件,无从得知参数。
525
+ func RestartDaemon(o RestartOptions) (*DaemonState, error) {
526
+ statePath := strings.TrimSpace(o.StatePath)
527
+ if statePath == "" {
528
+ statePath = DefaultStatePath()
529
+ }
530
+ prev, err := ReadDaemonState(statePath)
531
+ if err != nil {
532
+ return nil, err
533
+ }
534
+ if prev == nil {
535
+ return nil, fmt.Errorf("没有后台实例的状态文件(%s):重启只支持 `magic-client start` 起的实例", statePath)
536
+ }
537
+ if o.Delay > 0 {
538
+ time.Sleep(o.Delay)
539
+ }
540
+ if _, _, err := StopDaemon(StopOptions{StatePath: statePath}); err != nil {
541
+ return nil, err
542
+ }
543
+ /* 旧进程退出到端口真正释放之间还有一小段窗口(Windows 上关句柄不是瞬时),
544
+ 不等这一下,新实例会立刻撞「地址已在使用」—— 也就是手机上那句「重启后连不上」。 */
545
+ time.Sleep(300 * time.Millisecond)
546
+ return StartDaemon(StartOptions{
547
+ Exe: prev.Exe,
548
+ MagicAgent: prev.MagicAgent,
549
+ Profile: prev.Profile,
550
+ UIRoot: prev.UIRoot,
551
+ MobileDir: prev.MobileDir,
552
+ Addr: prev.Addr,
553
+ Token: prev.Token,
554
+ ProxyAddr: prev.ProxyAddr,
555
+ BrowseUA: prev.BrowseUA,
556
+ Lan: prev.Lan,
557
+ StatePath: statePath,
558
+ LogPath: prev.LogPath,
559
+ })
560
+ }
561
+
489
562
  // DaemonStatus 一份对外可读的运行状态(给 `status` 与排障用)。
490
563
  type DaemonStatus struct {
491
564
  Running bool `json:"running"`
@@ -9,6 +9,8 @@ package client
9
9
  // 3) status 的判据是「进程在 **且** 健康检查答得上」,不是只看 pid。
10
10
 
11
11
  import (
12
+ "io"
13
+ "log"
12
14
  "net/http"
13
15
  "net/http/httptest"
14
16
  "os"
@@ -198,6 +200,37 @@ func TestStopClearsStaleState(t *testing.T) {
198
200
  }
199
201
  }
200
202
 
203
+ // RestartDaemon 没有状态文件时必须在**停任何东西之前**失败:前台 serve 起的实例没有这份
204
+ // 文件,参数无从重建;硬重启会把「不知道参数」变成「先把服务停掉再报错」。
205
+ func TestRestartDaemonNeedsStateFile(t *testing.T) {
206
+ _, err := RestartDaemon(RestartOptions{StatePath: filepath.Join(t.TempDir(), "nope.json")})
207
+ if err == nil {
208
+ t.Fatal("没有状态文件时应当报错(不能猜参数、也不能先停实例)")
209
+ }
210
+ if !strings.Contains(err.Error(), "状态文件") {
211
+ t.Errorf("错误信息要能落地(说明缺的是状态文件):%v", err)
212
+ }
213
+ }
214
+
215
+ // POST /desk/restart 的两道闸:只收 POST;状态文件不指向本进程时 409。
216
+ // 后者是安全阀 —— 同机可能跑着两个实例,照着别人的状态文件重启会重启**别人**。
217
+ func TestHandleRestartGuards(t *testing.T) {
218
+ t.Setenv(EnvState, filepath.Join(t.TempDir(), "client.json"))
219
+ s := &Service{log: log.New(io.Discard, "", 0)}
220
+
221
+ rec := httptest.NewRecorder()
222
+ s.handleRestart(rec, httptest.NewRequest(http.MethodGet, "/desk/restart", nil))
223
+ if rec.Code != http.StatusMethodNotAllowed {
224
+ t.Errorf("GET 的状态码 = %d,want 405", rec.Code)
225
+ }
226
+
227
+ rec = httptest.NewRecorder()
228
+ s.handleRestart(rec, httptest.NewRequest(http.MethodPost, "/desk/restart", nil))
229
+ if rec.Code != http.StatusConflict {
230
+ t.Errorf("没有指向本进程的状态文件时 = %d,want 409(不许乱重启)", rec.Code)
231
+ }
232
+ }
233
+
201
234
  func portOf(t *testing.T, rawURL string) int {
202
235
  t.Helper()
203
236
  i := strings.LastIndex(rawURL, ":")
@@ -0,0 +1,57 @@
1
+ package client
2
+
3
+ // engines_checkin_test.go - 领取积分后同步引擎清单兜底缓存(避免重开弹层又显示「领取」)。
4
+
5
+ import (
6
+ "encoding/json"
7
+ "os"
8
+ "path/filepath"
9
+ "testing"
10
+ )
11
+
12
+ func TestPatchEnginesCacheAfterCheckin(t *testing.T) {
13
+ home := t.TempDir()
14
+ // Windows 上 os.UserHomeDir() 读 USERPROFILE,只设 HOME 隔离不住真机路径。
15
+ t.Setenv("HOME", home)
16
+ t.Setenv("USERPROFILE", home)
17
+
18
+ p, err := enginesCachePath()
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ before := `[{"engine":"codebuddy","ok":true,"checkin_today":false,"checkin_credit":"100","credit_left":"5830.7"},` +
26
+ `{"engine":"trae","ok":true}]`
27
+ if err := os.WriteFile(p, []byte(before), 0o600); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+
31
+ // 业务失败(今天已签)也要把状态翻成已领;余额沿用旧值。
32
+ patchEnginesCacheAfterCheckin([]byte(`{"engine":"codebuddy","ok":false,"checked_today":true,"message":"今天已签到"}`))
33
+
34
+ raw, err := os.ReadFile(p)
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ var rows []map[string]any
39
+ if err := json.Unmarshal(raw, &rows); err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ if len(rows) != 2 {
43
+ t.Fatalf("rows = %d, want 2", len(rows))
44
+ }
45
+ if rows[0]["checkin_today"] != true {
46
+ t.Errorf("checkin_today = %v, want true", rows[0]["checkin_today"])
47
+ }
48
+ if rows[0]["checkin_credit"] != "" {
49
+ t.Errorf("checkin_credit = %v, want 空串", rows[0]["checkin_credit"])
50
+ }
51
+ if rows[0]["credit_left"] != "5830.7" {
52
+ t.Errorf("credit_left 应保留旧值,实际 %v", rows[0]["credit_left"])
53
+ }
54
+ if rows[1]["engine"] != "trae" {
55
+ t.Errorf("其他引擎行不应被改动:%v", rows[1])
56
+ }
57
+ }
@@ -0,0 +1,64 @@
1
+ package client
2
+
3
+ // fallback_flags_test.go - 主备(2026-10-24)在插件侧的翻译:
4
+ // /desk/ask 的 fallbackEngine / fallbackModel 必须变成 core 的 --fallback-engine / --fallback-model。
5
+
6
+ import (
7
+ "net/http"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestPrepareAskFallbackFlags(t *testing.T) {
13
+ svc := &Service{opts: Options{Profile: DefaultProfile()}}
14
+ _, args, st, err := svc.prepareAsk(askRequest{
15
+ Engine: "claude",
16
+ Prompt: "hi",
17
+ FallbackEngine: "codebuddy",
18
+ FallbackModel: "hy3",
19
+ })
20
+ if err != nil {
21
+ t.Fatalf("prepareAsk: %v (status=%d)", err, st)
22
+ }
23
+ joined := strings.Join(args, " ")
24
+ if !strings.Contains(joined, "--fallback-engine codebuddy") {
25
+ t.Fatalf("备用引擎没透传:%v", args)
26
+ }
27
+ if !strings.Contains(joined, "--fallback-model hy3") {
28
+ t.Fatalf("备用模型没透传:%v", args)
29
+ }
30
+ }
31
+
32
+ func TestPrepareAskFallbackWhitelist(t *testing.T) {
33
+ prof := DefaultProfile()
34
+ prof.Engines = []string{"claude"}
35
+ svc := &Service{opts: Options{Profile: prof}}
36
+ _, _, st, err := svc.prepareAsk(askRequest{
37
+ Engine: "claude",
38
+ Prompt: "hi",
39
+ FallbackEngine: "codebuddy",
40
+ })
41
+ if err == nil || st != http.StatusForbidden {
42
+ t.Fatalf("备用引擎不在白名单时应 403,得到 st=%d err=%v", st, err)
43
+ }
44
+ }
45
+
46
+ func TestJobAskRequestKeepsFallback(t *testing.T) {
47
+ // 作业会落盘并在真正开跑时还原:备用配置必须跟着 Job 走,
48
+ // 否则「直接提交能降级、排队后不能」。
49
+ req := (&Job{Engine: "dsh", Prompt: "hi", FallbackEngine: "codebuddy", FallbackModel: "hy3"}).askRequest()
50
+ if req.FallbackEngine != "codebuddy" || req.FallbackModel != "hy3" {
51
+ t.Fatalf("作业还原丢了备用:%+v", req)
52
+ }
53
+ }
54
+
55
+ func TestPrepareAskFallbackIgnoredWhenEmpty(t *testing.T) {
56
+ svc := &Service{opts: Options{Profile: DefaultProfile()}}
57
+ _, args, _, err := svc.prepareAsk(askRequest{Engine: "claude", Prompt: "hi"})
58
+ if err != nil {
59
+ t.Fatalf("prepareAsk: %v", err)
60
+ }
61
+ if strings.Contains(strings.Join(args, " "), "--fallback") {
62
+ t.Fatalf("没配备用不该下发 fallback 参数:%v", args)
63
+ }
64
+ }