magic-agent 2.4.3 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CLIENT-CONTRACT.md +284 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +6 -0
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +3 -3
  105. package/internal/client/quotas_merge_test.go +107 -0
  106. package/internal/client/requirements.go +198 -1
  107. package/internal/client/requirements_shots_test.go +200 -0
  108. package/internal/client/restart.go +134 -0
  109. package/internal/client/restart_test.go +151 -0
  110. package/internal/client/scripts.go +1047 -0
  111. package/internal/client/scripts_decode_other.go +27 -0
  112. package/internal/client/scripts_decode_test.go +125 -0
  113. package/internal/client/scripts_decode_windows.go +206 -0
  114. package/internal/client/scripts_test.go +726 -0
  115. package/internal/client/service.go +322 -18
  116. package/internal/client/service_test.go +51 -0
  117. package/internal/client/suggest.go +105 -2
  118. package/internal/client/suggest_test.go +86 -0
  119. package/internal/client/team_flags.go +25 -11
  120. package/internal/client/team_flags_test.go +39 -0
  121. package/internal/requirements/bootstrap.go +16 -0
  122. package/internal/requirements/consolidate.go +100 -9
  123. package/internal/requirements/doc.go +154 -3
  124. package/internal/requirements/graph.go +138 -0
  125. package/internal/requirements/memory.go +7 -0
  126. package/internal/requirements/modules.go +312 -0
  127. package/internal/requirements/modules_test.go +373 -0
  128. package/internal/requirements/relations.go +155 -0
  129. package/internal/requirements/relations_test.go +231 -0
  130. package/internal/requirements/requirements.go +10 -0
  131. package/internal/requirements/shots.go +418 -0
  132. package/internal/requirements/shots_test.go +387 -0
  133. package/internal/session/session.go +92 -0
  134. package/internal/suggest/suggest.go +23 -1
  135. package/internal/team/event.go +3 -1
  136. package/internal/team/execute.go +21 -6
  137. package/internal/team/fallback_route_test.go +80 -0
  138. package/internal/team/planner.go +15 -3
  139. package/internal/team/route.go +89 -17
  140. package/internal/team/team.go +18 -4
  141. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  142. package/npm/apk/version.json +1 -5
  143. package/npm/dist/darwin-arm64/magic-agent +0 -0
  144. package/npm/dist/darwin-arm64/magic-client +0 -0
  145. package/npm/dist/darwin-x64/magic-agent +0 -0
  146. package/npm/dist/darwin-x64/magic-client +0 -0
  147. package/npm/dist/linux-arm64/magic-agent +0 -0
  148. package/npm/dist/linux-arm64/magic-client +0 -0
  149. package/npm/dist/linux-x64/magic-agent +0 -0
  150. package/npm/dist/linux-x64/magic-client +0 -0
  151. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  152. package/npm/dist/win32-x64/magic-client.exe +0 -0
  153. package/package.json +8 -2
