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,199 @@
1
+ // browse 驱动端:`magic-client browse <动作> …`。
2
+ //
3
+ // 「内置浏览器 MCP」的驱动/截图面(2026-10-25 用户定调):基于 **agent-browser**
4
+ // 插件(vercel-labs,Rust 原生 CLI,驱动真实 Chromium/CDP)实现。
5
+ //
6
+ // 与旧实现的差别:不再连本插件的 /browse-input WS 派发**合成事件**(isTrusted=false,
7
+ // 反自动化站点忽略),而是 spawn agent-browser 原生 exe —— 真实事件、CDP 级截图、
8
+ // PDF、a11y 快照。手机端「看页面 + 触控」仍走 /b/ 中转 + relay 远程鼠标(不在此列)。
9
+ //
10
+ // 用法:
11
+ //
12
+ // magic-client browse open <url> # 打开页面(daemon 未起自动拉起)
13
+ // magic-client browse state # 打印当前页面状态(标题/地址)
14
+ // magic-client browse click <x> <y> # 在页面坐标 (x,y) 点击
15
+ // magic-client browse move <x> <y> # 移动指针(不点击)
16
+ // magic-client browse dblclick <x> <y> # 双击
17
+ // magic-client browse scroll <dx> <dy> # 滚动(正数向下/右)
18
+ // magic-client browse type <文本> # 向焦点元素输入文本
19
+ // magic-client browse key <键名> # 如 Enter / Escape / a
20
+ // magic-client browse shot [--out 文件] [--full-page] # 截当前页面,存成 PNG(默认 shot.png)
21
+ // magic-client browse eval <js> # 在页面里跑一段 JS
22
+ // magic-client browse pdf <文件> # 存成 PDF
23
+ // magic-client browse snap [-i] # a11y 快照(-i 只出可交互元素)
24
+ //
25
+ // 会话固定 --session magic-client(与人类/其它工具的 default 会话隔离)。
26
+ // 探测:MAGIC_AGENT_AGENT_BROWSER_BIN > npm 全局前缀 > PATH;都没有时报安装提示。
27
+ package main
28
+
29
+ import (
30
+ "context"
31
+ "encoding/json"
32
+ "fmt"
33
+ "os"
34
+ "strconv"
35
+ "strings"
36
+
37
+ "github.com/darren/magic-agent/internal/client"
38
+ )
39
+
40
+ // browseTimeout 兼容旧名(新的超时在 client.ABTimeout)。
41
+ const browseTimeout = client.ABTimeout
42
+
43
+ func runBrowseDriver(args []string) int {
44
+ fs := newFlagSet("browse")
45
+ _ = fs.Parse(args)
46
+ rest := fs.Args()
47
+
48
+ if len(rest) == 0 {
49
+ fmt.Fprintln(os.Stderr, "用法: magic-client browse open|state|click|move|dblclick|scroll|type|key|shot|eval|pdf|snap …")
50
+ return 2
51
+ }
52
+
53
+ action := strings.ToLower(rest[0])
54
+ argv := rest[1:]
55
+
56
+ switch action {
57
+ case "open":
58
+ if len(argv) < 1 {
59
+ return browseUsage("open 需要 <url>")
60
+ }
61
+ return browseCall("open " + argv[0], func() (any, error) {
62
+ return nil, client.ABOpen(context.Background(), argv[0])
63
+ })
64
+ case "state":
65
+ return browseCall("state", func() (any, error) {
66
+ url, title, err := client.ABState(context.Background())
67
+ if err != nil {
68
+ return nil, err
69
+ }
70
+ return map[string]any{"url": url, "title": title}, nil
71
+ })
72
+ case "click", "move", "dblclick":
73
+ if len(argv) < 2 {
74
+ return browseUsage(action + " 需要 x y")
75
+ }
76
+ x, e1 := strconv.ParseFloat(argv[0], 64)
77
+ y, e2 := strconv.ParseFloat(argv[1], 64)
78
+ if e1 != nil || e2 != nil {
79
+ return browseUsage(action + " 的坐标必须是数字")
80
+ }
81
+ return browseCall(action, func() (any, error) {
82
+ return nil, client.ABMouse(context.Background(), action, x, y)
83
+ })
84
+ case "scroll":
85
+ if len(argv) < 2 {
86
+ return browseUsage("scroll 需要 dx dy")
87
+ }
88
+ dx, e1 := strconv.ParseFloat(argv[0], 64)
89
+ dy, e2 := strconv.ParseFloat(argv[1], 64)
90
+ if e1 != nil || e2 != nil {
91
+ return browseUsage("scroll 的参数必须是数字")
92
+ }
93
+ return browseCall("scroll", func() (any, error) {
94
+ return nil, client.ABScroll(context.Background(), dx, dy)
95
+ })
96
+ case "type":
97
+ if len(argv) < 1 {
98
+ return browseUsage("type 需要 文本")
99
+ }
100
+ text := strings.Join(argv, " ")
101
+ return browseCall("type", func() (any, error) {
102
+ return nil, client.ABType(context.Background(), text)
103
+ })
104
+ case "key":
105
+ if len(argv) < 1 {
106
+ return browseUsage("key 需要 键名(如 Enter)")
107
+ }
108
+ return browseCall("key", func() (any, error) {
109
+ return nil, client.ABKey(context.Background(), argv[0])
110
+ })
111
+ case "shot":
112
+ return browseShot(argv)
113
+ case "eval":
114
+ if len(argv) < 1 {
115
+ return browseUsage("eval 需要 <js>")
116
+ }
117
+ return browseCall("eval", func() (any, error) {
118
+ return client.ABEval(context.Background(), strings.Join(argv, " "))
119
+ })
120
+ case "pdf":
121
+ if len(argv) < 1 {
122
+ return browseUsage("pdf 需要 <文件>")
123
+ }
124
+ out := argv[len(argv)-1]
125
+ return browseCall("pdf", func() (any, error) {
126
+ return nil, client.ABPDF(context.Background(), out)
127
+ })
128
+ case "snap":
129
+ interactive := false
130
+ for _, a := range argv {
131
+ if a == "-i" || a == "--interactive" {
132
+ interactive = true
133
+ }
134
+ }
135
+ return browseCall("snap", func() (any, error) {
136
+ return client.ABSnap(context.Background(), interactive)
137
+ })
138
+ default:
139
+ return browseUsage("未知动作 " + action)
140
+ }
141
+ }
142
+
143
+ func browseUsage(msg string) int {
144
+ fmt.Fprintln(os.Stderr, "magic-client browse:", msg)
145
+ return 2
146
+ }
147
+
148
+ // browseCall 统一跑一个 agent-browser 动作并把结果打印/报错。
149
+ func browseCall(name string, fn func() (any, error)) int {
150
+ v, err := fn()
151
+ if err != nil {
152
+ fmt.Fprintf(os.Stderr, "magic-client browse %s: %v\n", name, err)
153
+ return 1
154
+ }
155
+ if v == nil {
156
+ fmt.Println("ok")
157
+ return 0
158
+ }
159
+ out, merr := json.MarshalIndent(v, "", " ")
160
+ if merr != nil {
161
+ fmt.Println("ok")
162
+ return 0
163
+ }
164
+ fmt.Println(string(out))
165
+ return 0
166
+ }
167
+
168
+ // browseShot 截当前页面并存成 PNG(agent-browser 真实 Chromium 截图,直接落盘)。
169
+ func browseShot(argv []string) int {
170
+ out := "shot.png"
171
+ fullPage := false
172
+ for i := 0; i < len(argv); i++ {
173
+ switch {
174
+ case argv[i] == "--out" && i+1 < len(argv):
175
+ out = argv[i+1]
176
+ i++
177
+ case strings.HasPrefix(argv[i], "--out="):
178
+ out = strings.TrimPrefix(argv[i], "--out=")
179
+ case argv[i] == "--full-page" || argv[i] == "--fullpage":
180
+ fullPage = true
181
+ case strings.HasPrefix(argv[i], "--"):
182
+ // 未知 flag 忽略
183
+ default:
184
+ out = argv[i] // 位置参数也认(`browse shot a.png`)
185
+ }
186
+ }
187
+ v, err := browseShotRun(out, fullPage)
188
+ if err != nil {
189
+ fmt.Fprintln(os.Stderr, "magic-client browse shot:", err)
190
+ return 1
191
+ }
192
+ _ = v
193
+ fmt.Println(out)
194
+ return 0
195
+ }
196
+
197
+ func browseShotRun(out string, fullPage bool) (any, error) {
198
+ return nil, client.ABShot(context.Background(), out, fullPage)
199
+ }
@@ -47,7 +47,7 @@ func writeJSONStdout(v any) {
47
47
  _ = enc.Encode(v)
48
48
  }
