magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -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 +40 -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 +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -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 +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  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 +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -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 (
@@ -30,6 +31,7 @@ import (
30
31
  "time"
31
32
 
32
33
  "github.com/darren/magic-agent/internal/client"
34
+ "github.com/darren/magic-agent/internal/config"
33
35
  )
34
36
 
35
37
  func main() {
@@ -38,8 +40,11 @@ func main() {
38
40
  os.Exit(2)
39
41
  }
40
42
  switch os.Args[1] {
41
- case "start", "stop", "status":
43
+ case "start", "stop", "status", "restart":
42
44
  os.Exit(runClientCommandWithExit(os.Args[1], os.Args[2:]))
45
+ case "browse":
46
+ // 浏览器输入驱动端(见 browsectl.go),不落 serve 流程
47
+ os.Exit(runBrowseDriver(os.Args[2:]))
43
48
  case "serve":
44
49
  // 落下去走下面的前台流程
45
50
  case "-h", "--help", "help":
@@ -54,15 +59,22 @@ func main() {
54
59
  profilePath = fs.String("profile", "", "profile JSON 路径(空 = 内置默认)")
55
60
  uiDir = fs.String("ui-dir", "", "静态资源目录(基础版 UI 与业务模块)")
56
61
  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 个字符)")
62
+ addr = fs.String("addr", "", "监听地址(默认 0.0.0.0:9100,手机可连;带 --local 时 127.0.0.1:0)")
63
+ lan = fs.Bool("lan", true, "绑到局域网(默认 0.0.0.0:9100),让手机 / 平板连得上。⚠️ 令牌是唯一门锁")
64
+ localOnly = fs.Bool("local", false, "只绑 127.0.0.1(关闭默认的局域网监听,手机连不上)")
65
+ token = fs.String("token", "", "访问令牌(空 = 随机生成并打印;局域网模式下至少要 24 个字符)")
60
66
  magicAgent = fs.String("magic-agent", os.Getenv("MAGIC_AGENT_BIN"), "core 可执行文件(空 = 从 PATH 找 magic-agent)")
61
67
  printURL = fs.Bool("print-url", false, "只打印带令牌的访问地址后退出(不启动服务)")
62
68
  proxyAddr = fs.String("proxy-addr", "", "SOCKS5 出口监听地址(供移动端 VPN 隧道核心使用),如 0.0.0.0:1080")
63
69
  browseUA = fs.String("browse-ua", "", "内置浏览器中转的 User-Agent:mobile(默认)/ desktop / 任意自定义串")
64
70
  readyFile = fs.String("ready-file", "", "监听成功后把 {pid,port,url,lanAddrs} 原子写到这个路径(`magic-client start` 用它拿端口,别手工传)")
65
- anyOrigin = fs.Bool("allow-any-origin", false, "对任意 Origin 回 CORS 头(默认只放行本机页面)。--lan 时自动开启;令牌仍是唯一门锁")
71
+ anyOrigin = fs.Bool("allow-any-origin", false, "对任意 Origin 回 CORS 头(默认只放行本机页面)。局域网模式自动开启;令牌仍是唯一门锁")
72
+ /* 前台 serve 自登记(2026-10-26):
73
+ `POST /desk/restart` 需要知道「这台实例是怎么起的」,否则手机点重启只能 409。
74
+ `magic-client start` 起的实例一直有 client.json 可读;前台 serve(magic-test 的
75
+ 桥接就是这种)过去什么都不写,于是按钮永远点不动。 */
76
+ statePath = fs.String("state", "", "把本实例的启动参数登记到这个状态文件(空 = <数据目录>/serve.json)。重启接口靠它认领自己")
77
+ hostPID = fs.Int("host-pid", 0, "托管本服务的父进程 pid(桌面壳)。它退出后本服务跟着退出,避免重启后留下孤儿占端口")
66
78
  )
67
79
  _ = fs.Parse(os.Args[2:])
68
80
 
@@ -71,30 +83,100 @@ func main() {
71
83
  fmt.Fprintln(os.Stderr, "magic-client:", err)
72
84
  os.Exit(1)
73
85
  }
86
+
87
+ /* 单实例互斥(2026-10-26 用户:「同时只启动1个」)。
88
+ *
89
+ * 位置:紧跟 LoadProfile(要 profile 才能算出 AppID 与数据目录),**早于
90
+ * client.New / net.Listen**,原因是:
91
+ * · client.New 会跑 `magic-agent --contract`(20s 超时)并建起作业队列,
92
+ * 那一步已经会去碰共用的 <DataDir>/jobs 与状态文件。晚于它就等于
93
+ * 「先动了手再问有没有别人」;
94
+ * · 晚于 net.Listen 的话,第二份实例的报错会变成「端口被占用」—— 那是在描述
95
+ * 症状不是原因(端口撞了只是这份目录上已有一份在跑的结果);
96
+ * · --addr 各不相同的话,端口探测根本发现不了对方,但两份照样各跑一套作业
97
+ * 队列去抢同一批工作区 —— 手机连着 A、任务在 B 里跑完,界面空回复。
98
+ *
99
+ * 两道闸门(缺一不可,见 singleinstance.go 的长注释):
100
+ * ① AppID 登记:同一应用**换了数据目录**也不算两份不同应用;
101
+ * ② DataDir 锁:同一个目录上只能有一份。
102
+ * 只做 ② 会漏掉「同一次部署被算出两个数据目录」这种漂移(本机真有:
103
+ * magic-test 既有 ~/.magic-test 又有 ~/.magic-client/magic-test)。
104
+ *
105
+ * 只跳过 --print-url(脚本探活用的,它连服务都不起,不该占锁)。 */
106
+ var instLock *client.SingleInstanceLock
107
+ if !*printURL {
108
+ lk, holder, which, lerr := client.AcquireInstanceLocks(prof.AppID(), prof.DataDir(), 0)
109
+ if lerr != nil {
110
+ fmt.Fprintln(os.Stderr, "magic-client:", lerr)
111
+ os.Exit(1)
112
+ }
113
+ if lk == nil {
114
+ if which == "appid" {
115
+ fmt.Fprintln(os.Stderr, "magic-client:", client.AppIDConflictMessage(prof.AppID(), holder))
116
+ } else {
117
+ fmt.Fprintln(os.Stderr, "magic-client:", client.SingleInstanceConflictMessage(holder))
118
+ }
119
+ os.Exit(3)
120
+ }
121
+ instLock = lk
122
+ defer instLock.Release()
123
+ }
124
+
125
+ // 局域网是默认行为(手机开箱能连);--local 或显式回环地址回到「只给本机」。
126
+ lanEnabled := *lan && !*localOnly
127
+ listenAddr := strings.TrimSpace(*addr)
128
+ if listenAddr == "" {
129
+ if lanEnabled {
130
+ listenAddr = client.DefaultLanAddr
131
+ } else {
132
+ listenAddr = "127.0.0.1:0"
133
+ }
134
+ }
135
+ if lanEnabled && isLoopbackListenAddr(listenAddr) {
136
+ lanEnabled = false
137
+ }
138
+
74
139
  tok := strings.TrimSpace(*token)
75
140
  if tok == "" {
76
141
  tok = randToken() // 16 字节 = 32 位十六进制,远强于局域网那条线
77
142
  }
78
- /* 绑到局域网时令牌是**唯一**门锁:内网能被扫到,短令牌等于没有。
143
+ /* 局域网模式下令牌是**唯一**门锁:内网能被扫到,短令牌等于没有。
79
144
  默认生成的那个够强,所以这条只在用户显式给了弱令牌时报错。 */
80
- if *lan {
145
+ if lanEnabled {
81
146
  if err := client.ValidateLanToken(tok); err != nil {
82
147
  fmt.Fprintln(os.Stderr, "magic-client:", err)
83
148
  os.Exit(2)
84
149
  }
85
150
  }
151
+ /* 飞书推送凭据(2026-10-26):config.json 的 feishu 节 / MAGIC_AGENT_FEISHU_APP_ID|SECRET。
152
+ 读不到配置文件不算错 —— 没配就是没开推送,界面里会提示去填(见 internal/client/feishu.go)。 */
153
+ feishuCfg, _ := config.Load()
154
+ /* 本实例的自登记路径,**在 New 之前**就算出来:Service 要靠它「认领自己」
155
+ (手机点重启时 selfDaemonState 按这条路找回状态,见 coreupgrade.go)。
156
+ 空串不可能 —— prof 一定有数据目录;真没有就退回 <数据目录>/serve.json 的约定。 */
157
+ selfStatePath := strings.TrimSpace(*statePath)
158
+ if selfStatePath == "" {
159
+ selfStatePath = client.SelfStatePath(prof.DataDir())
160
+ }
86
161
  svc, err := client.New(client.Options{
87
- MagicAgent: *magicAgent,
88
- Profile: prof,
89
- UIRoot: *uiDir,
90
- MobileRoot: *mobileDir,
91
- Token: tok,
92
- Lan: *lan,
93
- BrowseUA: *browseUA,
94
- /* 绑到局域网 = 「允许来自任意 IP 的页面访问」:再按 loopback 卡 CORS Origin
162
+ MagicAgent: *magicAgent,
163
+ FeishuAppID: feishuCfg.Feishu.AppID,
164
+ FeishuAppSecret: feishuCfg.Feishu.AppSecret,
165
+ Profile: prof,
166
+ UIRoot: *uiDir,
167
+ MobileRoot: *mobileDir,
168
+ Token: tok,
169
+ Lan: lanEnabled,
170
+ BrowseUA: *browseUA,
171
+ /* 局域网 = 「允许来自任意 IP 的页面访问」:再按 loopback 卡 CORS Origin
95
172
  等于把手机 / 别的设备的页面全判成跨域(用户侧就是「只能本地访问」)。
96
173
  显式 --allow-any-origin 也可以单独打开(绑回环但要给容器页面用)。 */
97
- AllowAnyOrigin: *anyOrigin || *lan,
174
+ AllowAnyOrigin: *anyOrigin || lanEnabled,
175
+ /* 前台 serve 自登记的落点:重启接口靠它认领本进程(见 daemon.go 的 SelfStateName)。 */
176
+ SelfStatePath: selfStatePath,
177
+ /* 托管父进程:重启之后的实例是 detach 的,壳退出杀不到它。
178
+ 记下来交给服务自己盯(见 service.go 的 WatchHost)。 */
179
+ HostPID: *hostPID,
98
180
  })
99
181
  if err != nil {
100
182
  // 契约不匹配是**要立刻停**的错误:带着不匹配的能力跑起来只会让人以为是界面 bug。
@@ -102,23 +184,21 @@ func main() {
102
184
  os.Exit(1)
103
185
  }
104
186
 
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
187
  ln, err := net.Listen("tcp", listenAddr)
114
188
  if err != nil {
115
- fmt.Fprintln(os.Stderr, "magic-client: 监听失败:", err)
189
+ // 固定默认端口是新引入的失败形态(被占用),给一句能落地的指引;
190
+ // 显式 --addr 的失败照旧只报错 —— 用户自己选的地址不需要教学。
191
+ if listenAddr == client.DefaultLanAddr && strings.TrimSpace(*addr) == "" {
192
+ fmt.Fprintf(os.Stderr, "magic-client: 监听失败: %v(%s 被占用?先 magic-client stop,或用 --addr 换个端口)\n", err, listenAddr)
193
+ } else {
194
+ fmt.Fprintf(os.Stderr, "magic-client: 监听失败: %v\n", err)
195
+ }
116
196
  os.Exit(1)
117
197
  }
118
198
  port := ln.Addr().(*net.TCPAddr).Port
119
199
  url := fmt.Sprintf("http://127.0.0.1:%d/?token=%s", port, tok)
120
200
  if *printURL {
121
- // 只打印本机地址:脚本拿它做探活,局域网地址在 --lan 下另有输出。
201
+ // 只打印本机地址:脚本拿它做探活,局域网地址在局域网模式下另有输出。
122
202
  fmt.Println(url)
123
203
  _ = ln.Close()
124
204
  return
@@ -133,7 +213,7 @@ func main() {
133
213
  }
134
214
  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
215
  var lanAddrs []string
136
- if *lan {
216
+ if lanEnabled {
137
217
  // 绑到局域网就把可用地址全打出来:一台机器常有多个网卡,猜错比多打两行麻烦。
138
218
  fmt.Fprintln(os.Stderr, "magic-client: ⚠️ 已绑到局域网(0.0.0.0):同网络内的设备都能访问,令牌是唯一门锁")
139
219
  lanAddrs = client.LanAddresses(port)
@@ -164,7 +244,7 @@ func main() {
164
244
  }()
165
245
  proxyActual = pln.Addr().String()
166
246
  fmt.Fprintf(os.Stderr, "magic-client: SOCKS5 出口 %s(用户名随意,口令 = 令牌)\n", proxyActual)
167
- } else if *lan {
247
+ } else if lanEnabled {
168
248
  fmt.Fprintln(os.Stderr, "magic-client: 未开 SOCKS5 出口(移动端隧道需要它:加 --proxy-addr 0.0.0.0:1080)")
169
249
  }
170
250
 
@@ -180,7 +260,7 @@ func main() {
180
260
  Port: port,
181
261
  Addr: ln.Addr().String(),
182
262
  URL: url,
183
- Lan: *lan,
263
+ Lan: lanEnabled,
184
264
  LanAddrs: lanURLs,
185
265
  ProxyAddr: proxyActual,
186
266
  }); err != nil {
@@ -190,10 +270,94 @@ func main() {
190
270
  }
191
271
  }
192
272
 
273
+ /* 自登记(2026-10-26 用户:「通过 magic-test 启动的无法在移动端点击重启服务重启」)。
274
+ *
275
+ * 手机「重启服务」= POST /desk/restart。它要做的第一件事是「确认我说的是本进程、
276
+ * 并且知道当初是怎么把我起来的」—— 重启助手要照这份参数停旧起新。`magic-client start`
277
+ * 起的实例一直有 client.json;前台 serve(magic-test 桥接、手工调试都是这种)过去
278
+ * 什么都不写,于是 409「当前实例不是后台启动的」,按钮永远点不动。
279
+ *
280
+ * 记到 <数据目录>/serve.json(**不是** client.json:那份是 start 的唯一实例记录,
281
+ * 详见 daemon.go 的 SelfStateName)。带 HostPID 是为了重启出来的 detach 实例还能
282
+ * 跟着壳一起退(见 svc.WatchHost)。
283
+ *
284
+ * 顺序:**监听成功之后**。写早了会把一个还没 Serve 的端口登记出去,手机拿着它去
285
+ * 探活/重启都会撞空。
286
+ *
287
+ * ⚠️ `magic-client start` 拉起的 serve **不登记**(ready 文件就是它那条路的标志):
288
+ * 那条路已经有 client.json(信息更全:LogPath / 幂等判据都在里面),再写一份
289
+ * serve.json 会让 selfDaemonState 先认领到这份**信息更少**的登记 —— 重启后
290
+ * 日志位置丢了、`stop` 的幂等判据也没了。两条启动路径各登记各的,不交叉。 */
291
+ /* 无论走哪条启动路径,只要知道「我那份登记在哪」,退出就清理(下面 defer)。
292
+ *
293
+ * 为什么 start 路径也要清:重启出来的实例不是被 `stop` 停掉的,而是 WatchHost
294
+ * 发现宿主没了、自己 srv.Close() 收摊 —— 那条路上没有任何人删登记,留着就是一个
295
+ * 指向死 pid 的陈旧文件(实测:serve.json 残留在 35444 这个已退出的 pid 上)。
296
+ * RemoveSelfState 带 pid 校验,新实例已经接管时不会误删它的。
297
+ *
298
+ * 但**写**登记只有前台 serve 才做:start 路径的 client.json 由 StartDaemon 写
299
+ *(它信息更全,含日志位置),serve 再写一遍等于用一份更少的覆盖它。 */
300
+ if !*printURL && selfStatePath != "" {
301
+ defer client.RemoveSelfState(selfStatePath)
302
+ }
303
+ if !*printURL && strings.TrimSpace(*readyFile) == "" {
304
+ lanURLs := make([]string, 0, len(lanAddrs))
305
+ for _, a := range lanAddrs {
306
+ lanURLs = append(lanURLs, fmt.Sprintf("http://%s/?token=%s", a, tok))
307
+ }
308
+ /* Exe 必须是**本进程自己的可执行文件**:重启助手照它拉新实例
309
+ (RestartDaemon → StartDaemon 的 Exe)。记空的话新实例会退回
310
+ 「当前进程自己」,而助手是另一个进程 —— 那就成了拉起助手自己。 */
311
+ exePath, _ := os.Executable()
312
+ p, werr := client.WriteSelfStateAt(selfStatePath, prof.DataDir(), &client.DaemonState{
313
+ PID: os.Getpid(),
314
+ Port: port,
315
+ URL: url,
316
+ LanAddrs: lanURLs,
317
+ Lan: lanEnabled,
318
+ Profile: *profilePath,
319
+ UIRoot: *uiDir,
320
+ MobileDir: *mobileDir,
321
+ ProxyAddr: proxyActual,
322
+ Token: tok,
323
+ StartedAt: time.Now(),
324
+ LogPath: "",
325
+ Exe: exePath,
326
+ Addr: strings.TrimSpace(*addr),
327
+ BrowseUA: *browseUA,
328
+ MagicAgent: *magicAgent,
329
+ AnyOrigin: *anyOrigin,
330
+ HostPID: *hostPID,
331
+ })
332
+ if werr != nil {
333
+ /* 登记失败不致命:服务照常能用,只是手机点重启会 409。
334
+ 绝不因此退出 —— 那等于「为了一个按钮把服务弄挂」。 */
335
+ fmt.Fprintf(os.Stderr, "magic-client: 警告 - 没能登记本实例状态(%v):手机端「重启服务」会不可用\n", werr)
336
+ } else {
337
+ fmt.Fprintf(os.Stderr, "magic-client: 实例状态 %s(手机可一键重启本实例)\n", p)
338
+ }
339
+ }
340
+
341
+ /* 飞书启动通知(2026-10-26 用户:「重新启动后如果绑定了飞书 触发一下发送访问地址」):
342
+ 重启是手机最容易失联的时刻(换网 / 换端口 / 隧道换域名),飞书是唯一不依赖那条
343
+ 连接的通道。没绑定 / 关了推送时 NotifyStartup 直接返回,不发任何请求;它在后台
344
+ 等隧道、可能试几十秒,不能挡住 Serve。局域网入口传进去,没有公网时拿它兜底。 */
345
+ startupEntry := ""
346
+ if len(lanAddrs) > 0 {
347
+ startupEntry = fmt.Sprintf("http://%s/?token=%s", lanAddrs[0], tok)
348
+ }
349
+ go svc.NotifyStartup(startupEntry)
350
+
193
351
  srv := &http.Server{
194
352
  Handler: svc.Handler(),
195
353
  ReadHeaderTimeout: 10 * time.Second,
196
354
  }
355
+ /* 托管父进程没了就收摊(见 service.go 的 WatchHost)。
356
+ 只在重启链路里才有意义:重启出来的实例是 detach 的,壳的 r.stop() 杀不到它,
357
+ 没有这一条就会留下孤儿占着端口与单实例锁,下次开壳「插件启动即退出」。 */
358
+ svc.WatchHost(func() {
359
+ _ = srv.Close()
360
+ })
197
361
  go func() {
198
362
  stop := make(chan os.Signal, 1)
199
363
  signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
@@ -214,3 +378,17 @@ func randToken() string {
214
378
  }
215
379
  return hex.EncodeToString(b)
216
380
  }
381
+
382
+ // isLoopbackListenAddr 判断监听地址是否只绑回环(127.0.0.1 / localhost / ::1)。
383
+ // 空 host(":9100")= 所有网卡,不算回环。
384
+ func isLoopbackListenAddr(addr string) bool {
385
+ host, _, err := net.SplitHostPort(addr)
386
+ if err != nil || host == "" {
387
+ return false
388
+ }
389
+ if strings.EqualFold(host, "localhost") {
390
+ return true
391
+ }
392
+ ip := net.ParseIP(host)
393
+ return ip != nil && ip.IsLoopback()
394
+ }
@@ -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)