@@ -25,12 +25,17 @@ import (
25
25
  * {"op":"answer","text":"..."} → 把答案作为后续 user 消息追进常驻会话
26
26
  * → {"type":"answered"};通道不可用 →
27
27
  * {"type":"control_error","error":"..."}
28
+ * {"op":"ask_answer","request_id":"perm_…","allowed":true|false,"message":"…","tool_name":"…"}
29
+ * → **原地**回应引擎的 control_request(计划审批 /
30
+ * 工具授权),走子进程 stdin 写一行 control_response
31
+ * → {"type":"ask_answered",…};无落点(引擎不支持 /
32
+ * 本轮已结束 / stdin 已关)→ control_error
28
33
  * 其它 → {"type":"control_error","op":"..."}
29
34
  *
30
- * ⚠️ answer 为什么走「后续 user 消息」而不是原地作答:headless 下 claude 在模型提问后
31
- * 会**立刻自行拒绝**(见 agent.KindAsk 的注释),唯一能把答案真正喂给模型的通道就是
32
- * 下一轮 user 消息 —— 也就是常驻会话的追加通道,与 `--append` 是同一条路。
33
- * 因此 answer 只在常驻会话生效时可用;不可用时明确回一条 control_error,不静默丢弃。
35
+ * ⚠️ answer 与 ask_answer 是**两件事**,别合并:
36
+ * · answer 是「下一轮用户消息」(headless 下模型提问后 CLI 会自行拒绝,答案只能这样补);
37
+ * · ask_answer 是「这一轮正阻塞等着的裁决」(ExitPlanMode 计划审批、manual 档的工具授权),
38
+ * 必须写回 CLI 的 control_response —— 当 user 消息发进去它不会读,整轮继续挂着。
34
39
  */
35
40
 
36
41
  // controlCommand 一条控制命令。
@@ -38,6 +43,14 @@ type controlCommand struct {
38
43
  Op string `json:"op"`
39
44
  Reason string `json:"reason,omitempty"`
40
45
  Text string `json:"text,omitempty"`
46
+ // RequestID 引擎 control_request 的 id(ask_answer 必填;原样带回 control_response)。
47
+ RequestID string `json:"request_id,omitempty"`
48
+ // Allowed 裁决结果(ask_answer 必填;nil = 没给 → 明确报错,不猜)。
49
+ Allowed *bool `json:"allowed,omitempty"`
50
+ // Message 拒绝理由 / 想回给引擎的话。
51
+ Message string `json:"message,omitempty"`
52
+ // ToolName 待裁决的工具名(只用于回执与排障,编码时不参与)。
53
+ ToolName string `json:"tool_name,omitempty"`
41
54
  }
42
55
 
43
56
  // controlHooks 控制命令的落地动作。
@@ -48,6 +61,9 @@ type controlHooks struct {
48
61
  stop func()
49
62
  // answer 把答案追进常驻会话;返回 false 表示通道此刻不可用。
50
63
  answer func(text string) bool
64
+ // answerAsk 原地回应一条 control_request;返回 false 表示写不回去
65
+ //(引擎不支持 / stdin 已关 / 这一轮已结束),调用方据此回 control_error。
66
+ answerAsk func(requestID string, allowed bool, message, toolName string) bool
51
67
  }
52
68
 
53
69
  // controlSession 一次控制通道的生命周期。
@@ -129,10 +145,35 @@ func (s *controlSession) dispatch(c controlCommand) {
129
145
  }
130
146
  _ = s.sink.notice("answered", nil)
131
147
 
148
+ case "ask_answer":
149
+ if strings.TrimSpace(c.RequestID) == "" {
150
+ _ = s.sink.notice("control_error", map[string]any{
151
+ "op": "ask_answer", "error": "ask_answer 需要 request_id(引擎 control_request 的 id)",
152
+ })
153
+ return
154
+ }
155
+ if c.Allowed == nil {
156
+ _ = s.sink.notice("control_error", map[string]any{
157
+ "op": "ask_answer", "error": "ask_answer 需要 allowed(true = 允许,false = 拒绝)",
158
+ })
159
+ return
160
+ }
161
+ if s.hooks.answerAsk == nil || !s.hooks.answerAsk(c.RequestID, *c.Allowed, c.Message, c.ToolName) {
162
+ _ = s.sink.notice("control_error", map[string]any{
163
+ "op": "ask_answer",
164
+ "error": "这一轮没法原地回审批:引擎不支持控制应答,或 stdin 已关(本轮已结束)。" +
165
+ "请改用 answer(会把答复作为下一轮用户消息发进会话)",
166
+ })
167
+ return
168
+ }
169
+ _ = s.sink.notice("ask_answered", map[string]any{
170
+ "request_id": c.RequestID, "allowed": *c.Allowed, "tool_name": c.ToolName,
171
+ })
172
+
132
173
  default:
133
174
  _ = s.sink.notice("control_error", map[string]any{
134
175
  "op": c.Op,
135
- "error": "未知 op:支持 ping / interrupt / stop / answer",
176
+ "error": "未知 op:支持 ping / interrupt / stop / answer / ask_answer",
136
177
  })
137
178
  }
138
179
  }
@@ -129,6 +129,71 @@ func TestControlAnswer(t *testing.T) {
129
129
  })
130
130
  }
131
131
 
132
+ // TestControlAskAnswer 原地回审批(ask_answer):参数校验、成败回执,以及
133
+ // 它和 answer(下一轮用户消息)是两条不同的路 —— 别把审批偷偷降级成追加。
134
+ func TestControlAskAnswer(t *testing.T) {
135
+ t.Run("缺 request_id", func(t *testing.T) {
136
+ w := newLineWriter()
137
+ ctl := startControlReader(strings.NewReader(`{"op":"ask_answer","allowed":true}`+"\n"),
138
+ newEventSink(w, "codebuddy", true), controlHooks{})
139
+ defer ctl.Close()
140
+ if ln := w.wait(t, 1)[0]; !strings.Contains(ln, "control_error") || !strings.Contains(ln, "request_id") {
141
+ t.Errorf("应回带 request_id 的 control_error,实际 %s", ln)
142
+ }
143
+ })
144
+
145
+ t.Run("缺 allowed", func(t *testing.T) {
146
+ w := newLineWriter()
147
+ ctl := startControlReader(strings.NewReader(`{"op":"ask_answer","request_id":"perm_1"}`+"\n"),
148
+ newEventSink(w, "codebuddy", true), controlHooks{})
149
+ defer ctl.Close()
150
+ if ln := w.wait(t, 1)[0]; !strings.Contains(ln, "control_error") || !strings.Contains(ln, "allowed") {
151
+ t.Errorf("应回带 allowed 的 control_error,实际 %s", ln)
152
+ }
153
+ })
154
+
155
+ t.Run("写不回去", func(t *testing.T) {
156
+ w := newLineWriter()
157
+ ctl := startControlReader(strings.NewReader(`{"op":"ask_answer","request_id":"perm_1","allowed":true}`+"\n"),
158
+ newEventSink(w, "codebuddy", true), controlHooks{})
159
+ defer ctl.Close()
160
+ if ln := w.wait(t, 1)[0]; !strings.Contains(ln, "control_error") {
161
+ t.Errorf("没有 answerAsk 时应回 control_error,实际 %s", ln)
162
+ }
163
+ })
164
+
165
+ t.Run("可用", func(t *testing.T) {
166
+ w := newLineWriter()
167
+ var gotReq atomic.Value
168
+ var gotAllowed atomic.Bool
169
+ var gotMsg atomic.Value
170
+ ctl := startControlReader(strings.NewReader(
171
+ `{"op":"ask_answer","request_id":"perm_9","allowed":false,"message":"先改改","tool_name":"ExitPlanMode"}`+"\n"),
172
+ newEventSink(w, "codebuddy", true), controlHooks{
173
+ answerAsk: func(requestID string, allowed bool, message, toolName string) bool {
174
+ gotReq.Store(requestID + "|" + toolName)
175
+ gotAllowed.Store(allowed)
176
+ gotMsg.Store(message)
177
+ return true
178
+ },
179
+ })
180
+ defer ctl.Close()
181
+ ln := w.wait(t, 1)[0]
182
+ if !strings.Contains(ln, `"type":"ask_answered"`) || !strings.Contains(ln, `"request_id":"perm_9"`) {
183
+ t.Errorf("应回 ask_answered 并带回 request_id,实际 %s", ln)
184
+ }
185
+ if v, _ := gotReq.Load().(string); v != "perm_9|ExitPlanMode" {
186
+ t.Errorf("hook 收到的 request_id/tool_name = %q", v)
187
+ }
188
+ if gotAllowed.Load() {
189
+ t.Error("allowed 应为 false")
190
+ }
191
+ if v, _ := gotMsg.Load().(string); v != "先改改" {
192
+ t.Errorf("message = %q want 先改改", v)
193
+ }
194
+ })
195
+ }
196
+
132
197
  // TestControlInterruptAndStop interrupt 不立即回应答(应答在本轮真的结束时发),
