magic-agent 2.6.0 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CLIENT-CONTRACT.md +188 -16
  2. package/client-ui/pc/app.css +42 -0
  3. package/client-ui/pc/js/90-glue.js +3 -0
  4. package/client-ui/shared/conv/03-chat-message.js +25 -19
  5. package/client-ui/shared/conv/09-stream-apply.js +9 -0
  6. package/client-ui/shared/conv/29-msg-variants.js +136 -0
  7. package/client-ui/shared/conv/conv.css +253 -158
  8. package/client-ui/shared/conversation.js +39 -2
  9. package/client-ui-mobile/apps.css +16 -0
  10. package/client-ui-mobile/apps.js +406 -0
  11. package/client-ui-mobile/index.html +2695 -379
  12. package/client-ui-mobile/reqs.css +11 -4
  13. package/client-ui-mobile/reqs.js +49 -5
  14. package/client-ui-mobile/scripts.css +46 -0
  15. package/client-ui-mobile/scripts.js +66 -6
  16. package/cmd/magic-client/browsectl.go +1 -1
  17. package/cmd/magic-client/clientctl.go +37 -2
  18. package/cmd/magic-client/main.go +159 -12
  19. package/internal/agent/dsh_caps_cache_test.go +184 -0
  20. package/internal/agent/dsh_resume.go +144 -5
  21. package/internal/agent/env.go +173 -1
  22. package/internal/agent/env_test.go +114 -0
  23. package/internal/agent/limitnotice.go +207 -1
  24. package/internal/agent/limitnotice_test.go +140 -0
  25. package/internal/agent/stream.go +30 -0
  26. package/internal/agent/streamjson.go +11 -1
  27. package/internal/cli/ask.go +8 -0
  28. package/internal/cli/engines_test.go +103 -0
  29. package/internal/cli/events.go +11 -0
  30. package/internal/cli/events_test.go +50 -0
  31. package/internal/cli/judge.go +104 -0
  32. package/internal/cli/root.go +35 -6
  33. package/internal/cli/session.go +69 -3
  34. package/internal/cli/session_test.go +82 -3
  35. package/internal/cli/sessionlog.go +6 -0
  36. package/internal/cli/sessionlog_test.go +22 -2
  37. package/internal/client/apk.go +8 -0
  38. package/internal/client/apk_test.go +59 -0
  39. package/internal/client/apps.go +1078 -0
  40. package/internal/client/apps_test.go +516 -0
  41. package/internal/client/coreexe.go +196 -0
  42. package/internal/client/coreexe_test.go +161 -0
  43. package/internal/client/coreupgrade.go +866 -0
  44. package/internal/client/coreupgrade_test.go +561 -0
  45. package/internal/client/daemon.go +209 -18
  46. package/internal/client/feishu.go +1259 -0
  47. package/internal/client/feishu_test.go +649 -0
  48. package/internal/client/health_identity_test.go +84 -0
  49. package/internal/client/hostenv.go +68 -0
  50. package/internal/client/hostenv_test.go +37 -0
  51. package/internal/client/jobs.go +233 -12
  52. package/internal/client/jobs_auto.go +571 -0
  53. package/internal/client/jobs_auto_test.go +431 -0
  54. package/internal/client/plugins.go +5 -1
  55. package/internal/client/prefs.go +67 -0
  56. package/internal/client/procstart_linux.go +59 -0
  57. package/internal/client/publicurl.go +82 -0
  58. package/internal/client/requirements.go +9 -1
  59. package/internal/client/requirements_test.go +103 -0
  60. package/internal/client/restart.go +167 -13
  61. package/internal/client/restart_handoff_test.go +230 -0
  62. package/internal/client/restart_test.go +140 -1
  63. package/internal/client/scripts_decode_implausible.go +38 -0
  64. package/internal/client/scripts_decode_other.go +4 -0
  65. package/internal/client/scripts_decode_test.go +16 -11
  66. package/internal/client/scripts_decode_windows.go +0 -24
  67. package/internal/client/scripts_test.go +14 -1
  68. package/internal/client/service.go +403 -87
  69. package/internal/client/service_test.go +166 -1
  70. package/internal/client/sessions_cache.go +148 -0
  71. package/internal/client/sessions_cache_test.go +163 -0
  72. package/internal/client/sessions_fold.go +165 -0
  73. package/internal/client/sessions_fold_test.go +204 -0
  74. package/internal/client/singleinstance.go +470 -0
  75. package/internal/client/singleinstance_test.go +607 -0
  76. package/internal/client/testmain_test.go +88 -0
  77. package/internal/client/workspace_default_test.go +158 -0
  78. package/internal/client/workspacelockenv_test.go +54 -0
  79. package/internal/config/config.go +48 -0
  80. package/internal/judge/judge.go +315 -0
  81. package/internal/session/multiclient_test.go +368 -0
  82. package/internal/session/session.go +83 -11
  83. package/internal/session/session_test.go +7 -0
  84. package/internal/session/status.go +21 -0
  85. package/internal/session/status_test.go +28 -0
  86. package/internal/session/title.go +16 -0
  87. package/internal/session/title_test.go +26 -0
  88. package/internal/session/workspace_lock.go +265 -28
  89. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  90. package/npm/apk/version.json +8 -1
  91. package/npm/dist/darwin-arm64/magic-agent +0 -0
  92. package/npm/dist/darwin-arm64/magic-client +0 -0
  93. package/npm/dist/darwin-x64/magic-agent +0 -0
  94. package/npm/dist/darwin-x64/magic-client +0 -0
  95. package/npm/dist/linux-arm64/magic-agent +0 -0
  96. package/npm/dist/linux-arm64/magic-client +0 -0
  97. package/npm/dist/linux-x64/magic-agent +0 -0
  98. package/npm/dist/linux-x64/magic-client +0 -0
  99. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  100. package/npm/dist/win32-x64/magic-client.exe +0 -0
  101. package/npm/install.js +11 -0
  102. package/package.json +8 -3