49
49
 
50
- // runClientCommand 处理 start / stop / status;未知动作返回 false 交给 serve 分支。
50
+ // runClientCommand 处理 start / stop / status / restart;未知动作返回 false 交给 serve 分支。
51
51
  func runClientCommand(action string, args []string) (handled bool, code int) {
52
52
  switch action {
53
53
  case "start":
@@ -56,6 +56,8 @@ func runClientCommand(action string, args []string) (handled bool, code int) {
56
56
  return true, runClientStop(args)
57
57
  case "status":
58
58
  return true, runClientStatus(args)
59
+ case "restart":
60
+ return true, runClientRestart(args)
59
61
  }
60
62
  return false, 0
61
63
  }
@@ -75,11 +77,13 @@ const clientUsage = `magic-client - magic-agent 的客户端插件(桌面 /
75
77
  用法:
76
78
  magic-client serve [flags] 前台运行(看日志 / 排障用)
77
79
  magic-client start [flags] 后台常驻(关掉终端也还在),打印手机可用的地址
80
+ magic-client restart 按当前参数重启后台实例(端口 / 令牌 / 目录沿用,手机不用改地址)
78
81
  magic-client status [--json] 看状态(是否在跑、端口、手机地址、健康检查)
79
82
  magic-client stop 停掉后台实例(本来没跑也算成功)
80
83
 
81
- 手机要连**必须**用 start --lan(或 serve --lan):默认只绑 127.0.0.1,同机可用、手机不可达。
82
- --lan 时令牌是唯一门锁,至少 24 字符;start 会复用 ~/.magic-client/token,跨重启稳定
84
+ 手机要连**默认就行**:start / serve 默认绑 0.0.0.0:9100(--addr 可覆盖;端口跨重启稳定,
85
+ 手机里的地址不用改)。只想本机访问加 --local(绑 127.0.0.1:0)。
86
+ 局域网模式下令牌是唯一门锁,至少 24 字符;start 会复用 ~/.magic-client/token,跨重启稳定
83
87
  (手机里存的是「地址 + 令牌」,每次换就等于配对作废)。
84
88
 
85
89
  serve 的 flags 见 ` + "`magic-client serve --help`" + `;start 另有 --state / --log / --wait。
@@ -87,13 +91,14 @@ serve 的 flags 见 ` + "`magic-client serve --help`" + `;start 另有 --state
87
91
 
88
92
  func runClientStart(args []string) int {
89
93
  fs := newFlagSet("start")
90
- lan := fs.Bool("lan", false, "绑到局域网(0.0.0.0),让手机 / 平板连得上。⚠️ 开了之后令牌是唯一门锁")
91
- addr := fs.String("addr", "", "监听地址(默认:带 --lan 时 0.0.0.0:0,否则 127.0.0.1:0)")
94
+ lan := fs.Bool("lan", true, "绑到局域网(默认 0.0.0.0:9100),让手机 / 平板连得上。⚠️ 令牌是唯一门锁")
95
+ localOnly := fs.Bool("local", false, "只绑 127.0.0.1(关闭默认的局域网监听,手机连不上)")
96
+ addr := fs.String("addr", "", "监听地址(默认 0.0.0.0:9100;带 --local 时 127.0.0.1:0)")
92
97
  profilePath := fs.String("profile", "", "profile JSON 路径(空 = 内置默认)")
93
98
  uiDir := fs.String("ui-dir", "", "桌面端界面目录(空 = 自动找与本程序同级的 client-ui)")
94
99
  mobileDir := fs.String("mobile-dir", "", "移动端界面目录(空 = 自动找同级的 client-ui-mobile),/m/ 由它提供")
95
100
  proxyAddr := fs.String("proxy-addr", "", "SOCKS5 出口监听地址(移动端 VPN 隧道核心用),如 0.0.0.0:1080")
96
- token := fs.String("token", "", "访问令牌(空 = 复用 ~/.magic-client/token,没有就生成;--lan 时至少 24 字符)")
101
+ token := fs.String("token", "", "访问令牌(空 = 复用 ~/.magic-client/token,没有就生成;局域网模式至少 24 字符)")
97
102
  statePath := fs.String("state", "", "状态文件路径(默认 ~/.magic-client/client.json;也可用 MAGIC_CLIENT_STATE)")
98
103
  logPath := fs.String("log", "", "后台日志路径(默认 ~/.magic-client/logs/client.log;也可用 MAGIC_CLIENT_LOG)")
99
104
  wait := fs.Duration("wait", 15*time.Second, "等后台服务报端口的时长(超时即判定启动失败)")
@@ -115,7 +120,7 @@ func runClientStart(args []string) int {
115
120
  Token: *token,
116
121
  ProxyAddr: *proxyAddr,
117
122
  BrowseUA: *browseUA,
118
- Lan: *lan,
123
+ Lan: *lan && !*localOnly,
119
124
  StatePath: *statePath,
120
125
  LogPath: *logPath,
121
126
  Wait: *wait,
@@ -149,6 +154,25 @@ func runClientStop(args []string) int {
149
154
  return 0
150
155
  }
151
156
 
157
+ // runClientRestart 按**当前实例自己的参数**重启:停旧 → 起新(见 client.RestartDaemon)。
158
+ //
159
+ // 参数全部来自状态文件,所以这里只接 --state 与 --delay:
160
+ // 手机点「重启服务」时由服务端 spawn 本动作,--delay 给那次 HTTP 响应留落地时间。
161
+ func runClientRestart(args []string) int {
162
+ fs := newFlagSet("restart")
163
+ statePath := fs.String("state", "", "状态文件路径(默认 ~/.magic-client/client.json)")
164
+ delay := fs.Duration("delay", 0, "停旧实例前等多久(服务端自重启用,给响应留时间)")
165
+ _ = fs.Parse(args)
166
+
167
+ st, err := client.RestartDaemon(client.RestartOptions{StatePath: *statePath, Delay: *delay})
168
+ if err != nil {
169
+ fmt.Fprintln(os.Stderr, "magic-client:", err)
170
+ return 1
171
+ }
172
+ printStarted(st)
173
+ return 0
174
+ }
175
+
152
176
  func runClientStatus(args []string) int {
153
177
  fs := newFlagSet("status")
154
178
  statePath := fs.String("state", "", "状态文件路径(默认 ~/.magic-client/client.json)")
@@ -181,7 +205,7 @@ func printStarted(st *client.DaemonState) {
181
205
  fmt.Printf(" 本机 %s\n", st.URL)
182
206
  switch {
183
207
  case len(st.LanAddrs) == 0:
184
- fmt.Println(" 局域网 (本次只绑了 127.0.0.1,手机连不上;要手机连请加 --lan)")
208
+ fmt.Println(" 局域网 (本次用了 --local,只绑 127.0.0.1;去掉 --local 重启即可让手机连)")
185
209
  default:
186
210
  for _, u := range st.LanAddrs {
187
211
  fmt.Printf(" 手机 %s\n", u)
@@ -214,7 +238,7 @@ func printStatus(st *client.DaemonStatus) {
214
238
  fmt.Printf(" 手机 %s\n", u)
215
239
  }
216
240
  if len(s.LanAddrs) == 0 {
217
- fmt.Println(" 局域网 (未绑局域网,手机连不上)")
241
+ fmt.Println(" 局域网 (未绑局域网,本次用了 --local)")
218
242
  }
219
243
  if st.Health != nil {
220
244
  fmt.Printf(" 应用 %s|契约 v%d|引擎 %d 个\n",
@@ -9,11 +9,12 @@
9
9
  //
10
10
  // magic-client serve --profile ./profiles/zhantianping.json --ui-dir ./client-ui
11
11
  // magic-client serve --print-url # 只打印带令牌的地址,不启动(脚本用)
12
- // magic-client start --lan # 后台常驻(关掉终端也还在),手机可连
12
+ // magic-client start # 后台常驻(关掉终端也还在),默认手机可连
13
+ // magic-client start --local # 只绑 127.0.0.1(不给局域网)
13
14
  // magic-client status | stop # 看状态 / 停掉后台实例
14
15
  //
15
- // 默认**只绑 127.0.0.1**,并需要 X-Magic-Token:同机任意进程都能访问本地端口,
16
- // 不开鉴权等于把引擎执行权交给它们。
16
+ // 默认绑 0.0.0.0:9100(手机可直接连),并强制 X-Magic-Token:
17
+ // 局域网内令牌是唯一门锁;只想本机访问时加 --local。
17
18
  package main
18
19
 
19
20
  import (
@@ -38,8 +39,11 @@ func main() {
38
39
  os.Exit(2)
39
40
  }
40
41
  switch os.Args[1] {
41
- case "start", "stop", "status":
42
+ case "start", "stop", "status", "restart":
42
43
  os.Exit(runClientCommandWithExit(os.Args[1], os.Args[2:]))
44
+ case "browse":
45
+ // 浏览器输入驱动端(见 browsectl.go),不落 serve 流程
46
+ os.Exit(runBrowseDriver(os.Args[2:]))
43
47
  case "serve":
44
48
  // 落下去走下面的前台流程
45
49
  case "-h", "--help", "help":
@@ -54,18 +58,33 @@ func main() {
54
58
  profilePath = fs.String("profile", "", "profile JSON 路径(空 = 内置默认)")
55
59
  uiDir = fs.String("ui-dir", "", "静态资源目录(基础版 UI 与业务模块)")
56
60
  mobileDir = fs.String("mobile-dir", "", "移动端界面目录(client-ui-mobile)。配了之后 /m/ 远程提供它,APK 壳可远程加载、改界面免重打")
57
- addr = fs.String("addr", "", "监听地址(默认 127.0.0.1:0;带 --lan 时是 0.0.0.0:0)")
58
- lan = fs.Bool("lan", false, "绑到局域网(0.0.0.0),让手机 / 平板连得上。⚠️ 开了之后令牌是唯一门锁")
59
- token = fs.String("token", "", "访问令牌(空 = 随机生成并打印;--lan 时至少要 24 个字符)")
61
+ addr = fs.String("addr", "", "监听地址(默认 0.0.0.0:9100,手机可连;带 --local 时 127.0.0.1:0)")
62
+ lan = fs.Bool("lan", true, "绑到局域网(默认 0.0.0.0:9100),让手机 / 平板连得上。⚠️ 令牌是唯一门锁")
63
+ localOnly = fs.Bool("local", false, "只绑 127.0.0.1(关闭默认的局域网监听,手机连不上)")
64
+ token = fs.String("token", "", "访问令牌(空 = 随机生成并打印;局域网模式下至少要 24 个字符)")
60
65
  magicAgent = fs.String("magic-agent", os.Getenv("MAGIC_AGENT_BIN"), "core 可执行文件(空 = 从 PATH 找 magic-agent)")
61
66
  printURL = fs.Bool("print-url", false, "只打印带令牌的访问地址后退出(不启动服务)")
62
67
  proxyAddr = fs.String("proxy-addr", "", "SOCKS5 出口监听地址(供移动端 VPN 隧道核心使用),如 0.0.0.0:1080")
63
68
  browseUA = fs.String("browse-ua", "", "内置浏览器中转的 User-Agent:mobile(默认)/ desktop / 任意自定义串")
64
69
  readyFile = fs.String("ready-file", "", "监听成功后把 {pid,port,url,lanAddrs} 原子写到这个路径(`magic-client start` 用它拿端口,别手工传)")
65
- anyOrigin = fs.Bool("allow-any-origin", false, "对任意 Origin 回 CORS 头(默认只放行本机页面)。--lan 时自动开启;令牌仍是唯一门锁")
70
+ anyOrigin = fs.Bool("allow-any-origin", false, "对任意 Origin 回 CORS 头(默认只放行本机页面)。局域网模式自动开启;令牌仍是唯一门锁")
66
71
  )
67
72
  _ = fs.Parse(os.Args[2:])
68
73
 
74
+ // 局域网是默认行为(手机开箱能连);--local 或显式回环地址回到「只给本机」。
75
+ lanEnabled := *lan && !*localOnly
76
+ listenAddr := strings.TrimSpace(*addr)
77
+ if listenAddr == "" {
78
+ if lanEnabled {
79
+ listenAddr = client.DefaultLanAddr
80
+ } else {
81
+ listenAddr = "127.0.0.1:0"
82
+ }
83
+ }
84
+ if lanEnabled && isLoopbackListenAddr(listenAddr) {
85
+ lanEnabled = false
86
+ }
87
+
69
88
  prof, err := client.LoadProfile(*profilePath)
70
89
  if err != nil {
71
90
  fmt.Fprintln(os.Stderr, "magic-client:", err)
@@ -75,9 +94,9 @@ func main() {
75
94
  if tok == "" {
76
95
  tok = randToken() // 16 字节 = 32 位十六进制,远强于局域网那条线
77
96
  }
78
- /* 绑到局域网时令牌是**唯一**门锁:内网能被扫到,短令牌等于没有。
97
+ /* 局域网模式下令牌是**唯一**门锁:内网能被扫到,短令牌等于没有。
79
98
  默认生成的那个够强,所以这条只在用户显式给了弱令牌时报错。 */
80
- if *lan {
99
+ if lanEnabled {
81
100
  if err := client.ValidateLanToken(tok); err != nil {
82
101
  fmt.Fprintln(os.Stderr, "magic-client:", err)
83
102
  os.Exit(2)
@@ -89,12 +108,12 @@ func main() {
89
108
  UIRoot: *uiDir,
90
109
  MobileRoot: *mobileDir,
91
110
  Token: tok,
92
- Lan: *lan,
111
+ Lan: lanEnabled,
93
112
  BrowseUA: *browseUA,
94
- /* 绑到局域网 = 「允许来自任意 IP 的页面访问」:再按 loopback 卡 CORS Origin
113
+ /* 局域网 = 「允许来自任意 IP 的页面访问」:再按 loopback 卡 CORS Origin
95
114
  等于把手机 / 别的设备的页面全判成跨域(用户侧就是「只能本地访问」)。
96
115
  显式 --allow-any-origin 也可以单独打开(绑回环但要给容器页面用)。 */
97
- AllowAnyOrigin: *anyOrigin || *lan,
116
+ AllowAnyOrigin: *anyOrigin || lanEnabled,
98
117
  })
99
118
  if err != nil {
100
119
  // 契约不匹配是**要立刻停**的错误:带着不匹配的能力跑起来只会让人以为是界面 bug。
@@ -102,23 +121,21 @@ func main() {
102
121
  os.Exit(1)
103
122
  }
104
123
 
105
- listenAddr := strings.TrimSpace(*addr)
106
- if listenAddr == "" {
107
- if *lan {
108
- listenAddr = "0.0.0.0:0"
109
- } else {
110
- listenAddr = "127.0.0.1:0"
111
- }
112
- }
113
124
  ln, err := net.Listen("tcp", listenAddr)
114
125
  if err != nil {
115
- fmt.Fprintln(os.Stderr, "magic-client: 监听失败:", err)
126
+ // 固定默认端口是新引入的失败形态(被占用),给一句能落地的指引;
127
+ // 显式 --addr 的失败照旧只报错 —— 用户自己选的地址不需要教学。
128
+ if listenAddr == client.DefaultLanAddr && strings.TrimSpace(*addr) == "" {
129
+ fmt.Fprintf(os.Stderr, "magic-client: 监听失败: %v(%s 被占用?先 magic-client stop,或用 --addr 换个端口)\n", err, listenAddr)
130
+ } else {
131
+ fmt.Fprintf(os.Stderr, "magic-client: 监听失败: %v\n", err)
132
+ }
116
133
  os.Exit(1)
117
134
  }
118
135
  port := ln.Addr().(*net.TCPAddr).Port
119
136
  url := fmt.Sprintf("http://127.0.0.1:%d/?token=%s", port, tok)
120
137
  if *printURL {
121
- // 只打印本机地址:脚本拿它做探活,局域网地址在 --lan 下另有输出。
138
+ // 只打印本机地址:脚本拿它做探活,局域网地址在局域网模式下另有输出。
122
139
  fmt.Println(url)
123
140
  _ = ln.Close()
124
141
  return
@@ -133,7 +150,7 @@ func main() {
133
150
  }
134
151
  fmt.Fprintf(os.Stderr, "magic-client: 接口 /desk/{health,contract,engines,modules,sessions,profile,suggest,asks,ask,ask/{id}/control} + 业务转发 /api/*(都要 X-Magic-Token)\n")
135
152
  var lanAddrs []string
136
- if *lan {
153
+ if lanEnabled {
137
154
  // 绑到局域网就把可用地址全打出来:一台机器常有多个网卡,猜错比多打两行麻烦。
138
155
  fmt.Fprintln(os.Stderr, "magic-client: ⚠️ 已绑到局域网(0.0.0.0):同网络内的设备都能访问,令牌是唯一门锁")
139
156
  lanAddrs = client.LanAddresses(port)
@@ -164,7 +181,7 @@ func main() {
164
181
  }()
165
182
  proxyActual = pln.Addr().String()
166
183
  fmt.Fprintf(os.Stderr, "magic-client: SOCKS5 出口 %s(用户名随意,口令 = 令牌)\n", proxyActual)
167
- } else if *lan {
184
+ } else if lanEnabled {
168
185
  fmt.Fprintln(os.Stderr, "magic-client: 未开 SOCKS5 出口(移动端隧道需要它:加 --proxy-addr 0.0.0.0:1080)")
169
186
  }
170
187
 
@@ -180,7 +197,7 @@ func main() {
180
197
  Port: port,
181
198
  Addr: ln.Addr().String(),
182
199
  URL: url,
183
- Lan: *lan,
200
+ Lan: lanEnabled,
184
201
  LanAddrs: lanURLs,
185
202
  ProxyAddr: proxyActual,
186
203
  }); err != nil {
@@ -214,3 +231,17 @@ func randToken() string {
214
231
  }
215
232
  return hex.EncodeToString(b)
216
233
  }
234
+
235
+ // isLoopbackListenAddr 判断监听地址是否只绑回环(127.0.0.1 / localhost / ::1)。
236
+ // 空 host(":9100")= 所有网卡,不算回环。
237
+ func isLoopbackListenAddr(addr string) bool {
238
+ host, _, err := net.SplitHostPort(addr)
239
+ if err != nil || host == "" {
240
+ return false
241
+ }
242
+ if strings.EqualFold(host, "localhost") {
243
+ return true
244
+ }
245
+ ip := net.ParseIP(host)
246
+ return ip != nil && ip.IsLoopback()
247
+ }
@@ -360,6 +360,27 @@ func EncodeAskAnswer(engine string, req *AskRequest, ans AskAnswer) ([]byte, err
360
360
  return nil, fmt.Errorf("ask: engine %q has no known ask-answer protocol shape", engine)
361
361
  }
362
362
 
363
+ /* codebuddy / codebuddy-ai 的权限应答形状与 claude SDK 不同(2026-10-25 抓包 + 读包内代码核实):
364
+ CLI 的 SdkPermissionClient.handleResponse 只认
365
+ {"allowed":true} / {"allowed":false,"reason":"…"}
366
+ 传 claude 那套 {"behavior":"allow"} 会被解析成 allowed=false(整个表达式取不到 allowed),
367
+ 实测表现就是「发了 allow,CLI 回了『用户不想继续这个计划』的拒绝结果」。
368
+ 见 codebuddy-code dist/codebuddy.js 的 sdk_permission_client 段。 */
369
+ if req.Kind == AskKindPermission && askPermissionUsesAllowed(engine) {
370
+ out := map[string]any{"allowed": !ans.Denied}
371
+ if ans.Denied {
372
+ msg := ans.Message
373
+ if msg == "" {
374
+ msg = "User declined"
375
+ }
376
+ out["reason"] = msg
377
+ if ans.Interrupt && AskInterruptSupportOf(engine) {
378
+ out["interrupt"] = true
379
+ }
380
+ }
381
+ return json.Marshal(out)
382
+ }
383
+
363
384
  if ans.Denied {
364
385
  out := map[string]any{"behavior": "deny"}
365
386
  msg := ans.Message
@@ -536,6 +557,30 @@ func askAnswerShapeKnown(engine string) bool {
536
557
  return equalFold(engine, "claude") || equalFold(engine, "codebuddy") || equalFold(engine, "codebuddy-ai")
537
558
  }
538
559
 
560
+ // AskAnswerControlOf 引擎是否支持「原地回审批」:宿主能通过 Request.Reply 往子进程 stdin
561
+ // 写一行 control_response,把这一轮正阻塞等着的裁决交回去。
562
+ //
563
+ // 只有 stream-json 输入通道的引擎(claude / codebuddy / codebuddy-ai)有这条路;
564
+ // 其余引擎要么没有控制协议,要么命令行侧根本没有 stdin 通道。
565
+ func AskAnswerControlOf(engine string) bool {
566
+ engine = CapabilityFamilyOf(engine)
567
+ switch engine {
568
+ case "claude", "codebuddy", "codebuddy-ai":
569
+ return true
570
+ }
571
+ return false
572
+ }
573
+
574
+ // askPermissionUsesAllowed 权限应答是否用 codebuddy 的 `{"allowed":…}` 形状。
575
+ //
576
+ // claude SDK 的 can_use_tool 回调结果是 {"behavior":"allow"|"deny",…};
577
+ // 而 codebuddy CLI 的 SdkPermissionClient 只读 `allowed` / `reason` / `interrupt`(见
578
+ // EncodeAskAnswer 里的实测注脚)。两者不能混用 —— 混了不报错,只会静默变成「拒绝」。
579
+ func askPermissionUsesAllowed(engine string) bool {
580
+ engine = CapabilityFamilyOf(engine)
581
+ return equalFold(engine, "codebuddy") || equalFold(engine, "codebuddy-ai")
582
+ }
583
+
539
584
  // AskInterruptSupportOf 拒绝时是否支持 interrupt(拒绝并中断整个会话)。
540
585
  //
541
586
  // 只有 codebuddy 的官方文档写了该字段;claude 的 SDK 文档只给 behavior/message,
@@ -262,6 +262,45 @@ func TestEncodeAskAnswerPermissionKind(t *testing.T) {
262
262
  }
263
263
  }
264
264
 
265
+ // codebuddy 的权限应答是 `{"allowed":…}` 而不是 claude 的 `{"behavior":"allow"}`。
266
+ // 2026-10-25 真机 + 读包内代码核实(codebuddy-code dist/codebuddy.js 的 SdkPermissionClient):
267
+ // 传 behavior 形状会被读成 allowed=false —— 表现是「发了允许,CLI 回一句用户不想继续」。
268
+ func TestEncodeAskAnswerPermissionCodebuddy(t *testing.T) {
269
+ req, ok := ParseAskLine("codebuddy", askPermissionControlRequestDoc)
270
+ if !ok {
271
+ t.Fatal("素材没解析出来")
272
+ }
273
+ out, err := EncodeAskAnswer("codebuddy", req, AskAnswer{})
274
+ if err != nil {
275
+ t.Fatalf("渲染失败: %v", err)
276
+ }
277
+ var allow struct {
278
+ Allowed bool `json:"allowed"`
279
+ }
280
+ if err := json.Unmarshal(out, &allow); err != nil {
281
+ t.Fatalf("输出不是合法 JSON: %v", err)
282
+ }
283
+ if !allow.Allowed {
284
+ t.Errorf("codebuddy 放行必须是 {\"allowed\":true}: %s", out)
285
+ }
286
+
287
+ denyOut, err := EncodeAskAnswer("codebuddy", req, AskAnswer{Denied: true, Message: "先改改计划", Interrupt: true})
288
+ if err != nil {
289
+ t.Fatalf("拒绝渲染失败: %v", err)
290
+ }
291
+ var deny struct {
292
+ Allowed bool `json:"allowed"`
293
+ Reason string `json:"reason"`
294
+ Interrupt bool `json:"interrupt"`
295
+ }
296
+ if err := json.Unmarshal(denyOut, &deny); err != nil {
297
+ t.Fatalf("拒绝输出不是合法 JSON: %v", err)
298
+ }
299
+ if deny.Allowed || deny.Reason != "先改改计划" || !deny.Interrupt {
300
+ t.Errorf("codebuddy 拒绝形状不对: %s", denyOut)
301
+ }
302
+ }
303
+
265
304
  func TestEncodeAskAnswerErrors(t *testing.T) {
266
305
  dup := `{"type":"assistant","message":{"content":[{"type":"tool_use","id":"c","name":"AskUserQuestion","input":{"questions":[
267
306
  {"question":"同一个问题?","header":"A","multiSelect":false,"options":[{"label":"是"},{"label":"否"}]},
@@ -421,8 +421,12 @@ func TestLLMCLIArgsUseAttachmentFlag(t *testing.T) {
421
421
 
422
422
  func TestTraePromptCarriesAttachmentPath(t *testing.T) {
423
423
  img := writeTestPNG(t, "shot.png")
424
- // 假 trae-cli:把收到的参数原样回显(纯文本输出)。
425
- cli := writeFakeCLI(t, "trae-cli", "#!/bin/sh\necho \"$*\"\n")
424
+ // 假 traex:把收到的参数塞进 agent_message,走 2.0 的 JSONL 事件流回读。
425
+ cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
426
+ printf '%s\n' '{"type":"thread.started","thread_id":"th-1"}'
427
+ printf '{"type":"item.completed","item":{"id":"i","type":"agent_message","text":"%s"}}\n' "$*"
428
+ printf '%s\n' '{"type":"turn.completed"}'
429
+ `)
426
430
  e := &TraeEngine{BinPath: cli}
427
431
  res, err := e.Complete(context.Background(), Request{
428
432
  Messages: []Message{{Role: "user", Content: "看图"}},
@@ -167,9 +167,15 @@ const (
167
167
  // 客户端据此决定要不要显示「需求」页签:老版本 core 没有这个 feature,
168
168
  // 界面必须把入口藏起来,而不是给一个点下去只会报错的按钮。
169
169
  FeatureRequirements = "feature.requirements"
170
+ // FeatureRequirementShots `--requirement-shot`:给功能点追加「页面完成情况」截图。
171
+ //
172
+ // 与 FeatureRequirements 分开是因为它是**后加的**能力:core 支持需求聚合
173
+ // 但还不支持截图的版本存在(2026-10-25)。客户端要能分别判断 ——
174
+ // 老 core 上「需求」页签照常用,只是「截图」入口藏起来。
175
+ FeatureRequirementShots = "feature.requirement.shots"
170
176
  )
171
177
 
172
178
  // CoreFeatures 返回 core 自身提供的通道能力(字典序,稳定)。
173
179
  func CoreFeatures() []string {
174
- return []string{FeatureRequirements, FeatureSessionControl, FeatureSessionEvents, FeatureSessionHosted, FeatureSessionTeam}
180
+ return []string{FeatureRequirementShots, FeatureRequirements, FeatureSessionControl, FeatureSessionEvents, FeatureSessionHosted, FeatureSessionTeam}
175
181
  }
@@ -52,7 +52,6 @@ package agent
52
52
  import (
53
53
  "context"
54
54
  "fmt"
55
- "io"
56
55
  "os"
57
56
  "path/filepath"
58
57
  "strings"
@@ -288,22 +287,28 @@ func (e *ClaudeEngine) Stream(ctx context.Context, req Request, onEvent func(Str
288
287
  //
289
288
  // 常驻会话(Request.Append 非 nil)的区别只在 stdin 是个**流**:
290
289
  // 追加消息源源不断写进去,通道关闭才 EOF(子进程随之收尾)。
291
- var stdin io.Reader
292
- if req.Append != nil {
293
- pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
294
- if perr != nil {
295
- return StreamResult{}, perr
296
- }
297
- stdin = pipe
298
- } else {
299
- line, serr := streamJSONUserLine(prompt, req.Attachments)
300
- if serr != nil {
301
- return StreamResult{}, serr
302
- }
303
- stdin = strings.NewReader(line)
290
+ // Reply 非 nil(要回控制协议行)时同样是流:stdin 保持打开到本轮收尾,
291
+ // 否则 CLI 发出 control_request 时已经没有可写回的落点(见 Request.Reply)。
292
+ stdin, closeStdin, serr := streamJSONStdinFor(req, prompt)
293
+ if serr != nil {
294
+ return StreamResult{}, serr
304
295
  }
296
+ defer closeStdin()
305
297
  args := streamJSONArgs(e.buildArgsBase(req), prompt, true)
306
- acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent, OnSessionID: req.OnSessionID}
298
+ var acc *streamAccumulator
299
+ if onEvent == nil && req.Reply == nil {
300
+ acc = &streamAccumulator{Engine: e.Name(), OnSessionID: req.OnSessionID}
301
+ } else {
302
+ acc = &streamAccumulator{Engine: e.Name(), OnSessionID: req.OnSessionID,
303
+ OnEvent: func(ev StreamEvent) {
304
+ if streamTerminalKind(ev.Kind) {
305
+ closeStdin()
306
+ }
307
+ if onEvent != nil {
308
+ onEvent(ev)
309
+ }
310
+ }}
311
+ }
307
312
  var fin streamJSONResult
308
313
  var cliStderr streamStderrBuf
309
314
  seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, stdin, acc, &fin, &cliStderr)