133
198
  // stop 立即回;两个标志位都要落。
134
199
  func TestControlInterruptAndStop(t *testing.T) {
@@ -202,6 +202,9 @@ func streamEventPayload(ev agent.StreamEvent) any {
202
202
  session_id)。omitempty:非团队事件不带它,老消费者字段集不变。 */
203
203
  SubSessionID string `json:"sub_session_id,omitempty"`
204
204
  Ask *agent.AskRequest `json:"ask,omitempty"`
205
+ /* 主备降级(2026-10-26,仅 type=fallback):from/to 两端 + 是否换了引擎 + 原因。
206
+ omitempty:其它事件不带它,老消费者字段集不变。 */
207
+ Fallback *agent.FallbackEvent `json:"fallback,omitempty"`
205
208
  /* 状态收敛四元组(2026-10-02,对齐 agents-anywhere 的 timeline item)。
206
209
  消费方按 (item_id, item_revision) 覆盖式 upsert 即可:
207
210
  同一 item 的多个 partial 帧 + 最后一个 final 帧,
@@ -228,6 +231,7 @@ func streamEventPayload(ev agent.StreamEvent) any {
228
231
  SessionID: ev.SessionID,
229
232
  SubSessionID: ev.SubSessionID,
230
233
  Ask: ev.Ask,
234
+ Fallback: ev.Fallback,
231
235
  ItemID: ev.ItemID,
232
236
  ItemRevision: ev.ItemRevision,
233
237
  Status: string(ev.Status),
@@ -0,0 +1,125 @@
1
+ package cli
2
+
3
+ // quotas.go - `--quotas`:只查「账号额度 / 模型套餐额度 / 每日签到」的轻量命令。
4
+ //
5
+ // 为什么单独一条:`--engines` 为了拿模型清单要真的启动各引擎的 CLI(秒级),客户端
6
+ // 因此把它整份缓存起来;但额度是会变的(用一次就掉、换个 key 就是另一个账号)。
7
+ // 缓存整份清单会让额度跟着一起冻住 —— 用户看到「额度不刷新」。
8
+ //
9
+ // 这条命令**不探测引擎、不启动任何 CLI**:只读本机票据 / models.json,再发一两条
10
+ // 只读 HTTP 请求(codebuddy 系 2 条、llm 的 MiniMax 各 key 1 条),秒级返回。
11
+ // 客户端把它合并进缓存清单,就能做到「模型清单用缓存、额度每次现查」。
12
+ //
13
+ // 输出:`[{engine, credit_left?, credit_suffix?, model_quota?, checkin_today?, checkin_credit?}]`
14
+ // (字段与 --engines 同名同义;查不到的字段省略,调用方据此保留旧值)。
15
+
16
+ import (
17
+ "context"
18
+ "sync"
19
+ "time"
20
+
21
+ "github.com/darren/magic-agent/internal/agent"
22
+ "github.com/spf13/cobra"
23
+ )
24
+
25
+ // quotaRow `--quotas` 的一行;字段语义与 --engines 对应字段完全一致。
26
+ type quotaRow struct {
27
+ Engine string `json:"engine"`
28
+ CreditLeft string `json:"credit_left,omitempty"`
29
+ CreditSuffix string `json:"credit_suffix,omitempty"`
30
+ ModelQuota map[string]agent.QuotaWindows `json:"model_quota,omitempty"`
31
+ CheckinToday *bool `json:"checkin_today,omitempty"`
32
+ CheckinCredit string `json:"checkin_credit,omitempty"`
33
+ // LoggedIn 该账号槽位是否已登录(只读票据文件,无网络)。登录 / 登出后
34
+ // 下一次打开选择器就能看到行数变化,不用等引擎清单缓存过期。
35
+ LoggedIn *bool `json:"logged_in,omitempty"`
36
+ // Account 登录的账号昵称(票据里的 account.nickname;未登录 / 无昵称时省略)。
37
+ Account string `json:"account,omitempty"`
38
+ }
39
+
40
+ // quotaProbeTimeout 整条命令的上限:额度查询本身各自有 8s 超时,这里再兜一层,
41
+ // 保证客户端不会因为一个挂住的账单接口把选择器拖到几十秒。
42
+ const quotaProbeTimeout = 6 * time.Second
43
+
44
+ // runQuotas 处理 `--quotas`。
45
+ func runQuotas(cmd *cobra.Command) error {
46
+ ctx, cancel := context.WithTimeout(cmd.Context(), quotaProbeTimeout)
47
+ defer cancel()
48
+
49
+ engines := agent.Engines()
50
+ rows := make([]quotaRow, 0, len(engines))
51
+ var mu sync.Mutex
52
+ var wg sync.WaitGroup
53
+ for _, e := range engines {
54
+ cl, hasCredit := e.(agent.CreditLister)
55
+ mq, hasQuota := e.(agent.ModelQuotaLister)
56
+ ck, hasCheckin := e.(agent.Checkiner)
57
+ if !hasCredit && !hasQuota && !hasCheckin {
58
+ continue
59
+ }
60
+ unit := ""
61
+ if u, ok := e.(agent.CreditUnitLister); ok {
62
+ unit = u.CreditUnit()
63
+ }
64
+ i := len(rows)
65
+ rows = append(rows, quotaRow{Engine: e.Name()})
66
+ // 登录态只读票据文件,同步拿(不发网络);未登录的槽位客户端据此隐藏。
67
+ if lc, okl := e.(agent.LoginChecker); okl {
68
+ loggedIn := lc.LoggedIn()
69
+ rows[i].LoggedIn = &loggedIn
70
+ }
71
+ if al, okl := e.(agent.AccountLister); okl {
72
+ rows[i].Account = al.AccountName()
73
+ }
74
+
75
+ if hasCredit {
76
+ wg.Add(1)
77
+ go func(i int, l agent.CreditLister) {
78
+ defer wg.Done()
79
+ left := l.CreditLeft(ctx)
80
+ if left == "" {
81
+ return
82
+ }
83
+ mu.Lock()
84
+ rows[i].CreditLeft = left
85
+ if unit != "" {
86
+ rows[i].CreditSuffix = unit
87
+ }
88
+ mu.Unlock()
89
+ }(i, cl)
90
+ }
91
+ if hasQuota {
92
+ wg.Add(1)
93
+ go func(i int, l agent.ModelQuotaLister) {
94
+ defer wg.Done()
95
+ q := l.ModelQuota(ctx)
96
+ if len(q) == 0 {
97
+ return
98
+ }
99
+ mu.Lock()
100
+ rows[i].ModelQuota = q
101
+ if unit != "" {
102
+ rows[i].CreditSuffix = unit
103
+ }
104
+ mu.Unlock()
105
+ }(i, mq)
106
+ }
107
+ if hasCheckin {
108
+ wg.Add(1)
109
+ go func(i int, c agent.Checkiner) {
110
+ defer wg.Done()
111
+ info, known := c.CheckinStatus(ctx)
112
+ if !known {
113
+ return
114
+ }
115
+ checked := info.CheckedToday
116
+ mu.Lock()
117
+ rows[i].CheckinToday = &checked
118
+ rows[i].CheckinCredit = info.DailyCredit
119
+ mu.Unlock()
120
+ }(i, ck)
121
+ }
122
+ }
123
+ wg.Wait()
124
+ return printJSON(cmd.OutOrStdout(), rows)
125
+ }
@@ -22,6 +22,7 @@ package cli
22
22
 
23
23
  import (
24
24
  "fmt"
25
+ "io"
25
26
  "strings"
26
27
 
27
28
  "github.com/spf13/cobra"
@@ -69,6 +70,23 @@ func runRequirements(cmd *cobra.Command, opts *askOptions) error {
69
70
  return printJSON(cmd.OutOrStdout(), out)
70
71
  }
71
72
 
73
+ // runRequirementsGraph 会话↔需求关系图谱:`--requirements-graph all|<ws_id|目录>`。
74
+ //
75
+ // 只读(与 --requirements 同族,客户端每刷一次都调,绝不能触发 LLM)。
76
+ // 聚合 relations.jsonl + 文档目录 → {ok,root,workspaces:[{id,name,dir,nodes,edges}]}。
77
+ func runRequirementsGraph(cmd *cobra.Command, opts *askOptions) error {
78
+ where := strings.TrimSpace(opts.reqGraph)
79
+ if where == "*" {
80
+ where = "all"
81
+ }
82
+ b, err := requirements.BuildGraph(where)
83
+ if err != nil {
84
+ return fmt.Errorf("--requirements-graph %s: %w", where, err)
85
+ }
86
+ _, err = cmd.OutOrStdout().Write(append(b, '\n'))
87
+ return err
88
+ }
89
+
72
90
  // runConsolidateRequirements 立即归纳一轮:`--consolidate-requirements <ws_id|目录|.>`。
73
91
  //
74
92
  // `.` = 当前目录(与 `-w` 的默认语义一致:用户站在哪个项目里,整理的就是哪个项目)。
@@ -103,6 +121,35 @@ func runConsolidateRequirements(cmd *cobra.Command, opts *askOptions) error {
103
121
  return printJSON(cmd.OutOrStdout(), res)
104
122
  }
105
123
 
124
+ // runRequirementShot 给一个功能点追加一张「页面完成情况」截图:
125
+ // `--requirement-shot [<ws|dir>]` + `--requirement-name` / `--requirement-caption`。
126
+ //
127
+ // PNG 内容走 **stdin**(不走命令行参数):图片动辄几百 KB,放进 argv 会撞 Windows
128
+ // 命令行长度上限,也会把二进制搅进进程列表。stdin 的好处是调用方可以管线接。
129
+ func runRequirementShot(cmd *cobra.Command, opts *askOptions) error {
130
+ where := strings.TrimSpace(opts.reqShot)
131
+ if where == "." {
132
+ where = ""
133
+ }
134
+ name := strings.TrimSpace(opts.reqShotName)
135
+ if name == "" {
136
+ return &usageError{fmt.Errorf("--requirement-shot 需要 --requirement-name <功能点名>")}
137
+ }
138
+ png, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), requirements.MaxShotBytes+1))
139
+ if err != nil {
140
+ return fmt.Errorf("--requirement-shot: 读取 stdin 失败: %w", err)
141
+ }
142
+ res, err := requirements.AttachShot(where, opts.reqModule, name, opts.reqShotCap, png)
143
+ if err != nil && !requirements.IsBusy(err) {
144
+ if res.Error == "" {
145
+ res.Error = err.Error()
146
+ }
147
+ _ = printJSON(cmd.OutOrStdout(), res)
148
+ return fmt.Errorf("--requirement-shot %s: %s", where, err)
149
+ }
150
+ return printJSON(cmd.OutOrStdout(), res)
151
+ }
152
+
106
153
  // bindRequirementFlags 注册需求聚合的 flags。
107
154
  //
108
155
  // --requirements / --consolidate-requirements 的值都可以留空(空串是合法输入:
@@ -110,6 +157,7 @@ func runConsolidateRequirements(cmd *cobra.Command, opts *askOptions) error {
110
157
  func bindRequirementFlags(cmd *cobra.Command, opts *askOptions) {
111
158
  f := cmd.PersistentFlags()
112
159
  f.StringVar(&opts.requirements, "requirements", "", "列出需求清单(工作区 → 模块 → 功能点,JSON:{ok,root,workspaces})。值留空 = 列全部;给 ws id 或目录 = 只列那一个。只读,不调模型")
160
+ f.StringVar(&opts.reqGraph, "requirements-graph", "", "会话↔需求关系图谱(JSON:{ok,root,workspaces:[{nodes,edges}]},nodes 带 type=session|feature)。值留空 = 全部;只读,不调模型")
113
161
  f.StringVar(&opts.consolidateReqs, "consolidate-requirements", "", "立即把会话记忆归纳成需求文档(会调模型)。值留空 = 当前目录;给 ws id 或目录 = 归纳那一个")
114
162
  f.BoolVar(&opts.reqForce, "requirement-force", false, "配合 --consolidate-requirements:没有未归纳的记忆也跑一轮(手动「立即整理」)")
115
163
  f.BoolVar(&opts.reqAll, "requirement-all", false, "配合 --consolidate-requirements:全量重放历史记忆(回填 / 滚动校正)。仍走增量合并,只增不减,不会推翻已有内容")
@@ -118,4 +166,7 @@ func bindRequirementFlags(cmd *cobra.Command, opts *askOptions) {
118
166
  f.StringVar(&opts.reqModule, "requirement-module", "", "配合 --consolidate-requirements:只整理这个模块(空 = 整个空间)。不推进空间游标,不触发首次目录初始化")
119
167
  f.DurationVar(&opts.reqTimeout, "requirement-timeout", requirements.DefaultTimeout, "配合 --consolidate-requirements:单次模型调用超时(默认归纳 4m / 首次目录初始化 9m)")
120
168
  f.BoolVar(&opts.reqAuto, "requirement-auto", false, "配合 --consolidate-requirements:自动归纳(不触发首次按目录初始化;插件轮末 / 每日兜底用)")
169
+ f.StringVar(&opts.reqShot, "requirement-shot", "", "给功能点追加一张「页面完成情况」截图(PNG 走 stdin)。值留空 = 当前目录;给 ws id 或目录 = 那个空间")
170
+ f.StringVar(&opts.reqShotName, "requirement-name", "", "配合 --requirement-shot:功能点名(= 文档名)")
171
+ f.StringVar(&opts.reqShotCap, "requirement-caption", "", "配合 --requirement-shot:截图的标注说明")
121
172
  }
@@ -33,6 +33,7 @@ package cli
33
33
  // --temperature <t> 采样温度(仅 llm 引擎透传 -o temperature,其余忽略)
34
34
  // --engines 列出引擎、可用性与支持的模型(JSON 数组;--json 兼容保留)
35
35
  // --no-models 配合 --engines:跳过模型探测(只列引擎,快)
36
+ // --quotas 只查各引擎账号的额度与每日签到(不探测引擎/不启动 CLI,秒级)
36
37
  // --stop <id> 停止指定会话/运行(session_id 或 run_id),杀掉它的引擎进程组
37
38
  // --delete-session <id> 删除一条会话:登记记录 + 事件历史 + 标题覆盖一起删,**全机生效**
38
39
  // (各端读同一份登记表,一端删了处处都看不到)。幂等
@@ -185,7 +186,7 @@ llm 引擎(包装 simonw/LLM CLI,模型/密钥由它自管):
185
186
  每个可用引擎的模型清单都从它自己的权威入口现取现算,CLI 升级/新注册模型后自动跟随:
186
187
  claude ~/.claude/settings.json(顶层 model + env 里 ANTHROPIC_*MODEL[*_NAME])
187
188
  codebuddy codebuddy --help 里 --model 自带的 "Currently supported: (...)" 清单
188
- trae trae-cli models --json
189
+ trae traex models --json
189
190
  llm llm models
190
191
  codex codex debug models
191
192
  openclaw openclaw models list --json
@@ -202,7 +203,7 @@ llm 引擎(包装 simonw/LLM CLI,模型/密钥由它自管):
202
203
  codex npm install -g @openai/codex
203
204
  openclaw npm install -g openclaw@latest
204
205
  dsh npm i -g @deepseek-ai/dsh
205
- trae sh -c "$(curl -L https://trae.cn/trae-cli/install.sh)"
206
+ trae sh -c "$(curl -fsSL https://trae.cn/trae-cli/install_v2.sh)" # Windows: irm https://trae.cn/trae-cli/install_v2.ps1 | iex
206
207
  llm python3 -m venv ~/.llm-venv && ~/.llm-venv/bin/pip install llm
207
208
  命令一律取自各 CLI 的官方安装方式;**没有可执行安装路径的引擎不给该字段**
208
209
  (codebuddy / codebuddy-ai 是桌面端 GUI 应用,arkclaw 靠配置文件),
@@ -468,6 +469,9 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
468
469
  case flagChanged(cmd, "repair-models"):
469
470
  // 同上:`--repair-models ""` 要走参数校验,而不是掉进「提问」分支报 empty prompt。
470
471
  return runRepairModels(cmd, opts)
472
+ case flagChanged(cmd, "checkin"):
473
+ // 同上:`--checkin ""` 要走参数校验。
474
+ return runCheckin(cmd, opts)
471
475
  case opts.listSessions:
472
476
  return runSessions(cmd, opts)
473
477
  case flagChanged(cmd, "session-log"):
@@ -500,9 +504,15 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
500
504
  case flagChanged(cmd, "requirements"):
501
505
  // 读清单:值留空 = 列全部工作区(`--requirements ""` 也是合法调用)。
502
506
  return runRequirements(cmd, opts)
507
+ case flagChanged(cmd, "requirements-graph"):
508
+ // 读图谱:值留空 = 全部工作区。只读,不调模型。
509
+ return runRequirementsGraph(cmd, opts)
503
510
  case flagChanged(cmd, "consolidate-requirements"):
504
511
  // 写:跑一轮归纳。值留空 = 当前目录。
505
512
  return runConsolidateRequirements(cmd, opts)
513
+ case flagChanged(cmd, "requirement-shot"):
514
+ // 写:给功能点追加一张完成情况截图(PNG 走 stdin)。
515
+ return runRequirementShot(cmd, opts)
506
516
  case flagChanged(cmd, "requirement-force"), flagChanged(cmd, "requirement-all"),
507
517
  flagChanged(cmd, "requirement-engine"), flagChanged(cmd, "requirement-model"),
508
518
  flagChanged(cmd, "requirement-timeout"), flagChanged(cmd, "requirement-auto"):
@@ -510,6 +520,10 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
510
520
  // 静默忽略就等于「传了参数什么都没发生」。
511
521
  return &usageError{fmt.Errorf("--requirement-force / -all / -engine / -model / -timeout / -auto " +
512
522
  "只在 --consolidate-requirements 下成立:请配合它使用")}
523
+ case flagChanged(cmd, "requirement-name"), flagChanged(cmd, "requirement-caption"):
524
+ // 这两个是 --requirement-shot 的修饰项(截图落在哪个功能点、标注写什么)。
525
+ return &usageError{fmt.Errorf("--requirement-name / --requirement-caption " +
526
+ "只在 --requirement-shot 下成立:请配合它使用")}
513
527
  case flagChanged(cmd, "stop"):
514
528
  // 用「传过没有」而不是「值非空」判断:`--stop ""` 也要走参数校验
515
529
  //(exit 2 提示要 id),而不是掉进「提问」分支报 empty prompt。
@@ -523,6 +537,8 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
523
537
  return runContract(cmd, opts)
524
538
  case opts.engines:
525
539
  return runEngines(cmd, opts)
540
+ case opts.quotas:
541
+ return runQuotas(cmd)
526
542
  case opts.team:
527
543
  // 团队模式必须排在 --stream 之前:它自己就是流式路径的一种运行模式
528
544
  //(同一套 ready / seq / result 收尾),但内部编排完全不同。
@@ -767,7 +783,7 @@ func flagChanged(cmd *cobra.Command, name string) bool {
767
783
  // 实测只有「受理帧 + 整段正文」,见 internal/agent/arkclaw_stream.go
768
784
  //
769
785
  // 模型**一律现取现算、不硬编码**:各引擎用自己 CLI/配置文件里的权威入口
770
- // (trae-cli models / llm models / codex debug models / openclaw models list /
786
+ // (traex models / llm models / codex debug models / openclaw models list /
771
787
  // codebuddy --help / claude settings.json)。探测要真的启动 CLI(只读子命令),
772
788
  // 故并发执行并套 agent.DefaultModelProbeTimeout;--no-models 可整段跳过(只列引擎,快)。
773
789
  // --json 为兼容旧调用保留(本函数不再区分,恒为 JSON)。
@@ -823,6 +839,40 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
823
839
  // 仅实现了 agent.ModelCreditLister 的引擎给出 —— 目前 codebuddy / codebuddy-ai)。
824
840
  // 客户端不给倍率的模型(custom-local)不出现在 map 里。
825
841
  ModelCredits map[string]string `json:"model_credits,omitempty"`
842
+ // ModelVendors 各模型的供应商展示名(model → 供应商,如 "Volcengine");
843
+ // 与 models 同形同链,键已剥 `custom-local:` 前缀。只用于**选择器的第二行**,
844
+ // 不参与 -m / 落库。仅实现 agent.ModelVendorLister 的引擎给出(目前 codebuddy 系),
845
+ // 其余引擎省略该字段(新增字段,不升 contractVersion)。
846
+ ModelVendors map[string]string `json:"model_vendors,omitempty"`
847
+ // CreditLeft 账号当前**剩余积分**(decimal 字符串,如 "5939.2";单位 credits)。
848
+ // 仅实现了 agent.CreditLister 的引擎给出(codebuddy / codebuddy-ai),
849
+ // 各自读自己票据(codebuddy-ai 读隔离目录),查不到就省略该字段。
850
+ // 新增(2026-10-24,只增字段不升 contractVersion)。
851
+ CreditLeft string `json:"credit_left,omitempty"`
852
+ // CreditSuffix CreditLeft / ModelQuota 的单位后缀(缺省「积分」)。llm 的
853
+ // MiniMax 套餐额度是百分比,给 "%" —— 客户端据此显示「5h 98% · 周 53%」。
854
+ // 新增(2026-10-25,只增字段不升 contractVersion)。
855
+ CreditSuffix string `json:"credit_suffix,omitempty"`
856
+ // ModelQuota 按**模型**给出的套餐额度快照(model id → {interval_left, weekly_left},
857
+ // 两个窗口的剩余百分比,单位见 credit_suffix)。仅实现了 agent.ModelQuotaLister
858
+ // 的引擎给出 —— 目前是 llm(MiniMax 套餐;只挂在走 MiniMax 端点的模型上)。
859
+ // 新增(2026-10-25,只增字段不升 contractVersion)。
860
+ ModelQuota map[string]agent.QuotaWindows `json:"model_quota,omitempty"`
861
+ // CheckinToday 今天是否已领每日积分(true / false);拿不到状态时**省略**该字段
862
+ // (nil 指针),客户端据此决定要不要画「领取」按钮。
863
+ // 新增(2026-10-24,只增字段不升 contractVersion)。
864
+ CheckinToday *bool `json:"checkin_today,omitempty"`
865
+ // CheckinCredit 今日可领积分(字符串,如 "100";活动没开 / 拿不到时省略)。
866
+ CheckinCredit string `json:"checkin_credit,omitempty"`
867
+ // LoggedIn 该账号槽位是否已登录(仅实现 agent.LoginChecker 的多账号引擎给出:
868
+ // codebuddy / codebuddy-ai)。客户端据此**只显示已登录的账号** ——
869
+ // 「登录了几个账号就显示几个」,而不是固定两行。
870
+ // 新增(2026-10-25,只增字段不升 contractVersion);老 core 没有该字段时客户端按「显示」处理。
871
+ LoggedIn *bool `json:"logged_in,omitempty"`
872
+ // Account 该账号槽位登录的**账号昵称**(如 "Darren" / "玲";仅 codebuddy 系给出,
873
+ // 来自票据里的 account.nickname)。选择器把它画在引擎名下面,一眼看出这行是哪个号。
874
+ // 新增(2026-10-25,只增字段不升 contractVersion)。
875
+ Account string `json:"account,omitempty"`
826
876
  // Workspace 该引擎「指定工作目录」的落地方式(machine-readable,供调用方决定是否下发):
827
877
  // flag:-C 原生 flag(codex);cwd 子进程 cwd(claude/codebuddy/trae);
828
878
  // none 无落地通道(llm/arkclaw/openclaw/codebuddy-gateway,参数会被忽略)
@@ -896,6 +946,16 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
896
946
  if ok {
897
947
  r.Bin, r.Note = note, ""
898
948
  }
949
+ /* 多账号引擎(codebuddy 系):这个槽位有没有登录。没登录的槽位客户端不显示,
950
+ 所以「登录了几个账号就显示几个」;探测本身不依赖它(模型清单照旧给)。 */
951
+ if lc, okl := e.(agent.LoginChecker); okl {
952
+ loggedIn := lc.LoggedIn()
953
+ r.LoggedIn = &loggedIn
954
+ }
955
+ /* 账号昵称:选择器里两个 codebuddy 槽位只差引擎名,标上「这行是哪个号」。 */
956
+ if al, okl := e.(agent.AccountLister); okl {
957
+ r.Account = al.AccountName()
958
+ }
899
959
  /* 安装 / 升级命令:**可用时也给**(同一条命令重跑一遍 = 升级到最新版)。
900
960
  没有可执行安装路径的引擎留空 —— 见 InstallCommandOf 的说明,不编命令。 */
901
961
  r.Install = agent.InstallCommandOf(e.Name())
@@ -914,6 +974,51 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
914
974
  rows[i].Version = agent.EngineVersionOf(cmd.Context(), bin)
915
975
  }(i, r.Bin)
