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.
- package/CLIENT-CONTRACT.md +284 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +6 -0
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- 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
|
-
|
|
82
|
-
--
|
|
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",
|
|
91
|
-
|
|
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
|
|
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(" 局域网
|
|
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",
|
package/cmd/magic-client/main.go
CHANGED
|
@@ -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
|
|
12
|
+
// magic-client start # 后台常驻(关掉终端也还在),默认手机可连
|
|
13
|
+
// magic-client start --local # 只绑 127.0.0.1(不给局域网)
|
|
13
14
|
// magic-client status | stop # 看状态 / 停掉后台实例
|
|
14
15
|
//
|
|
15
|
-
//
|
|
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", "", "监听地址(默认
|
|
58
|
-
lan = fs.Bool("lan",
|
|
59
|
-
|
|
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
|
|
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
|
|
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:
|
|
111
|
+
Lan: lanEnabled,
|
|
93
112
|
BrowseUA: *browseUA,
|
|
94
|
-
/*
|
|
113
|
+
/* 局域网 = 「允许来自任意 IP 的页面访问」:再按 loopback 卡 CORS Origin
|
|
95
114
|
等于把手机 / 别的设备的页面全判成跨域(用户侧就是「只能本地访问」)。
|
|
96
115
|
显式 --allow-any-origin 也可以单独打开(绑回环但要给容器页面用)。 */
|
|
97
|
-
AllowAnyOrigin: *anyOrigin ||
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/internal/agent/ask.go
CHANGED
|
@@ -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
|
-
// 假
|
|
425
|
-
cli := writeFakeCLI(t, "trae-cli",
|
|
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
|
}
|
package/internal/agent/claude.go
CHANGED
|
@@ -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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
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)
|