@@ -31,6 +31,15 @@ func sinkSamples() []agent.StreamEvent {
31
31
  Reason: "without result line",
32
32
  Status: agent.StatusFailed, ItemID: "claude_msg_1", ItemRevision: 4,
33
33
  },
34
+ /* 额度卡三项(2026-10-25):非空时才出现在 wire 上 —— 放进样本是为了让上面
35
+ 那条「两条路径字段集只差 v/seq」的断言也覆盖到它们(否则新字段漏了 marshal
36
+ 到老形状里也测不出来)。 */
37
+ {
38
+ Kind: agent.KindTurnFailed, Text: "", SessionID: "sess_quota",
39
+ Error: "429 You have exceeded the 5-hour usage quota.", Reason: "429 You have exceeded the 5-hour usage quota.",
40
+ ErrorKind: agent.ErrorKindQuota, ErrorCode: "429", ResetAt: "2026-10-25T15:07:27+08:00",
41
+ Status: agent.StatusFailed,
42
+ },
34
43
  }
35
44
  }
36
45
 
@@ -250,6 +259,47 @@ func TestTurnFailedPayloadFields(t *testing.T) {
250
259
  }
251
260
  }
252
261
 
262
+ // 额度卡三项必须原样过 wire(客户端靠它决定画额度卡还是普通失败卡):
263
+ // 名字与 CLAUDE-CONTRACT / CLIENT-CONTRACT.md 1.1.1 那张表逐字一致,改一个字母
264
+ // 客户端就再也认不出来 —— 所以这里按**字符串字段名**断言,而不是读结构体。
265
+ func TestTurnFailedQuotaFields(t *testing.T) {
266
+ var buf bytes.Buffer
267
+ s := newEventSink(&buf, "codebuddy", true)
268
+ if err := s.stream(agent.StreamEvent{
269
+ Kind: agent.KindTurnFailed, SessionID: "s2",
270
+ Error: "429 You have exceeded the 5-hour usage quota.", Reason: "429 You have exceeded the 5-hour usage quota.",
271
+ ErrorKind: agent.ErrorKindQuota, ErrorCode: "429", ResetAt: "2026-10-25T15:07:27+08:00",
272
+ Status: agent.StatusFailed,
273
+ }); err != nil {
274
+ t.Fatal(err)
275
+ }
276
+ m := decodeEventLine(t, buf.String())
277
+ if m["error_kind"] != "quota" {
278
+ t.Errorf("error_kind = %v,want quota", m["error_kind"])
279
+ }
280
+ if m["error_code"] != "429" {
281
+ t.Errorf("error_code = %v,want 429", m["error_code"])
282
+ }
283
+ if m["reset_at"] != "2026-10-25T15:07:27+08:00" {
284
+ t.Errorf("reset_at = %v", m["reset_at"])
285
+ }
286
+
287
+ /* 分不出来时三项都不出现(omitempty)—— 老消费者的字段集因此不变。 */
288
+ buf.Reset()
289
+ if err := s.stream(agent.StreamEvent{
290
+ Kind: agent.KindTurnFailed, SessionID: "s3",
291
+ Error: "工具执行失败", Reason: "工具执行失败", Status: agent.StatusFailed,
292
+ }); err != nil {
293
+ t.Fatal(err)
294
+ }
295
+ m = decodeEventLine(t, buf.String())
296
+ for _, k := range []string{"error_kind", "error_code", "reset_at"} {
297
+ if _, ok := m[k]; ok {
298
+ t.Errorf("分不出来时不该出现 %s:%v", k, m)
299
+ }
300
+ }
301
+ }
302
+
253
303
  // ── 黄金样本:钉住 Claude / CodeBuddy 在 wire 上的真实字节形状 ──
254
304
  //
255
305
  // 为什么不复用上面的 TestStreamEventPayloadConvergenceFields:
@@ -0,0 +1,104 @@
1
+ package cli
2
+
3
+ // judge.go - `--judge-conflict <file|->`:判断本轮任务与同空间运行中任务是否冲突。
4
+ //
5
+ // 为什么要单独一条 CLI(2026-10-26):「自动」任务模式要在**开跑前**决定这一轮是并行还是
6
+ // 排队,判断依据是两边「在干什么」—— 这是语义问题,只能由模型答;而提示词与解析口径必须
7
+ // 只有一份(移动端 / PC / 服务端都不能各写一套)。口径收进 core(internal/judge)之后,
8
+ // magic-client 的作业队列 exec 这一条即可。
9
+ //
10
+ // 与 `--suggest-followups` 同一套旁路调用:独立进程、新会话、ToolsOff、不写历史。
11
+ //
12
+ // 退出码:
13
+ //
14
+ // 0 判定完成(conflict=true / false 都是成功;调用方按结论排队或放行)
15
+ // 1 引擎失败(不可用 / 超时 / 输出不是 JSON)—— 调用方按「默认并行」放行
16
+ // 2 参数错(请求 JSON 读不到 / 不合法 / 缺 prompt)
17
+
18
+ import (
19
+ "encoding/json"
20
+ "fmt"
21
+ "io"
22
+ "os"
23
+ "strings"
24
+
25
+ "github.com/spf13/cobra"
26
+
27
+ "github.com/darren/magic-agent/internal/judge"
28
+ )
29
+
30
+ // runJudgeConflict 读请求 JSON → 调 judge.Judge → 打印 JSON 结论。
31
+ func runJudgeConflict(cmd *cobra.Command, opts *askOptions) error {
32
+ body, err := readJudgeInput(cmd, opts.judgeInput)
33
+ if err != nil {
34
+ return &usageError{err}
35
+ }
36
+ /* 引擎:没显式传 -e 时用 llm(启动最快,2.5s 预算内能答完);传了就以调用方的为准
37
+ (调用方传的是用户配的「建议引擎」)。 */
38
+ engine := judge.DefaultEngine
39
+ if flagChanged(cmd, "engine") {
40
+ engine = opts.engine
41
+ }
42
+ model := ""
43
+ if flagChanged(cmd, "model") {
44
+ model = opts.model
45
+ }
46
+ res, err := judge.Judge(cmd.Context(), judge.Request{
47
+ Prompt: body.Prompt,
48
+ Candidates: body.Candidates,
49
+ Engine: engine,
50
+ Model: model,
51
+ Timeout: judge.DefaultTimeout,
52
+ })
53
+ if err != nil {
54
+ return fmt.Errorf("--judge-conflict: 判定失败: %w", err)
55
+ }
56
+ if res.Overlaps == nil {
57
+ res.Overlaps = []string{}
58
+ }
59
+ return printJSON(cmd.OutOrStdout(), res)
60
+ }
61
+
62
+ // readJudgeInput 读请求 JSON:`-` = stdin,其余按文件路径(支持 `@file`)。
63
+ // 上限 256KB:判定只该带「本轮提示词 + 几个候选的摘要」,超了说明调用方塞了整条历史。
64
+ func readJudgeInput(cmd *cobra.Command, spec string) (judge.RequestBody, error) {
65
+ var body judge.RequestBody
66
+ src := strings.TrimSpace(spec)
67
+ read := func(r io.Reader, from string) error {
68
+ b, err := io.ReadAll(io.LimitReader(r, 256<<10))
69
+ if err != nil {
70
+ return fmt.Errorf("读%s失败:%w", from, err)
71
+ }
72
+ if err := json.Unmarshal(b, &body); err != nil {
73
+ return fmt.Errorf("%s不是合法 JSON:%w", from, err)
74
+ }
75
+ return nil
76
+ }
77
+ switch src {
78
+ case "":
79
+ return body, fmt.Errorf("--judge-conflict 需要请求 JSON:传文件路径或 \"-\"(stdin)")
80
+ case "-":
81
+ if err := read(cmd.InOrStdin(), "stdin "); err != nil {
82
+ return body, err
83
+ }
84
+ default:
85
+ path := strings.TrimPrefix(src, "@")
86
+ if strings.HasPrefix(path, "~/") {
87
+ if home, herr := os.UserHomeDir(); herr == nil {
88
+ path = home + path[1:]
89
+ }
90
+ }
91
+ f, err := os.Open(path)
92
+ if err != nil {
93
+ return body, fmt.Errorf("读请求文件失败:%w", err)
94
+ }
95
+ defer f.Close()
96
+ if err := read(f, "请求文件 "); err != nil {
97
+ return body, err
98
+ }
99
+ }
100
+ if strings.TrimSpace(body.Prompt) == "" {
101
+ return body, fmt.Errorf(`缺少 prompt(本轮任务):形如 {"prompt":"…","candidates":[…]}(candidates 为空表示没有冲突对象)`)
102
+ }
103
+ return body, nil
104
+ }
@@ -486,6 +486,9 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
486
486
  case flagChanged(cmd, "suggest-followups"):
487
487
  // 值可以是 "-"(stdin),所以用「传过没有」判,而不是「值非空」。
488
488
  return runSuggestFollowups(cmd, opts)
489
+ case flagChanged(cmd, "judge-conflict"):
490
+ // 同上:值可以是 "-"(stdin)。
491
+ return runJudgeConflict(cmd, opts)
489
492
  case flagChanged(cmd, "title-force"):
490
493
  // --title-force 只在 --summarize-title 下成立:单给它必然是调用方误解了
491
494
  // (想要「强制重新归纳」却没给 id)。明说,不静默忽略。