916
976
  }
977
+ /* 账号剩余额度(codebuddy 系积分 / llm 的 MiniMax 套餐百分比):只读本机票据
978
+ 或 models.json + 一次只读请求。与模型探测并发跑;查不到就不给该字段。 */
979
+ if cl, okc := e.(agent.CreditLister); okc {
980
+ eng := e
981
+ wg.Add(1)
982
+ go func(i int, l agent.CreditLister) {
983
+ defer wg.Done()
984
+ left := l.CreditLeft(cmd.Context())
985
+ rows[i].CreditLeft = left
986
+ if left != "" {
987
+ if u, oku := eng.(agent.CreditUnitLister); oku {
988
+ rows[i].CreditSuffix = u.CreditUnit()
989
+ }
990
+ }
991
+ }(i, cl)
992
+ }
993
+ /* 模型级套餐额度(llm 的 MiniMax 模型):同一账号一份快照,挂到每个走该端点的模型。 */
994
+ if mq, okm := e.(agent.ModelQuotaLister); okm {
995
+ eng := e
996
+ wg.Add(1)
997
+ go func(i int, l agent.ModelQuotaLister) {
998
+ defer wg.Done()
999
+ q := l.ModelQuota(cmd.Context())
1000
+ if len(q) == 0 {
1001
+ return
1002
+ }
1003
+ rows[i].ModelQuota = q
1004
+ if u, oku := eng.(agent.CreditUnitLister); oku {
1005
+ rows[i].CreditSuffix = u.CreditUnit()
1006
+ }
1007
+ }(i, mq)
1008
+ }
1009
+ /* 每日签到状态(同上,仅 codebuddy 系):今天是否已领 + 可领多少,
1010
+ 客户端据此画「领取」按钮;拿不到就省略两个字段。 */
1011
+ if ck, okc := e.(agent.Checkiner); okc {
1012
+ wg.Add(1)
1013
+ go func(i int, c agent.Checkiner) {
1014
+ defer wg.Done()
1015
+ if info, known := c.CheckinStatus(cmd.Context()); known {
1016
+ claimed := info.CheckedToday
1017
+ rows[i].CheckinToday = &claimed
1018
+ rows[i].CheckinCredit = info.DailyCredit
1019
+ }
1020
+ }(i, ck)
1021
+ }
917
1022
  if !ok {
918
1023
  continue
919
1024
  }
