magic-agent 2.4.2 → 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 (156) hide show
  1. package/CLIENT-CONTRACT.md +290 -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 +7 -10
  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 +193 -7
  105. package/internal/client/lan_test.go +110 -0
  106. package/internal/client/quotas_merge_test.go +107 -0
  107. package/internal/client/requirements.go +198 -1
  108. package/internal/client/requirements_shots_test.go +200 -0
  109. package/internal/client/restart.go +134 -0
  110. package/internal/client/restart_test.go +151 -0
  111. package/internal/client/scripts.go +1047 -0
  112. package/internal/client/scripts_decode_other.go +27 -0
  113. package/internal/client/scripts_decode_test.go +125 -0
  114. package/internal/client/scripts_decode_windows.go +206 -0
  115. package/internal/client/scripts_test.go +726 -0
  116. package/internal/client/service.go +322 -18
  117. package/internal/client/service_test.go +51 -0
  118. package/internal/client/suggest.go +105 -2
  119. package/internal/client/suggest_test.go +86 -0
  120. package/internal/client/team_flags.go +25 -11
  121. package/internal/client/team_flags_test.go +39 -0
  122. package/internal/requirements/bootstrap.go +16 -0
  123. package/internal/requirements/consolidate.go +100 -9
  124. package/internal/requirements/doc.go +154 -3
  125. package/internal/requirements/graph.go +138 -0
  126. package/internal/requirements/memory.go +7 -0
  127. package/internal/requirements/modules.go +312 -0
  128. package/internal/requirements/modules_test.go +373 -0
  129. package/internal/requirements/relations.go +155 -0
  130. package/internal/requirements/relations_test.go +231 -0
  131. package/internal/requirements/requirements.go +10 -0
  132. package/internal/requirements/shots.go +418 -0
  133. package/internal/requirements/shots_test.go +387 -0
  134. package/internal/session/session.go +92 -0
  135. package/internal/session/sessionlog.go +23 -0
  136. package/internal/session/sessionlog_read_test.go +39 -0
  137. package/internal/suggest/suggest.go +23 -1
  138. package/internal/team/event.go +3 -1
  139. package/internal/team/execute.go +21 -6
  140. package/internal/team/fallback_route_test.go +80 -0
  141. package/internal/team/planner.go +15 -3
  142. package/internal/team/route.go +89 -17
  143. package/internal/team/team.go +18 -4
  144. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  145. package/npm/apk/version.json +1 -5
  146. package/npm/dist/darwin-arm64/magic-agent +0 -0
  147. package/npm/dist/darwin-arm64/magic-client +0 -0
  148. package/npm/dist/darwin-x64/magic-agent +0 -0
  149. package/npm/dist/darwin-x64/magic-client +0 -0
  150. package/npm/dist/linux-arm64/magic-agent +0 -0
  151. package/npm/dist/linux-arm64/magic-client +0 -0
  152. package/npm/dist/linux-x64/magic-agent +0 -0
  153. package/npm/dist/linux-x64/magic-client +0 -0
  154. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  155. package/npm/dist/win32-x64/magic-client.exe +0 -0
  156. package/package.json +8 -2