@@ -717,20 +720,28 @@ var (
717
720
  // key 一般用引擎名(codebuddy / codebuddy-ai / trae …);命中且未过期的缓存直接返回,
718
721
  // 不触碰 CLI / 文件。底层走 probeEngineModels / probeEngineCredits(可被测试替换)。
719
722
  // ListModels 出错或返回空清单时**不缓存**(视为瞬时态,下次重探)。
720
- func cachedModelList(ctx context.Context, key string, l agent.ModelLister) (models []string, credits map[string]string, note string) {
723
+ //
724
+ // noSource 区分两种"拿不到模型"的语义(见 agent.ErrNoModelSource):
725
+ // - true = 引擎**本来就不提供**模型清单(arkclaw 按 claw_id 绑模型、
726
+ // codebuddy-gateway 用网关进程的模型)—— 它不是故障,调用方不该因此隐藏引擎;
727
+ // - false = 有来源但这次探测失败(CLI 退出码非 0 / 空清单)—— 可以隐藏或置灰。
728
+ //
729
+ // 以前两者只靠 note 字符串表达,客户端只能看"note 是否为空"一刀切,
730
+ // 把 arkclaw / MagicAI / codebuddy-gateway 一起隐藏了(2026-10 报障)。
731
+ func cachedModelList(ctx context.Context, key string, l agent.ModelLister) (models []string, credits map[string]string, note string, noSource bool) {
721
732
  modelListCacheMu.Lock()
722
733
  if it, ok := modelListCache[key]; ok && time.Now().Before(it.expiresAt) {
723
734
  modelListCacheMu.Unlock()
724
- return it.models, it.credits, ""
735
+ return it.models, it.credits, "", false
725
736
  }
726
737
  modelListCacheMu.Unlock()
727
738
 
728
739
  models, err := probeEngineModels(ctx, l)
729
740
  if err != nil {
730
- return nil, nil, err.Error()
741
+ return nil, nil, err.Error(), errors.Is(err, agent.ErrNoModelSource)
731
742
  }
732
743
  if len(models) == 0 {
733
- return nil, nil, "no models reported"
744
+ return nil, nil, "no models reported", false
734
745
  }
735
746
  if cl, ok := l.(agent.ModelCreditLister); ok {
736
747
  credits = probeEngineCredits(ctx, cl)
@@ -742,7 +753,7 @@ func cachedModelList(ctx context.Context, key string, l agent.ModelLister) (mode
742
753
  expiresAt: time.Now().Add(modelListCacheTTL),
743
754
  }
744
755
  modelListCacheMu.Unlock()
745
- return models, credits, ""
756
+ return models, credits, "", false
746
757
  }
747
758
 
748
759
  // clearModelListCache 清空模型清单缓存(测试用,避免用例之间互相命中缓存)。
@@ -834,6 +845,14 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
834
845
  Version string `json:"version,omitempty"`
835
846
  Models []string `json:"models,omitempty"`
836
847
  ModelsNote string `json:"models_note,omitempty"`
848
+ /* ModelsSource 模型清单的**来源语义**(machine-readable,取代"靠 models_note
849
+ 猜"的做法;只增字段不升 contractVersion):
850
+ dynamic —— models 可用;
851
+ none —— 引擎本身不提供模型选择(模型绑在 claw_id / 网关进程上),
852
+ **不是故障**:调用方应保留这一行并显示「引擎默认」;
853
+ failed —— 有动态来源但本次探测失败(CLI 非 0 退出等),可隐藏或置灰。
854
+ 旧 core 没有该字段,调用方按 models_note 前缀兜底(见 shared/conversation.js)。 */
855
+ ModelsSource string `json:"models_source,omitempty"`
837
856
  // ModelCredits 各模型的积分倍率(machine-readable,model → 倍率数字字符串,
838
857
  // 如 "fast-model":"0.34",源于客户端 "x0.34 credits";与 models 同源同链探测,
839
858
  // 仅实现了 agent.ModelCreditLister 的引擎给出 —— 目前 codebuddy / codebuddy-ai)。
@@ -1025,6 +1044,7 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
1025
1044
  lister := agent.ModelListerOf(e)
1026
1045
  if lister == nil {
1027
1046
  rows[i].ModelsNote = "no dynamic model source"
1047
+ rows[i].ModelsSource = "none"
1028
1048
  continue
1029
1049
  }
1030
1050
  wg.Add(1)
@@ -1032,7 +1052,7 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
1032
1052
  defer wg.Done()
1033
1053
  ctx, cancel := context.WithTimeout(cmd.Context(), agent.DefaultModelProbeTimeout)
1034
1054
  defer cancel()
1035
- models, credits, note := cachedModelList(ctx, name, l)
1055
+ models, credits, note, noSource := cachedModelList(ctx, name, l)
1036
1056
  // codebuddy 系:展示时剥掉自定义模型 id 的 "custom-local:" 前缀(噪点);
1037
1057
  // -m 实际传值时由引擎按裸名补回前缀(见 codebuddyCore.modelArg),
1038
1058
  // 自定义模型依旧可用。
@@ -1043,10 +1063,19 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
1043
1063
  switch {
1044
1064
  case note != "":
1045
1065
  rows[i].ModelsNote = note
1066
+ // 语义型"没有模型源"≠ 探测失败:前者保留可选项(模型由 claw_id /
1067
+ // 网关进程决定),后者才让调用方隐藏。
1068
+ if noSource {
1069
+ rows[i].ModelsSource = "none"
1070
+ } else {
1071
+ rows[i].ModelsSource = "failed"
1072
+ }
1046
1073
  case len(models) == 0:
1047
1074
  rows[i].ModelsNote = "no models reported"
1075
+ rows[i].ModelsSource = "failed"
1048
1076
  default:
1049
1077
  rows[i].Models = models
1078
+ rows[i].ModelsSource = "dynamic"
1050
1079
  if credits != nil {
1051
1080
  rows[i].ModelCredits = credits
1052
1081
  }
@@ -385,31 +385,95 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
385
385
  // 所以非流式跑出来的会话没有历史可回放 —— 客户端要拿它判断「这条点开有东西看吗」,
386
386
  // 而让客户端自己去拼日志路径就是把本仓库的内部布局当接口用。
387
387
  HasLog bool `json:"has_log"`
388
+ /* WorkspaceLocked 这条会话所在的目录当前是不是被**别人**占着工作空间锁
389
+ (2026-10-26,多客户端仲裁)。
390
+
391
+ 为什么要有这个字段:core 现在默认对同 workspace 串行(见 workspace_lock.go),
392
+ 但**等待发生在 Begin 里**,客户端在发请求的那一刻看不到「我要等」——
393
+ 用户看到的只是「点了没反应 / 转很久」。把这个判断上移到列表里,
394
+ 客户端就能在按下发送之前就说「这个目录正被手机端占着,已排队」。
395
+ ⚠️ 只增字段,老客户端忽略即可(不升契约版本)。 */
396
+ WorkspaceLocked bool `json:"workspace_locked,omitempty"`
397
+ // LockHolder 占着这个目录的那一端是谁(pid / client / source / run_id)。
398
+ // 只在 WorkspaceLocked 为真时给;用户的追问永远是「谁在占」,只有 pid 答不了。
399
+ LockHolder *session.LockHolder `json:"lock_holder,omitempty"`
388
400
  }
389
401
  logDir, _ := sessionLogDir()
402
+ /* 锁的查询按**目录**去重:一屏几十条会话大多在同一个 workspace 上,
403
+ 逐行读一次锁文件既浪费又要处理「同一目录答案不一致」的边界。
404
+ 这里先收集涉及的目录,各查一次。 */
405
+ lockOf := map[string]*session.LockHolder{}
406
+ lockOfWorkspace := func(ws string) *session.LockHolder {
407
+ key, _ := session.LockKey(ws)
408
+ if h, ok := lockOf[key]; ok {
409
+ return h
410
+ }
411
+ var out *session.LockHolder
412
+ if h, ok := session.LockHolderFor(ws); ok && h.PID != os.Getpid() {
413
+ /* 排除自己:列表页说「你自己占着这个目录」是噪音 ——
414
+ 用户关心的是「我要不要等别人」。 */
415
+ out = &h
416
+ }
417
+ lockOf[key] = out
418
+ return out
419
+ }
420
+ /* 标题表与状态表各取**一次**快照(2026-10-26 修):
421
+ 这两个查询原本逐行调用,而每次调用都会让底层把整张表深拷贝一遍(缓存不能交给
422
+ 调用方乱改)+ 加一次锁。407 行 = 814 次全表拷贝。列表页要的只是 O(1) 查表,
423
+ 拷一次就够 —— 语义不变(TitleOf / StatusOf 与 TitleForAny / StatusForAny 同口径)。 */
424
+ titles := session.TitleSnapshot()
425
+ statuses := session.StatusSnapshot()
390
426
  rows := make([]row, 0, len(records))
391
427
  for _, r := range records {
392
428
  /* 标题的键可能是 session_id,也可能是 run_id(首轮归纳时引擎还没吐 id,见
393
429
  session.TitleForAny)—— 两个都认,且**一次取定**,免得两个客户端因为
394
430
  「谁先拿到 id」而显示不同标题。取不到才回落 prompt_head(= 还没归纳)。 */
395
- title := session.TitleForAny(r.SessionID, r.RunID)
431
+ title := session.TitleOf(titles, r.SessionID, r.RunID)
396
432
  summarized := title != ""
397
433
  if title == "" {
398
434
  title = r.PromptHead
399
435
  }
436
+ hasLog := session.HasSessionLogIn(logDir, r.SessionID, r.RunID)
400
437
  /* 排队占位(服务端作业队列预登记,state=queued)还没有会话日志,归纳必然失败。
401
438
  这里直接报「已定稿」,客户端就不会每轮列表刷新都去打一次 --summarize-title。
402
439
  真开跑后 core 那条记录照常走归纳。 */
403
440
  if r.State == session.StateQueued {
404
441
  summarized = true
405
442
  }
443
+ /* 结构性不可能归纳:**已经有 session_id、却没有事件日志**。
444
+ 这种会话每一次归纳都必然失败,重试一万次结果一样,所以直接报「已定稿」,
445
+ 客户端别再去打那一发(否则它永远停在「待归纳」,每次刷新重来一遍)。
446
+
447
+ 为什么必然失败(见 runSummarizeTitle):归纳的输入必须是**首问**,首问只从
448
+ 事件日志里取;日志读不到时唯一的兜底是 prompt_head,而那条兜底**只在还没有
449
+ session_id 时**才允许(有 id = 至少第二轮,prompt_head 记的是当前这轮追问,
450
+ 拿它归纳等于把标题改成最新追问 —— 正是「标题每次追问都在变」那个老问题)。
451
+ 于是 `sessionID != "" && 无日志` 落进「读不到首问」的硬失败分支。
452
+
453
+ 什么时候会落到这个组合:引擎当时是非流式跑的(日志只在 --stream 时写)、
454
+ 或日志被清理而登记还在。两者都不会自己好转 —— 除非用户明确要求 `--title-force`
455
+ 重来(客户端的手动「重新归纳」按钮照常能打)。 */
456
+ if !summarized && r.SessionID != "" && !hasLog {
457
+ summarized = true
458
+ }
406
459
  /* 统一状态:一条会话一个值(各轮的行给出同一个),决定客户端画「进行中 / 已完成」。
407
460
  没有条目(改造前的老记录)才回落这一轮的 state。alive 也**按它算** ——
408
461
  否则一行里 status=idle、alive=true 自相矛盾,客户端又会分叉。 */
409
- status := session.StatusForAny(r.SessionID, r.RunID)
462
+ status := session.StatusOf(statuses, r.SessionID, r.RunID)
410
463
  if status == "" {
411
464
  status = r.State
412
465
  }
466
+ /* 这个会话所在目录当前被谁占着(空 workspace 用 cwd 兜底,与锁的归一一致)。
467
+ 只有**活着的**会话才关心:已收尾的会话不给这个字段,免得列表上到处是
468
+ 「锁被占着」的陈旧提示(锁文件可能因为进程刚退出还没删)。 */
469
+ var holder *session.LockHolder
470
+ if session.IsLiveState(status) {
471
+ ws := r.Workspace
472
+ if ws == "" {
473
+ ws = r.Cwd
474
+ }
475
+ holder = lockOfWorkspace(ws)
476
+ }
413
477
  rows = append(rows, row{
414
478
  RunID: r.RunID,
415
479
  SessionID: r.SessionID,
@@ -431,7 +495,9 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
431
495
  TitleSummarized: summarized,
432
496
  StartedAt: r.StartedAt.Format("2006-01-02 15:04:05"),
433
497
  UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
434
- HasLog: session.HasSessionLogIn(logDir, r.SessionID, r.RunID),
498
+ HasLog: hasLog,
499
+ WorkspaceLocked: holder != nil,
500
+ LockHolder: holder,
435
501
  })
436
502
  }
437
503
  return printJSON(cmd.OutOrStdout(), rows)
@@ -157,10 +157,20 @@ func TestAskRegistersSession(t *testing.T) {
157
157
  if r.Alive {
158
158
  t.Error("已结束的会话不该标 alive")
159
159
  }
160
- // 记录文件确实落在指定目录
160
+ /* 记录文件确实落在指定目录。
161
+ ⚠️ 只数 `.json`(2026-10-26):工作空间锁默认启用之后,同一个目录里还会多出
162
+ `locks/` 子目录(见 internal/session/workspace_lock.go)。原来按目录项数断言
163
+ 「= 1」会因为多了这个子目录而假失败 —— 它数的是「目录里有几样东西」,
164
+ 而这里想验的是「登记记录落在哪」,两者本来就不是一回事。 */
161
165
  entries, _ := os.ReadDir(dir)
162
- if len(entries) != 1 {
163
- t.Errorf("目录里文件数 = %d want 1", len(entries))
166
+ nJSON := 0
167
+ for _, e := range entries {
168
+ if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") {
169
+ nJSON++
170
+ }
171
+ }
172
+ if nJSON != 1 {
173
+ t.Errorf("目录里登记记录数 = %d want 1(entries=%v)", nJSON, entries)
164
174
  }
165
175
  }
166
176
 
@@ -339,3 +349,72 @@ func mustSessions(t *testing.T) string {
339
349
  }
340
350
  return out
341
351
  }
352
+
353
+ // TestSessionsMarksUnsortedWhenSummarizeImpossible 有 session_id 却没有事件日志的会话,
354
+ // 归纳**必然失败**(见 runSummarizeTitle 里「读不到首问」那条硬失败分支),列表要直接
355
+ // 报「已定稿」—— 否则客户端每轮刷新都去打一发 --summarize-title,永远是失败,永远重来。
356
+ //
357
+ // 为什么这个标记必须由 core 给:客户端只看得到 title(恒非空,回落 prompt_head)与
358
+ // title_summarized,它**没有**能力判断「这个组合注定失败」——那依赖归纳的输入从哪来,
359
+ // 是 CLI 的内部知识。让客户端自己猜就会分叉。
360
+ func TestSessionsMarksUnsortedWhenSummarizeImpossible(t *testing.T) {
361
+ dir := t.TempDir()
362
+ t.Setenv(session.EnvDir, dir)
363
+ oldLog := SessionLogDirOverride
364
+ defer func() { SessionLogDirOverride = oldLog }()
365
+ SessionLogDirOverride = t.TempDir() // 空目录:任何会话都没有事件日志
366
+
367
+ /* 造两条记录:一条会拿到 session_id(引擎吐 id,归纳必然读不到首问),
368
+ 一条不吐 id(首轮,prompt_head 就是首问,归纳**有**机会成功)。
369
+ 用真实的调用链造(假引擎 + --save-registry),不手写记录文件 ——
370
+ writeRecord 没导出,而且手写的记录容易和 Read 侧的归一规则脱节。 */
371
+ registerFake(&sidEngine{name: "fake-hard-sid", text: "ok", sid: "sid-hard-fail"})
372
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-hard-sid", "有 id 没日志"); err != nil {
373
+ t.Fatalf("造带 id 的记录失败: %v", err)
374
+ }
375
+ registerFake(&stringEngine{name: "fake-no-sid", text: "ok"})
376
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-no-sid", "没 id 也没日志"); err != nil {
377
+ t.Fatalf("造无 id 的记录失败: %v", err)
378
+ }
379
+
380
+ var rows []struct {
381
+ RunID string `json:"run_id"`
382
+ SessionID string `json:"session_id"`
383
+ TitleSummarized bool `json:"title_summarized"`
384
+ HasLog bool `json:"has_log"`
385
+ }
386
+ out := mustSessions(t)
387
+ if err := json.Unmarshal([]byte(out), &rows); err != nil {
388
+ t.Fatalf("--sessions 输出不是 JSON:%v", err)
389
+ }
390
+ bySID := map[string]struct {
391
+ TitleSummarized bool
392
+ HasLog bool
393
+ }{}
394
+ for _, r := range rows {
395
+ bySID[r.SessionID] = struct {
396
+ TitleSummarized bool
397
+ HasLog bool
398
+ }{r.TitleSummarized, r.HasLog}
399
+ }
400
+
401
+ hard, ok := bySID["sid-hard-fail"]
402
+ if !ok {
403
+ t.Fatalf("有 session_id 的那条没出现在列表里:%s", out)
404
+ }
405
+ if hard.HasLog {
406
+ t.Error("前置条件不成立:这条本来就没有日志")
407
+ }
408
+ if !hard.TitleSummarized {
409
+ t.Error("有 session_id + 无日志 = 归纳必然失败,列表必须报 title_summarized=true(别再让客户端重试)")
410
+ }
411
+
412
+ // 没有 session_id 的那条:SessionID 为空串。
413
+ soft, ok := bySID[""]
414
+ if !ok {
415
+ t.Fatalf("没有 session_id 的那条没出现在列表里:%s", out)
416
+ }
417
+ if soft.TitleSummarized {
418
+ t.Error("没有 session_id 的会话归纳有机会成功(prompt_head 就是首问),不该被标成已定稿")
419
+ }
420
+ }
@@ -118,6 +118,12 @@ type sessionEvent struct {
118
118
  // Error / Reason 异常收尾的原因(仅 turn_failed),与事件流同源。
119
119
  Error string `json:"error,omitempty"`
120
120
  Reason string `json:"reason,omitempty"`
121
+ /* 失败分类(2026-10-25,仅 turn_failed):与事件流同名同义(见 events.go 的
122
+ streamEventPayload)。落盘它是为了让**历史回放画出同一张卡** —— 额度用尽
123
+ 那一轮翻回去看,仍应是「额度卡」而不是普通失败卡。 */
124
+ ErrorKind string `json:"error_kind,omitempty"`
125
+ ErrorCode string `json:"error_code,omitempty"`
126
+ ResetAt string `json:"reset_at,omitempty"`
121
127
  // Attachments 用户提问携带的附件(仅 kind:"user" 有)。引擎事件里没有附件信息
122
128
  // (`image` 事件只对应助手**产出**),所以只有落在这里,历史回放才复原得出
123
129
  // 「那一轮用了哪张图」—— 移动端据此在历史气泡里画出缩略图。
@@ -216,10 +216,30 @@ func TestSessionWriterRealisticStream(t *testing.T) {
216
216
  if len(evs) != 7 {
217
217
  t.Fatalf("ReadSessionLog 期望 7 条,实得 %d", len(evs))
218
218
  }
219
+
220
+ /* 额度卡三项要**落盘**(2026-10-25):只走实时流不带它们,用户翻历史时那一轮就退回
221
+ 普通失败卡 —— 「当时看到的」和「事后回看的」长成两张脸,是本仓库反复踩过的那类问题。 */
222
+ sw2, err := newSessionWriterAuto(sessionLogDir)
223
+ if err != nil {
224
+ t.Fatal(err)
225
+ }
226
+ sw2.setSessionID("sess-quota")
227
+ sw2.push(sessionEvent{
228
+ Kind: "turn_failed", Status: "failed",
229
+ Error: "429 You have exceeded the 5-hour usage quota.", Reason: "429 You have exceeded the 5-hour usage quota.",
230
+ ErrorKind: "quota", ErrorCode: "429", ResetAt: "2026-10-25T15:07:27+08:00",
231
+ })
232
+ drain(t, sw2)
233
+ back, err := ReadSessionLog("", "sess-quota")
234
+ if err != nil || len(back) != 1 {
235
+ t.Fatalf("读回额度那一轮失败:%v(%d 条)", err, len(back))
236
+ }
237
+ if back[0].ErrorKind != "quota" || back[0].ErrorCode != "429" || back[0].ResetAt != "2026-10-25T15:07:27+08:00" {
238
+ t.Errorf("落盘丢了额度字段:%+v", back[0])
239
+ }
219
240
  }
220
241
 
221
- /* ── 2026-10-15:日志不再等 session_id 才创建 ──
222
- 用户要求:「不等终点事件就应该创建会话落库,这样多个客户端马上就能看到正在跑的会话」。
242
+ /* ── 2026-10-15:日志不再等 session_id 才创建 ── 用户要求:「不等终点事件就应该创建会话落库,这样多个客户端马上就能看到正在跑的会话」。
223
243
  此前日志要等引擎吐 session_id 才知道文件名 → 正在跑的会话读出来永远是空历史;
224
244
  引擎整轮不给 id 时(dsh 的 headless 回退轮)更是收工就把内存 buffer 丢光。 */
225
245
 
@@ -105,6 +105,11 @@ func (s *Service) handleAppInfo(w http.ResponseWriter, r *http.Request) {
105
105
  Version string `json:"version"`
106
106
  Size int64 `json:"size"`
107
107
  URL string `json:"url"`
108
+ /* Upgrade(2026-10-26 只增字段):这个客户端**自己能不能**做桌面端 npm 一键升级
109
+ (npm 在 PATH 上、找得到目标二进制)。为 true 时界面的「检测更新」会顺手问
110
+ /desk/core,一次把「App 有没有新版」「桌面 CLI 有没有新版」都报出来。
111
+ 旧插件没有这个字段 → 界面只查 APK,不假装能升桌面端。 */
112
+ Upgrade bool `json:"upgrade"`
108
113
  }{}
109
114
  if s.apkAvailable() {
110
115
  if st, err := os.Stat(s.apkPath); err == nil {
@@ -113,6 +118,9 @@ func (s *Service) handleAppInfo(w http.ResponseWriter, r *http.Request) {
113
118
  out.URL = "/desk/app.apk"
114
119
  }
115
120
  }
121
+ if s.upgradableCore() {
122
+ out.Upgrade = true
123
+ }
116
124
  writeJSON(w, http.StatusOK, out)
117
125
  }
118
126
 
@@ -41,6 +41,7 @@ func TestAppInfoAndAPK(t *testing.T) {
41
41
  Version string `json:"version"`
42
42
  Size int64 `json:"size"`
43
43
  URL string `json:"url"`
44
+ Upgrade bool `json:"upgrade"`
44
45
  }
45
46
  if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
46
47
  t.Fatal(err)
@@ -48,6 +49,12 @@ func TestAppInfoAndAPK(t *testing.T) {
48
49
  if got.Version != "2.1.2" || got.URL != "/desk/app.apk" || got.Size != int64(len("fake-apk-2.1.2")) {
49
50
  t.Fatalf("app info = %+v", got)
50
51
  }
52
+ // 2026-10-26:/desk/app 要顺手告诉界面「这份客户端能不能一键升桌面端 npm」,
53
+ // 界面据此一次检测 APK + npm。值与本机真实探测一致(测试机上有没有 npm 不定),
54
+ // 不写死。
55
+ if got.Upgrade != s.upgradableCore() {
56
+ t.Fatalf("/desk/app.upgrade 与本机探测不一致:%+v", got)
57
+ }
51
58
 
52
59
  rec = httptest.NewRecorder()
53
60
  s.handleAppAPK(rec, httptest.NewRequest(http.MethodGet, "/desk/app.apk", nil))
@@ -87,6 +94,58 @@ func TestAppInfoWithoutAPK(t *testing.T) {
87
94
  }
88
95
  }
89
96
 
97
+ // 2026-10-26 用户:「版本检测需要同时考虑 apk 和 npm,都要支持升级」。
98
+ // 升级链路(coreupgrade.go)早就写好、单测也一直在跑,但正式路由从没挂上 ——
99
+ // 手机问 /desk/core 拿到的是 404 页而不是版本信息。这条钉住「真的挂上去了」。
100
+ func TestVersionRoutesRegistered(t *testing.T) {
101
+ s, _ := newUpgradeService(t)
102
+ h := s.Handler()
103
+ hdr := map[string]string{"X-Magic-Token": s.opts.Token}
104
+ for _, path := range []string{"/desk/core", "/desk/core/upgrade"} {
105
+ req := httptest.NewRequest(http.MethodGet, path, nil)
106
+ for k, v := range hdr {
107
+ req.Header.Set(k, v)
108
+ }
109
+ rec := httptest.NewRecorder()
110
+ h.ServeHTTP(rec, req)
111
+ if rec.Code == http.StatusNotFound {
112
+ t.Fatalf("%s 没挂到正式路由上(404)", path)
113
+ }
114
+ }
115
+ // GET /desk/core 的响应形状:界面按这些字段决定「能不能一键升」。
116
+ req := httptest.NewRequest(http.MethodGet, "/desk/core", nil)
117
+ rec := httptest.NewRecorder()
118
+ h.ServeHTTP(rec, req)
119
+ if rec.Code != http.StatusOK {
120
+ t.Fatalf("/desk/core code = %d: %s", rec.Code, rec.Body.String())
121
+ }
122
+ var info coreInfo
123
+ if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ if !info.CanUpgrade || !info.Upgradable || info.Latest != "2.4.2" {
127
+ t.Fatalf("/desk/core = %+v", info)
128
+ }
129
+ }
130
+
131
+ // 找得到 npm + 目标二进制时,/desk/app 必须明说「能升桌面端」,界面才会去问 /desk/core。
132
+ func TestAppInfoReportsNpmUpgradeAvailability(t *testing.T) {
133
+ s, _ := newUpgradeService(t)
134
+ apk := writeTestAPK(t, t.TempDir(), "2.4.1")
135
+ s.apkPath = apk
136
+ rec := httptest.NewRecorder()
137
+ s.handleAppInfo(rec, httptest.NewRequest(http.MethodGet, "/desk/app", nil))
138
+ var got struct {
139
+ Upgrade bool `json:"upgrade"`
140
+ }
141
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ if !got.Upgrade {
145
+ t.Fatalf("/desk/app.upgrade = false,但假 npm 与目标二进制都在:%s", rec.Body.String())
146
+ }
147
+ }
148
+
90
149
  func TestAPKVersionFallsBackToFilename(t *testing.T) {
91
150
  dir := t.TempDir()
92
151
  apk := filepath.Join(dir, "magic-agent-mobile-3.2.1-release.apk")