@@ -923,7 +1028,7 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
923
1028
  continue
924
1029
  }
925
1030
  wg.Add(1)
926
- go func(i int, name string, l agent.ModelLister) {
1031
+ go func(i int, name string, l agent.ModelLister, eng agent.Engine) {
927
1032
  defer wg.Done()
928
1033
  ctx, cancel := context.WithTimeout(cmd.Context(), agent.DefaultModelProbeTimeout)
929
1034
  defer cancel()
@@ -946,7 +1051,19 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
946
1051
  rows[i].ModelCredits = credits
947
1052
  }
948
1053
  }
949
- }(i, e.Name(), lister)
1054
+ /* 供应商展示名(选择器第二行):与 models 同形,键同样剥前缀。
1055
+ 拿不到就整个省略 —— 界面据此决定画不画第二行(dsh 的第二行是路由,
1056
+ 走的是另一条「id 里自带 route/」的路径,与此无关)。 */
1057
+ if vl := agent.ModelVendorListOf(eng); vl != nil {
1058
+ vendors := vl.ModelVendors(ctx)
1059
+ if name == "codebuddy" || name == "codebuddy-ai" {
1060
+ vendors = agent.StripCustomLocalPrefixCredits(vendors)
1061
+ }
1062
+ if len(vendors) > 0 {
1063
+ rows[i].ModelVendors = vendors
1064
+ }
1065
+ }
1066
+ }(i, e.Name(), lister, e)
950
1067
  }