@@ -0,0 +1,113 @@
1
+ package agent
2
+
3
+ /* wraplaunch_test.go - 钉住两个对外导出的行为。
4
+ *
5
+ * 这两个导出是「发现 → 脚本」功能的地基:
6
+ * · WrapLaunch —— Windows 上 .cmd/.bat/.ps1 必须包 PowerShell,否则参数被拆坏;
7
+ * 且**退出码要透传**(否则「脚本失败」只显示退出码 1)。
8
+ * · ConfigureProcGroup —— 停止脚本要能整组杀,不留孤儿 worker。
9
+ *
10
+ * 放在这里而不是 internal/client:这两个是 agent 层的能力,
11
+ * 行为边界属于 agent 层(平台差异 + 退出码语义)。
12
+ */
13
+
14
+ import (
15
+ "os/exec"
16
+ "strings"
17
+ "testing"
18
+ )
19
+
20
+ // TestWrapLaunchKeepsArgsOnUnix unix 上:原样返回,不包装、不改写。
21
+ //
22
+ // 脚本参数是用户自己填的定长参数 —— 套上引擎的 emptyStringFlags 白名单
23
+ // 会莫名其妙地改写用户的参数。
24
+ func TestWrapLaunchKeepsArgsOnUnix(t *testing.T) {
25
+ if platformIsWindows() {
26
+ t.Skip("Windows 上 .cmd 会被包装,本断言只对 unix 成立")
27
+ }
28
+ bin, args := WrapLaunch("/opt/bin/run.sh", []string{"--port", "8080", "a b"})
29
+ if bin != "/opt/bin/run.sh" {
30
+ t.Fatalf("bin = %q,应原样保留", bin)
31
+ }
32
+ if len(args) != 3 || args[0] != "--port" || args[1] != "8080" || args[2] != "a b" {
33
+ t.Fatalf("args = %q,应原样保留", args)
34
+ }
35
+ }
36
+
37
+ // TestWrapLaunchNativeExeUntouched Windows 上:原生 .exe 不包装。
38
+ //
39
+ // 包一层 PowerShell 会多一个常驻进程,进程组杀 / 超时 / 信号转发都多一跳。
40
+ func TestWrapLaunchNativeExeUntouched(t *testing.T) {
41
+ if !platformIsWindows() {
42
+ t.Skip("本断言只对 Windows 成立")
43
+ }
44
+ bin, args := WrapLaunch(`C:\tools\run.exe`, []string{"--x"})
45
+ if bin != `C:\tools\run.exe` {
46
+ t.Fatalf("bin = %q,.exe 不该被包装", bin)
47
+ }
48
+ if len(args) != 1 || args[0] != "--x" {
49
+ t.Fatalf("args = %q,应原样保留", args)
50
+ }
51
+ }
52
+
53
+ // TestWrapLaunchWindowsScriptShape Windows 上:脚本被包成 PowerShell,且参数是单引号字面量。
54
+ //
55
+ // 精确断言整个 argv —— 参数顺序/个数错了 PowerShell 会静默执行错的命令。
56
+ func TestWrapLaunchWindowsScriptShape(t *testing.T) {
57
+ if !platformIsWindows() {
58
+ t.Skip("本断言只对 Windows 成立")
59
+ }
60
+ bin, args := WrapLaunch(`D:\tools\build.cmd`, []string{"--port", "8080"})
61
+ if bin != powershellBin() {
62
+ t.Fatalf("bin = %q,want %q", bin, powershellBin())
63
+ }
64
+ // argv 形状:-NoProfile / -ExecutionPolicy / Bypass / -Command / <脚本>
65
+ want := []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"}
66
+ if len(args) != len(want)+1 {
67
+ t.Fatalf("args = %q\nwant 前缀 %q + 一个脚本", args, want)
68
+ }
69
+ for i := range want {
70
+ if args[i] != want[i] {
71
+ t.Fatalf("第 %d 个 = %q,want %q", i, args[i], want[i])
72
+ }
73
+ }
74
+ script := args[len(args)-1]
75
+ // 路径与参数都必须是**单引号字面量**(不是双引号 —— 双引号里的 $ 会被展开)。
76
+ if !strings.Contains(script, `& 'D:\tools\build.cmd'`) {
77
+ t.Fatalf("脚本里没有单引号字面量调用:%q", script)
78
+ }
79
+ if !strings.Contains(script, `'--port' '8080'`) {
80
+ t.Fatalf("参数没有逐个单引号包裹:%q", script)
81
+ }
82
+ }
83
+
84
+ // TestWrapLaunchPropagatesExitCode Windows 上:包装后必须带 `exit $LASTEXITCODE`。
85
+ //
86
+ // 实测:PowerShell 用 `& 'script.cmd'` 调 `exit /b 3` 的批处理时,
87
+ // **PowerShell 自己的退出码是 1,不是 3** —— 退出码被吞了。
88
+ // 少这句,界面上「脚本失败」只显示退出码 1,用户少一条关键排查信息。
89
+ func TestWrapLaunchPropagatesExitCode(t *testing.T) {
90
+ if !platformIsWindows() {
91
+ t.Skip("本断言只对 Windows 成立")
92
+ }
93
+ _, args := WrapLaunch(`D:\tools\build.cmd`, nil)
94
+ script := args[len(args)-1]
95
+ if !strings.Contains(script, "exit $LASTEXITCODE") {
96
+ t.Fatalf("包装后没有透传退出码:%q", script)
97
+ }
98
+ }
99
+
100
+ // TestConfigureProcGroupSetsAttr 配置后进程组要真的建起来。
101
+ //
102
+ // 这条钉的是「停止脚本能整组杀」的前提 —— 没建进程组,
103
+ // unix 上 kill(-pid) 会杀掉调用方自己那一组(灾难性),Windows 上 taskkill /T 无树可杀。
104
+ func TestConfigureProcGroupSetsAttr(t *testing.T) {
105
+ cmd := exec.Command("cmd", "/c", "exit", "0")
106
+ before := cmd.SysProcAttr
107
+ ConfigureProcGroup(cmd)
108
+ if cmd.SysProcAttr == before {
109
+ t.Fatal("配置后 SysProcAttr 没变(进程组没建起来)")
110
+ }
111
+ // nil 不能 panic(调用方可能拿到空 cmd)。
112
+ ConfigureProcGroup(nil)
113
+ }
@@ -44,25 +44,32 @@ import (
44
44
 
45
45
  // askOptions 根命令的全部参数。
46
46
  type askOptions struct {
47
- engine string
48
- model string
49
- system string
50
- prompt string
51
- file string
52
- attach []string // 附件(截图/图片)路径;与提示词一起发给引擎
53
- tools string
54
- timeout time.Duration
55
- retries int
56
- backoff time.Duration
57
- output string
58
- verbose bool
59
- engines bool
60
- contract bool // 输出带版本的契约 envelope(--contract;客户端启动校验用)
61
- noModels bool
62
- login string // 拉起某引擎自己的交互式登录会话(--login <engine>)
47
+ engine string
48
+ model string
49
+ // 备用引擎(2026-10-24):主引擎失败且零产出时自动降级(见 agent/fallback.go)。
50
+ // 空 = 不降级,行为与以前一致。
51
+ fallbackEngine string
52
+ fallbackModel string
53
+ system string
54
+ prompt string
55
+ file string
56
+ attach []string // 附件(截图/图片)路径;与提示词一起发给引擎
57
+ tools string
58
+ timeout time.Duration
59
+ retries int
60
+ backoff time.Duration
61
+ output string
62
+ verbose bool
63
+ engines bool
64
+ contract bool // 输出带版本的契约 envelope(--contract;客户端启动校验用)
65
+ noModels bool
66
+ quotas bool // 只查额度 / 签到(--quotas;不探测引擎、不启动 CLI)
67
+ login string // 拉起某引擎自己的交互式登录会话(--login <engine>)
63
68
  // repairModels 还原被反向同步写坏的桌面端 models.json(--repair-models <engine>):
64
69
  // 2026-09-30 Windows 事故的收尾手段,见 internal/agent/custom_models.go 的事故还原一节。
65
- repairModels string
70
+ repairModels string
71
+ // checkin 领取指定引擎账号的每日积分(--checkin <engine>):只读本机票据调账单接口。
72
+ checkin string
66
73
  events bool // --stream 的事件流带契约版本与行号(--events;客户端消费用)
67
74
  control bool // 从 stdin 读 NDJSON 控制命令(--control;打断 / 收工 / 回审批)
68
75
  jsonOut bool
@@ -136,6 +143,9 @@ type askOptions struct {
136
143
  // 一轮结束后生成 0-3 条「用户下一句」的快捷回复;一次性旁路调用,不续接会话。
137
144
  // 值 = 请求 JSON 的文件路径("-" = stdin)。
138
145
  suggestInput string // --suggest-followups <file|->:{"user":"…","reply":"…"}
146
+ // 建议引擎的主备(2026-10-24):主建议引擎失败时先试这个,再落到内置 llm 兜底。
147
+ suggestFallbackEngine string // --suggest-fallback-engine
148
+ suggestFallbackModel string // --suggest-fallback-model
139
149
 
140
150
  // 四档权限模型(见 internal/agent/permission.go)。仅 claude / codebuddy 接线。
141
151
  permission string // 档位:manual | accept-edits | auto | full
@@ -157,8 +167,9 @@ type askOptions struct {
157
167
  teamParallel int // --team-parallel:并发度(默认 4)
158
168
  teamTaskTO time.Duration // --team-task-timeout:单子任务超时(0 = 按总超时推算)
159
169
  teamRoutes []string // --team-route <kind>=<engine>[/<model>],可重复
170
+ teamFallbacks []string // --team-fallback <kind>=<engine>[/<model>],可重复(主备里的「备」)
160
171
  teamRunDir string // --team-run-dir:覆盖运行目录父级
161
- teamPerm string // --team-permission:子任务权限上限(默认 accept-edits)
172
+ teamPerm string // --team-permission:子任务权限上限(空 = 继承主会话 --permission)
162
173
  teamAdaptive bool // --team-adaptive:执行后按失败 / open 项再规划一轮
163
174
  teamSplit string // --team-split:拆解粒度 coarse | standard | fine
164
175
  teamSplitCode bool // --team-split-code:简单代码也拆成独立子任务
@@ -173,6 +184,7 @@ type askOptions struct {
173
184
  * 所以派发一律用 flagChanged 判断(看「传过没有」而不是「值非空」)。 */
174
185
  requirements string // --requirements [<ws|dir>]:读清单
175
186
  consolidateReqs string // --consolidate-requirements [<ws|dir>]:跑一轮归纳
187
+ reqGraph string // --requirements-graph [<ws|dir>]:会话↔需求关系图谱(只读)
176
188
  reqForce bool // --requirement-force
177
189
  reqAll bool // --requirement-all:全量重放
178
190
  reqEngine string // --requirement-engine(默认 llm)
@@ -180,6 +192,10 @@ type askOptions struct {
180
192
  reqModule string // --requirement-module:只整理这个模块
181
193
  reqTimeout time.Duration // --requirement-timeout
182
194
  reqAuto bool // --requirement-auto:自动归纳,不触发首次初始化
195
+ // 截图相关(--requirement-shot):PNG 走 stdin,不走命令行参数。
196
+ reqShot string // --requirement-shot [<ws|dir>]:给功能点追加一张完成情况截图
197
+ reqShotName string // --requirement-shot-name:功能点名
198
+ reqShotCap string // --requirement-shot-caption:截图标注说明
183
199
  }
184
200
 
185
201
  // newAskOptions 返回带默认值的选项集。
@@ -203,6 +219,10 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
203
219
  f := cmd.PersistentFlags()
204
220
  f.StringVarP(&opts.engine, "engine", "e", "codebuddy", "引擎: codebuddy | codebuddy-ai | claude | trae | llm | codex | openclaw | dsh | arkclaw(默认 codebuddy)")
205
221
  f.StringVarP(&opts.model, "model", "m", "", "模型(空 = 引擎默认:codebuddy=hy3;codebuddy-ai=CLI 自选默认;llm 引擎传 llm CLI 注册名,如 minimax-m3;dsh/arkclaw 不透传)")
222
+ /* 主备(2026-10-24):主引擎失败且**零产出**时自动换备用重跑(额度用尽 / 单模型故障)。
223
+ 只降一级;已经流出正文则不降级(避免两段互相矛盾的答案)。 */
224
+ f.StringVar(&opts.fallbackEngine, "fallback-engine", "", "备用引擎:主引擎失败且零产出时自动降级(缺省 = 不降级;可用值同 --engine)")
225
+ f.StringVar(&opts.fallbackModel, "fallback-model", "", "备用引擎的模型(空 = 该引擎默认;只在 --fallback-engine 非空时生效)")
206
226
  f.StringVarP(&opts.system, "system", "s", "", "系统提示词(空 = 用配置文件 ~/.config/magic-agent/config.json 的 systemPrompt;两者都为空则不注入)。值若是文件路径或 @文件 → 读该文件内容")
207
227
  f.StringVarP(&opts.prompt, "prompt", "p", "", "提示词(值若是文件路径或 @文件 → 读该文件内容作为提示词)")
208
228
  f.StringVarP(&opts.workspace, "workspace", "w", "", "工作目录(workspace):在该目录里执行引擎;codex 走原生 -C,claude/codebuddy/trae/dsh 用子进程 cwd,llm/arkclaw/openclaw 不支持(忽略并提示)")
@@ -222,6 +242,8 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
222
242
  f.BoolVar(&opts.titleForce, "title-force", false, "配合 --summarize-title:已有标题也重新归纳(默认命中缓存就直接返回,省一次 LLM 调用)")
223
243
  f.StringVar(&opts.titleEngine, "title-engine", "llm", "--summarize-title 用哪个引擎归纳(默认 llm:纯文本、不带工具)")
224
244
  f.StringVar(&opts.suggestInput, "suggest-followups", "", "生成下一轮快捷回复:传请求 JSON 的文件路径(\"-\" = stdin):{\"user\":\"…\",\"reply\":\"…\"}。旁路调用,不续接当前会话;引擎走 -e(默认 llm)")
245
+ f.StringVar(&opts.suggestFallbackEngine, "suggest-fallback-engine", "", "配合 --suggest-followups:建议引擎的备用(主建议引擎失败时先试它,再落到内置 llm 兜底)")
246
+ f.StringVar(&opts.suggestFallbackModel, "suggest-fallback-model", "", "配合 --suggest-followups:建议备用引擎的模型(空 = 该引擎默认)")
225
247
  f.Uint64Var(&opts.logAfter, "after", 0, "配合 --session-log:增量续读,只回 seq > N 的事件(另附 count/after/lastSeq/snapshotRequired)")
226
248
  f.Uint64Var(&opts.logBefore, "before", 0, "配合 --session-log:向上翻更早的历史,从文件尾跳过 N 条已画事件再取一页(0 = 最新一页;下一页把返回的 nextBefore 传回来。另附 count/before/nextBefore/firstSeq/more;与 --after 互斥)")
227
249
  f.IntVar(&opts.logLimit, "limit", 200, "配合 --before:每页最多返回多少条事件(默认 200)")
@@ -244,8 +266,10 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
244
266
  f.BoolVar(&opts.engines, "engines", false, "列出支持的引擎、本机 CLI 可用性与各引擎当前支持的模型(JSON 数组;每行含 capabilities / models / workspace / streaming 能力字段;不可用的引擎带 install 一键安装命令)")
245
267
  f.BoolVar(&opts.contract, "contract", false, "输出桌面/移动客户端契约:{\"contractVersion\":N,\"engines\":[...]}(engines 与 --engines 同构,另含 capabilities 静态能力字段)。默认不探测模型(快);要模型写 --no-models=false")
246
268
  f.BoolVar(&opts.noModels, "no-models", false, "配合 --engines / --contract:跳过各引擎的模型探测(只列引擎与可用性,不启动 CLI)")
269
+ f.BoolVar(&opts.quotas, "quotas", false, "只查各引擎账号的额度与每日签到(JSON 数组,字段与 --engines 同名;不探测引擎、不启动任何 CLI,秒级返回)")
247
270
  f.StringVar(&opts.login, "login", "", "拉起指定引擎自己的交互式登录会话(如 codebuddy / codebuddy-ai):自动带上该引擎的账号环境,进去执行 /login 即可;登录态落在该引擎自己的票据上,两个账号互不顶号")
248
271
  f.StringVar(&opts.repairModels, "repair-models", "", "还原被覆盖写坏的桌面端 models.json(如 codebuddy / codebuddy-ai):把对象格式改回桌面端的数组格式,官方模型清单不动;只在形状确实是坏的时候才改,且先留 .broken.bak 备份")
272
+ f.StringVar(&opts.checkin, "checkin", "", "领取指定引擎账号的每日积分(如 codebuddy / codebuddy-ai):用该引擎自己的登录票据调账单接口,输出本次到账积分与连续天数;每天只能领一次")
249
273
  f.BoolVar(&opts.jsonOut, "json", false, "兼容保留:--engines 已默认 JSON,本 flag 不再需要")
250
274
  f.BoolVar(&opts.stream, "stream", false, "流式输出:正文/思考增量实时打到 stdout(text 模式思考走 stderr)")
251
275
  f.BoolVar(&opts.events, "events", false, "配合 --stream:事件流带契约版本与行号(每行加 v / seq,并先发一行 ready),供客户端消费;老消费者不要开(形状与 --stream 不同)")
@@ -271,9 +295,11 @@ func bindTeamFlags(cmd *cobra.Command, opts *askOptions) {
271
295
  f.DurationVar(&opts.teamTaskTO, "team-task-timeout", 0, "配合 --team:单个子任务超时(0 = 总超时的 1/2,上限 20m)")
272
296
  f.StringArrayVar(&opts.teamRoutes, "team-route", nil,
273
297
  "配合 --team:给某类子任务或某个角色指定引擎/模型,形如 image=llm/hunyuan-image-3.0 或 role:coder=claude/sonnet(可重复;类别:plan|text|code|image|video|game;角色用 role:<name>)")
298
+ f.StringArrayVar(&opts.teamFallbacks, "team-fallback", nil,
299
+ "配合 --team:给某类/某个角色指定**备用**引擎/模型,主引擎失败且零产出时自动降级,形如 image=dsh/v3 或 role:coder=codebuddy(可重复;键同 --team-route)")
274
300
  f.StringVar(&opts.teamRunDir, "team-run-dir", "", "配合 --team:覆盖运行目录的父目录(空 = 选了空间落 <空间>/.magic/team,否则落 ~/.magic-agent/team)")
275
- f.StringVar(&opts.teamPerm, "team-permission", string(agent.PermissionAcceptEdits),
276
- "配合 --team:子任务的权限上限(默认 accept-edits —— 子任务不该继承主会话的完全访问)")
301
+ f.StringVar(&opts.teamPerm, "team-permission", "",
302
+ "配合 --team:子任务的权限上限(空 = 继承主会话 --permission;显式给档位可压低子任务权限,如 accept-edits)")
277
303
  f.BoolVar(&opts.teamAdaptive, "team-adaptive", true,
278
304
  "配合 --team:第一轮执行后按失败与未解决项再规划一轮补充任务(默认开;只补一轮、最多 3 个)")
279
305
  f.StringVar(&opts.teamSplit, "team-split", "",
@@ -555,18 +581,29 @@ func runAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
555
581
 
556
582
  // 会话登记 + spawn 钩子:把引擎子进程 pid 记下来,之后任何进程都能
557
583
  // `magic-agent --stop <session_id>` 精确停掉它(见 internal/session)。
558
- h := beginSession(engine.Name(), req, opts.saveRegistry)
584
+ h, berr := beginSession(engine.Name(), req, opts.saveRegistry)
585
+ if berr != nil {
586
+ /* 登记失败 = 这一轮跑不起来(最典型:工作空间锁等超时)。**必须当失败返回**:
587
+ 继续跑的话,引擎会在没有登记的情况下开跑,客户端那边看到的是「在跑」,
588
+ 实际这一轮早该失败 —— 而失败原因只剩 stderr 里一行字。
589
+ ⚠️ 用原 error 返回(不是 reportedError):reportedError 的语义是
590
+ 「已经输出过了」,包上它 Execute 就不再打印,原因会被吞掉。 */
591
+ return berr
592
+ }
559
593
  ctx := withSessionHook(cmd.Context(), h)
560
594
 
561
- runner := &agent.Runner{
562
- Engine: engine,
563
- Timeout: opts.timeout,
564
- Retries: opts.retries,
565
- Backoff: opts.backoff,
566
- Verbose: opts.verbose,
567
- }
568
- resp, err := runner.Run(ctx, req)
595
+ /* 非流式路径同样支持主备降级:主引擎失败且没拿到正文时换备用再跑一次
596
+ (提示写到 stderr —— 这条路径没有事件流可挂 notice)。 */
597
+ resp, fbInfo, err := agent.RunWithFallback(ctx, engine, req, agent.RunOptions{
598
+ Timeout: opts.timeout, Retries: opts.retries, Backoff: opts.backoff, Verbose: opts.verbose,
599
+ }, func(fe agent.FallbackEvent) {
600
+ note := agent.FallbackNoticeText(fe)
601
+ fmt.Fprintf(cmd.ErrOrStderr(), "magic-agent: %s\n", note)
602
+ })
569
603
  if err != nil {
604
+ if fbInfo.Reason != "" {
605
+ fmt.Fprintf(cmd.ErrOrStderr(), "magic-agent: 本轮没有降级:%s\n", fbInfo.Reason)
606
+ }
570
607
  finishSession(h, "", session.StateFailed)
571
608
  attempts := 1 + opts.retries
572
609
  // WriteError 已把失败信息(json envelope / text 行)写到 stderr,
@@ -587,7 +624,10 @@ func runAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
587
624
  // ⚠️ saveRegistry=false 时**不落盘**(用户 2026-10-12:「会话登记也不需要」):
588
625
  // 句柄照常分配(run_id 要给常驻会话的追加入口命名),只是磁盘上不留记录 ——
589
626
  // 代价是 --sessions / --stop 看不见它,--append 只能按 run_id 走约定路径。
590
- func beginSession(engine string, req agent.Request, saveRegistry bool) *session.Handle {
627
+ //
628
+ // 返回的 err 是登记失败的原因(最典型的是**工作空间锁等超时**):
629
+ // 吞掉它的话,调用方只知道「没有会话句柄」,真实原因查不出来。
630
+ func beginSession(engine string, req agent.Request, saveRegistry bool) (*session.Handle, error) {
591
631
  h, err := session.Begin(session.BeginOptions{
592
632
  Engine: engine,
593
633
  Model: req.Model,
@@ -600,10 +640,10 @@ func beginSession(engine string, req agent.Request, saveRegistry bool) *session.
600
640
  NoPersist: !saveRegistry,
601
641
  })
602
642
  if err != nil {
603
- return nil
643
+ return nil, err
604
644
  }
605
645
  setActiveSession(h)
606
- return h
646
+ return h, nil
607
647
  }
608
648
 
609
649
  // withSessionHook 给 ctx 挂上「子进程已启动」回调(h 为空时原样返回)。
@@ -655,8 +695,7 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
655
695
  if err != nil {
656
696
  return err
657
697
  }
658
- streamer := agent.AsStreamer(engine)
659
- if streamer == nil {
698
+ if agent.AsStreamer(engine) == nil {
660
699
  return &usageError{fmt.Errorf("engine %q does not support streaming", engine.Name())}
661
700
  }
662
701
 
@@ -800,6 +839,10 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
800
839
  // runStreamAsk 的 error 分支写(stdout 一条 error 事件),这里只
801
840
  // 保证 text 模式的调用方也**看得见**这一轮没有正常结束。
802
841
  fmt.Fprint(stderr, "✗ 本轮异常收尾:"+ev.Reason+"\n")
842
+ case agent.KindNotice:
843
+ /* 过程提示(max_tokens 截断 / 「工具不在当前工具集」等)。
844
+ 与 thinking、工具事件同走 stderr,stdout 正文保持干净。 */
845
+ fmt.Fprint(stderr, "· "+ev.Text+"\n")
803
846
  }
804
847
  wireLogger(ev)
805
848
  }
@@ -822,7 +865,12 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
822
865
  }
823
866
 
824
867
  // 会话登记 + spawn 钩子(与 runAsk 同一套:--stop 靠它拿到引擎 pid)。
825
- h := beginSession(engine.Name(), req, opts.saveRegistry)
868
+ h, berr := beginSession(engine.Name(), req, opts.saveRegistry)
869
+ if berr != nil {
870
+ /* 同 runAsk:登记失败就别开跑。真实原因(等锁超时等)直接报出去,
871
+ 免得整轮以「跑起来了但列表里没有」的形式收场。 */
872
+ return berr
873
+ }
826
874
  ctx = withSessionHook(ctx, h)
827
875
 
828
876
  /* 把「本次运行 id」也交给存档 writer(2026-10-15):引擎还没吐 session_id 时,
@@ -832,7 +880,11 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
832
880
  · 引擎**整轮都不给 id** 的形态(dsh 的 headless 回退轮)不再白开 --save-history
833
881
  (此前那些事件一直躺在内存 buffer 里,收工时被丢光)。
834
882
  拿到 id 后 writer 自己改名为 <session_id>.jsonl(见 sessionlog.go 的 renameTo)。 */
835
- if sessionLog != nil {
883
+ /* ⚠️ h 可能为 nil:session.Begin 失败(例如工作空间锁等超时)时 beginSession 返回 nil,
884
+ 而 h.RunID() 是值接收者 → nil 解引用直接 panic,整轮以一堆栈回溯收场,
885
+ 真实原因(等锁超时)反而被淹没。beginSession 的错误本来就该由调用方当失败处理,
886
+ 这里只保证不崩:没有 run id 就不设,存档 writer 继续用会话 id 命名。 */
887
+ if sessionLog != nil && h != nil {
836
888
  sessionLog.setRunID(h.RunID())
837
889
  }
838
890
 
@@ -870,6 +922,16 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
870
922
  onEvent = ka.wrapOnEvent(onEvent)
871
923
  }
872
924
 
925
+ /* 控制应答通道(原地回审批,2026-10-25):计划审批 ExitPlanMode / manual 档的工具授权
926
+ 都是引擎发 control_request **原地等 stdin 上的 control_response** —— 不写回去整轮就挂着
927
+ (用户报障:「没看到审批」+ 一直转圈)。这条路与常驻会话无关:Windows 没有追加入口,
928
+ 但审批照样要能回,所以独立建通道(引擎侧会因此把 stdin 保持到本轮收尾,见 Request.Reply)。 */
929
+ var controlReplies chan string
930
+ if opts.control && agent.AskAnswerControlOf(engine.Name()) {
931
+ controlReplies = make(chan string, 8)
932
+ req.Reply = controlReplies
933
+ }
934
+
873
935
  /* 控制通道:给调用方一条回传通道(打断 / 收工 / 回审批)。
874
936
  起在常驻会话之后 —— answer 依赖 ka。 */
875
937
  var ctl *controlSession
@@ -884,6 +946,27 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
884
946
  cancelAll()
885
947
  },
886
948
  answer: func(text string) bool { return ka != nil && ka.Push(text) },
949
+ answerAsk: func(requestID string, allowed bool, message, toolName string) bool {
950
+ if controlReplies == nil {
951
+ return false
952
+ }
953
+ raw, eerr := agent.EncodeAskControlResponse(engine.Name(), &agent.AskRequest{
954
+ Engine: engine.Name(),
955
+ Kind: agent.AskKindPermission,
956
+ Source: agent.AskSourceControlRequest,
957
+ RequestID: requestID,
958
+ ToolName: toolName,
959
+ }, agent.AskAnswer{Denied: !allowed, Message: message})
960
+ if eerr != nil {
961
+ return false
962
+ }
963
+ select {
964
+ case controlReplies <- string(raw):
965
+ return true
966
+ default:
967
+ return false // 缓冲区满 = 引擎侧没在读,如实失败
968
+ }
969
+ },
887
970
  })
888
971
  defer ctl.Close()
889
972
  }
@@ -961,6 +1044,18 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
961
1044
  }
962
1045
  }
963
1046
  isCommand := isSlashCommand(lastUserMsg)
1047
+ /* core 自己合成的帧(command / fallback)统一走这里:JSON 形态落盘 + 外发,
1048
+ text 形态只落 sessionlog(stdout 是正文通道)。 */
1049
+ emitCoreFrame := func(ev agent.StreamEvent, textOnly bool) {
1050
+ if format == agent.FormatJSON {
1051
+ _ = sink.stream(ev)
1052
+ wireLogger(ev)
1053
+ return
1054
+ }
1055
+ if textOnly && sessionLog != nil {
1056
+ sessionLog.push(sessionEvent{Kind: string(ev.Kind), Text: ev.Text})
1057
+ }
1058
+ }
964
1059
  emitCommand := func(status agent.StreamStatus) {
965
1060
  if !isCommand {
966
1061
  return
@@ -977,8 +1072,26 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
977
1072
  }
978
1073
  emitCommand(agent.StatusRunning)
979
1074
 
980
- res, err := streamer.Stream(ctx, req, onEvent)
1075
+ /* 降级那一帧在 agent 层发出(走 onEvent),这里同时落 sessionlog —— 历史回放要能
1076
+ 看到「这条回答是备用给的」。两个动作都放 onEvent 里:text 形态只落盘、JSON 形态
1077
+ 落盘 + 外发(emitCoreFrame 的分支)。 */
1078
+ if sessionLog != nil {
1079
+ inner := onEvent
1080
+ onEvent = func(ev agent.StreamEvent) {
1081
+ if ev.Kind == agent.KindFallback {
1082
+ sessionLog.push(sessionEvent{Kind: string(agent.KindFallback), Text: ev.Text})
1083
+ }
1084
+ inner(ev)
1085
+ }
1086
+ }
1087
+ res, fbInfo, err := agent.StreamWithFallback(ctx, engine, req, onEvent)
1088
+ /* 主备结论(2026-10-26):**没降级的原因**补一条 notice —— 用户配过备用却看到一个
1089
+ 普通的失败时,得能分辨「配错 / 能力不支持 / 主引擎已经出过正文」。
1090
+ 只在失败路径报:成功轮不打扰(配了备用、主引擎也跑好了,这是常态)。 */
981
1091
  interrupted := ctl != nil && ctl.WasInterrupted()
1092
+ if err != nil && !interrupted && fbInfo.Reason != "" {
1093
+ emitCoreFrame(agent.StreamEvent{Kind: agent.KindNotice, Text: "本轮没有降级:" + fbInfo.Reason}, true)
1094
+ }
982
1095
  if interrupted {
983
1096
  /* 打断的应答放在这里、而不是命令到达的那一刻:interrupt 的真实含义是
984
1097
  「这一轮真的结束了」。失败链本身就是 context canceled,不必再包一层。 */
@@ -1360,6 +1473,16 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
1360
1473
  t := opts.temperature
1361
1474
  req.Temperature = &t
1362
1475
  }
1476
+ /* 备用引擎(2026-10-24):指向不存在的引擎属于**配置错**,开跑前就报,
1477
+ 别等主引擎失败后才发现降级目标也没有。与主引擎同名 = 没配(不重复跑一次)。 */
1478
+ if fb := strings.TrimSpace(opts.fallbackEngine); fb != "" && !strings.EqualFold(fb, engine.Name()) {
1479
+ if agent.Lookup(fb) == nil {
1480
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf(
1481
+ "--fallback-engine %q 不存在(可用引擎见 magic-agent --engines)", fb)}
1482
+ }
1483
+ req.FallbackEngine = fb
1484
+ req.FallbackModel = strings.TrimSpace(opts.fallbackModel)
1485
+ }
1363
1486
  if s := strings.TrimSpace(opts.jsonSchema); s != "" {
1364
1487
  schema, err := parseJSONSchemaInline(s)
1365
1488
  if err != nil {
@@ -641,7 +641,7 @@ func unregisterLast() {}
641
641
  // ── --engines flag ───────────────────────────────────────────
642
642
  //
643
643
  // 注意:以下用例一律带 --no-models —— 模型探测会真的启动本机 CLI
644
- //(trae-cli / llm / codex / openclaw / codebuddy),单元测试不该依赖它们。
644
+ //(traex / llm / codex / openclaw / codebuddy),单元测试不该依赖它们。
645
645
  // models 字段本身的行为由 TestEnginesFlagIncludesModels 用假引擎 + 假探测覆盖。
646
646
 
647
647
  // --engines 默认即输出 JSON 数组(含引擎名与是否可用),无需 --json。
@@ -0,0 +1,77 @@
1
+ package cli
2
+
3
+ // checkin.go - `--checkin <engine>`:领取该引擎账号的每日积分。
4
+ //
5
+ // 与 --login 同源:读该引擎自己的登录票据(codebuddy-ai 读隔离目录),
6
+ // 调 CodeBuddy 账单接口 POST /billing/meter/daily-checkin。每天只能领一次,
7
+ // 重复领取返回服务端业务错误(ok:false + message),不算命令失败(exit 0)。
8
+
9
+ import (
10
+ "context"
11
+ "fmt"
12
+ "strings"
13
+ "time"
14
+
15
+ "github.com/darren/magic-agent/internal/agent"
16
+ "github.com/spf13/cobra"
17
+ )
18
+
19
+ // checkinOutput `--checkin` 的输出形状(客户端 / 脚本都消费它)。
20
+ type checkinOutput struct {
21
+ Engine string `json:"engine"`
22
+ // OK 本次是否真的领到(false 时看 message:已领 / 活动未开 / 未登录)。
23
+ OK bool `json:"ok"`
24
+ // Credit 本次到账积分(字符串,可能为空)。
25
+ Credit string `json:"credit,omitempty"`
26
+ // StreakDays 连续签到天数。
27
+ StreakDays int `json:"streak_days,omitempty"`
28
+ // CheckedToday 领取后恒为 true;失败时保留服务端语义(此处为 false)。
29
+ CheckedToday bool `json:"checked_today"`
30
+ // Message 业务失败原因。
31
+ Message string `json:"message,omitempty"`
32
+ // CreditLeft 领取后的账号剩余积分(能查到才给)。
33
+ CreditLeft string `json:"credit_left,omitempty"`
34
+ }
35
+
36
+ // runCheckin 处理 `--checkin <engine>`。
37
+ func runCheckin(cmd *cobra.Command, opts *askOptions) error {
38
+ name := strings.TrimSpace(opts.checkin)
39
+ if name == "" {
40
+ return &usageError{fmt.Errorf("--checkin 需要引擎名:magic-agent --checkin <engine>(当前支持 codebuddy / codebuddy-ai)")}
41
+ }
42
+ eng := agent.Lookup(name)
43
+ if eng == nil {
44
+ return &usageError{fmt.Errorf("未知引擎 %q(当前支持 codebuddy / codebuddy-ai)", name)}
45
+ }
46
+ c, ok := eng.(agent.Checkiner)
47
+ if !ok {
48
+ return &usageError{fmt.Errorf("引擎 %s 不支持 --checkin:每日积分只有 codebuddy / codebuddy-ai 有", eng.Name())}
49
+ }
50
+
51
+ ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
52
+ defer cancel()
53
+
54
+ out := checkinOutput{Engine: eng.Name()}
55
+ info, err := c.ClaimCheckin(ctx)
56
+ if err != nil {
57
+ out.OK = false
58
+ out.Message = err.Error()
59
+ if info.Message != "" {
60
+ out.Message = info.Message
61
+ }
62
+ // 「今天已签到」这类业务失败也带上真实状态,客户端能把「领取」翻成「今日已领」。
63
+ out.CheckedToday = info.CheckedToday
64
+ out.StreakDays = info.StreakDays
65
+ return printJSON(cmd.OutOrStdout(), out)
66
+ }
67
+ out.OK = true
68
+ out.Credit = info.Credit
69
+ out.StreakDays = info.StreakDays
70
+ out.CheckedToday = true
71
+
72
+ // 领取后顺手刷新剩余额度(查不到就省略;不让它影响领取结果)。
73
+ if cl, ok := eng.(agent.CreditLister); ok {
74
+ out.CreditLeft = cl.CreditLeft(ctx)
75
+ }
76
+ return printJSON(cmd.OutOrStdout(), out)
77
+ }
@@ -36,7 +36,8 @@ func clientSubcommand() *cobra.Command {
36
36
  Short: "客户端插件(magic-client):后台启动 / 停止 / 看状态,手机据此连接",
37
37
  Long: `把参数原样交给 magic-client(客户端插件,随 npm 包一起安装)。
38
38
 
39
- magic-agent client start --lan # 后台常驻,打印手机可用的地址与配对二维码
39
+ magic-agent client start # 后台常驻(默认绑 0.0.0.0:9100),打印手机地址与配对二维码
40
+ magic-agent client start --local # 只绑 127.0.0.1,手机连不上
40
41
  magic-agent client status # 在不在、端口、手机地址、健康检查
41
42
  magic-agent client stop # 停掉(本来没跑也算成功)
42
43
 
@@ -45,7 +46,7 @@ func clientSubcommand() *cobra.Command {
45
46
  状态文件(~/.magic-client/client.json),三个动作围绕它工作,令牌跨重启稳定
46
47
  (存 ~/.magic-client/token,删掉它才会换)。
47
48
 
48
- ⚠️ --lan 只绑局域网:手机要和这台电脑在**同一个 Wi-Fi**。跨网访问不在本命令范围内。
49
+ ⚠️ 默认绑局域网(0.0.0.0:9100):手机要和这台电脑在**同一个 Wi-Fi**。跨网访问不在本命令范围内。
49
50
 
50
51
  本命令不在 core 里实现,只负责转发(桌面 / 移动端是可选插件,core 不依赖它)。
51
52
  详细 flag 见 magic-client start --help。`,