951
1068
  wg.Wait()
952
1069
  if !envelope {
@@ -397,6 +397,12 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
397
397
  if title == "" {
398
398
  title = r.PromptHead
399
399
  }
400
+ /* 排队占位(服务端作业队列预登记,state=queued)还没有会话日志,归纳必然失败。
401
+ 这里直接报「已定稿」,客户端就不会每轮列表刷新都去打一次 --summarize-title。
402
+ 真开跑后 core 那条记录照常走归纳。 */
403
+ if r.State == session.StateQueued {
404
+ summarized = true
405
+ }
400
406
  /* 统一状态:一条会话一个值(各轮的行给出同一个),决定客户端画「进行中 / 已完成」。
401
407
  没有条目(改造前的老记录)才回落这一轮的 state。alive 也**按它算** ——
402
408
  否则一行里 status=idle、alive=true 自相矛盾,客户端又会分叉。 */
@@ -45,11 +45,13 @@ func runSuggestFollowups(cmd *cobra.Command, opts *askOptions) error {
45
45
  model = opts.model
46
46
  }
47
47
  res, err := suggest.Generate(cmd.Context(), suggest.Request{
48
- User: body.User,
49
- Reply: body.Reply,
50
- Engine: engine,
51
- Model: model,
52
- Timeout: suggest.DefaultTimeout,
48
+ User: body.User,
49
+ Reply: body.Reply,
50
+ Engine: engine,
51
+ Model: model,
52
+ FallbackEngine: strings.TrimSpace(opts.suggestFallbackEngine),
53
+ FallbackModel: strings.TrimSpace(opts.suggestFallbackModel),
54
+ Timeout: suggest.DefaultTimeout,
53
55
  })
54
56
  if err != nil {
55
57
  return fmt.Errorf("--suggest-followups: 生成失败: %w", err)