magic-agent 2.3.2 → 2.4.2

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 (35) hide show
  1. package/CLIENT-CONTRACT.md +30 -4
  2. package/README.md +1 -1
  3. package/client-ui/shared/conv/07-stream-delegation.js +15 -0
  4. package/client-ui/shared/conversation.js +39 -0
  5. package/client-ui/shared/transport-http.js +10 -0
  6. package/client-ui-mobile/index.html +431 -46
  7. package/cmd/magic-client/main.go +5 -0
  8. package/internal/agent/engine.go +2 -2
  9. package/internal/agent/llmengine.go +0 -8
  10. package/internal/agent/llmengine_test.go +0 -17
  11. package/internal/agent/llmmodels.go +0 -35
  12. package/internal/client/cors_test.go +32 -0
  13. package/internal/client/instance.go +59 -0
  14. package/internal/client/jobs.go +1160 -0
  15. package/internal/client/jobs_test.go +369 -0
  16. package/internal/client/service.go +251 -141
  17. package/internal/client/service_test.go +46 -1
  18. package/internal/session/session.go +15 -1
  19. package/internal/session/workspace_lock.go +165 -0
  20. package/internal/session/workspace_lock_test.go +90 -0
  21. package/internal/suggest/suggest.go +8 -25
  22. package/internal/suggest/suggest_test.go +0 -6
  23. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  24. package/npm/apk/version.json +3 -3
  25. package/npm/dist/darwin-arm64/magic-agent +0 -0
  26. package/npm/dist/darwin-arm64/magic-client +0 -0
  27. package/npm/dist/darwin-x64/magic-agent +0 -0
  28. package/npm/dist/darwin-x64/magic-client +0 -0
  29. package/npm/dist/linux-arm64/magic-agent +0 -0
  30. package/npm/dist/linux-arm64/magic-client +0 -0
  31. package/npm/dist/linux-x64/magic-agent +0 -0
  32. package/npm/dist/linux-x64/magic-client +0 -0
  33. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  34. package/npm/dist/win32-x64/magic-client.exe +0 -0
  35. package/package.json +1 -1
@@ -0,0 +1,165 @@
1
+ package session
2
+
3
+ import (
4
+ "crypto/sha1"
5
+ "encoding/hex"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "strconv"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/darren/magic-agent/internal/agent"
14
+ )
15
+
16
+ /* ── 工作空间串行锁(2026-10-23,多客户端串行的一部分)──
17
+
18
+ 为什么要锁在 core 而不是只靠 magic-client 的调度器:
19
+ · 同一台桌面上,除了 magic-client 起的轮次,还有人直接跑 `magic-agent -p`;
20
+ · 多台手机连的是同一个 magic-client,但**进程间**(多个 magic-client 实例)也可能并存。
21
+ 调度器只能管自己进程里的队列;一个落在文件系统上的锁才能让「谁先跑」对所有进程一致。
22
+
23
+ 为什么是选入式(MAGIC_AGENT_WORKSPACE_LOCK=1):
24
+ 默认开启会改变 CLI 的既有语义(两条命令并发跑本来是允许的),也会让团队模式的
25
+ 子任务与主任务互相等待。这里只在 magic-client spawn core 时注入该环境变量,
26
+ 团队子任务(ParentRunID 非空)自动跳过。
27
+
28
+ 锁的形态:<sessions dir>/locks/<sha1(workspace)>.lock,内容 `<pid>\n<unix nano>`。
29
+ · 用 O_CREATE|O_EXCL 原子创建;
30
+ · 持锁进程已不在 / 文件超过 staleAfter → 视为陈旧并回收;
31
+ · 进程退出没来得及删也不会永久堵住(下一步的持有者会按 pid 判死回收)。
32
+ */
33
+
34
+ // EnvWorkspaceLock 打开「工作空间串行锁」的环境变量(值为 "1" 时生效)。
35
+ const EnvWorkspaceLock = "MAGIC_AGENT_WORKSPACE_LOCK"
36
+
37
+ // EnvWorkspaceLockWait 等锁上限(Go duration;默认 10m)。
38
+ const EnvWorkspaceLockWait = "MAGIC_AGENT_WORKSPACE_LOCK_WAIT"
39
+
40
+ const (
41
+ workspaceLockPoll = 500 * time.Millisecond
42
+ workspaceLockStale = 6 * time.Hour
43
+ )
44
+
45
+ // WorkspaceLock 一把已持有的工作空间锁;Release 幂等。
46
+ type WorkspaceLock struct {
47
+ path string
48
+ held bool
49
+ }
50
+
51
+ func workspaceLockEnabled() bool {
52
+ return strings.TrimSpace(os.Getenv(EnvWorkspaceLock)) == "1"
53
+ }
54
+
55
+ func workspaceLockWait() time.Duration {
56
+ if v := strings.TrimSpace(os.Getenv(EnvWorkspaceLockWait)); v != "" {
57
+ if d, err := time.ParseDuration(v); err == nil && d > 0 {
58
+ return d
59
+ }
60
+ }
61
+ return 10 * time.Minute
62
+ }
63
+
64
+ // lockKey 归一工作空间:空 = 进程 cwd;返回绝对路径 + 哈希文件名。
65
+ func workspaceLockKey(workspace string) (key, file string) {
66
+ ws := strings.TrimSpace(workspace)
67
+ if ws == "" {
68
+ ws, _ = os.Getwd()
69
+ }
70
+ if abs, err := filepath.Abs(ws); err == nil {
71
+ ws = abs
72
+ }
73
+ ws = filepath.Clean(ws)
74
+ sum := sha1.Sum([]byte(ws))
75
+ return ws, hex.EncodeToString(sum[:])
76
+ }
77
+
78
+ /*
79
+ AcquireWorkspaceLock 取工作空间锁(阻塞,直到拿到或超时)。
80
+
81
+ o.NoPersist 没关系:锁在 sessions 目录,不依赖会话记录落盘。
82
+ 返回 nil 表示未启用(调用方无需处理)。
83
+ */
84
+ func acquireWorkspaceLock(workspace string) (*WorkspaceLock, error) {
85
+ if !workspaceLockEnabled() {
86
+ return nil, nil
87
+ }
88
+ dir := Dir()
89
+ if dir == "" {
90
+ return nil, fmt.Errorf("cannot resolve sessions dir")
91
+ }
92
+ lockDir := filepath.Join(dir, "locks")
93
+ if err := os.MkdirAll(lockDir, 0o700); err != nil {
94
+ return nil, err
95
+ }
96
+ ws, name := workspaceLockKey(workspace)
97
+ path := filepath.Join(lockDir, name+".lock")
98
+ deadline := time.Now().Add(workspaceLockWait())
99
+ announced := false
100
+ for {
101
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
102
+ if err == nil {
103
+ _, _ = fmt.Fprintf(f, "%d\n%d\n", os.Getpid(), time.Now().UnixNano())
104
+ _ = f.Close()
105
+ return &WorkspaceLock{path: path, held: true}, nil
106
+ }
107
+ if !os.IsExist(err) {
108
+ return nil, err
109
+ }
110
+ if reapStaleWorkspaceLock(path) {
111
+ continue
112
+ }
113
+ if time.Now().After(deadline) {
114
+ return nil, fmt.Errorf("等待工作空间 %s 上的上一个任务超时(%s),可用 %s 调整", ws, workspaceLockWait(), EnvWorkspaceLockWait)
115
+ }
116
+ if !announced {
117
+ announced = true
118
+ fmt.Fprintf(os.Stderr, "magic-agent: 同一工作空间已有任务在跑,等待其结束(%s)…\n", ws)
119
+ }
120
+ time.Sleep(workspaceLockPoll)
121
+ }
122
+ }
123
+
124
+ // reapStaleWorkspaceLock 回收陈旧锁(pid 已不在 / 文件太老);返回是否已删。
125
+ func reapStaleWorkspaceLock(path string) bool {
126
+ st, err := os.Stat(path)
127
+ if err != nil {
128
+ return !os.IsNotExist(err) // 文件刚消失:让调用方重试
129
+ }
130
+ stale := time.Since(st.ModTime()) > workspaceLockStale
131
+ if !stale {
132
+ if b, err := os.ReadFile(path); err == nil {
133
+ lines := strings.Split(strings.TrimSpace(string(b)), "\n")
134
+ if len(lines) > 0 {
135
+ if pid, err := strconv.Atoi(strings.TrimSpace(lines[0])); err == nil {
136
+ if pid <= 0 || !agent.PIDAlive(pid) {
137
+ stale = true
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ if !stale {
144
+ return false
145
+ }
146
+ _ = os.Remove(path)
147
+ return true
148
+ }
149
+
150
+ // Release 释放锁;只删自己持有的那把(内容里的 pid 对得上)。
151
+ func (l *WorkspaceLock) Release() {
152
+ if l == nil || !l.held {
153
+ return
154
+ }
155
+ l.held = false
156
+ b, err := os.ReadFile(l.path)
157
+ if err != nil {
158
+ return
159
+ }
160
+ first := strings.TrimSpace(strings.SplitN(string(b), "\n", 2)[0])
161
+ if first != strconv.Itoa(os.Getpid()) {
162
+ return
163
+ }
164
+ _ = os.Remove(l.path)
165
+ }
@@ -0,0 +1,90 @@
1
+ package session
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strconv"
7
+ "testing"
8
+ "time"
9
+ )
10
+
11
+ // 工作空间串行锁:同一 workspace 的第二轮必须等第一轮 Finish 之后才能 Begin。
12
+ func TestWorkspaceLockSerializesRuns(t *testing.T) {
13
+ t.Setenv(EnvDir, t.TempDir())
14
+ t.Setenv(EnvWorkspaceLock, "1")
15
+ t.Setenv(EnvWorkspaceLockWait, "5s")
16
+ ws := t.TempDir()
17
+
18
+ h1, err := Begin(BeginOptions{NoPersist: true, Workspace: ws, Engine: "claude"})
19
+ if err != nil {
20
+ t.Fatalf("第一轮 Begin 失败:%v", err)
21
+ }
22
+
23
+ got := make(chan *Handle, 1)
24
+ go func() {
25
+ h2, err := Begin(BeginOptions{NoPersist: true, Workspace: ws, Engine: "claude"})
26
+ if err != nil {
27
+ got <- nil
28
+ return
29
+ }
30
+ got <- h2
31
+ }()
32
+
33
+ select {
34
+ case h := <-got:
35
+ if h != nil {
36
+ h.Finish("", StateDone)
37
+ }
38
+ t.Fatal("第一轮还没结束,第二轮不应拿到锁")
39
+ case <-time.After(400 * time.Millisecond):
40
+ }
41
+
42
+ h1.Finish("", StateDone)
43
+ select {
44
+ case h2 := <-got:
45
+ if h2 == nil {
46
+ t.Fatal("第一轮结束后,第二轮应拿到锁")
47
+ }
48
+ h2.Finish("", StateDone)
49
+ case <-time.After(4 * time.Second):
50
+ t.Fatal("第一轮结束后,第二轮仍未拿到锁")
51
+ }
52
+ }
53
+
54
+ // 持锁进程已不在(崩溃 / 被 kill):下一次 Begin 要能回收陈旧锁。
55
+ func TestWorkspaceLockReapsDeadOwner(t *testing.T) {
56
+ dir := t.TempDir()
57
+ t.Setenv(EnvDir, dir)
58
+ t.Setenv(EnvWorkspaceLock, "1")
59
+ t.Setenv(EnvWorkspaceLockWait, "2s")
60
+ ws := t.TempDir()
61
+
62
+ _, name := workspaceLockKey(ws)
63
+ lockDir := filepath.Join(dir, "locks")
64
+ if err := os.MkdirAll(lockDir, 0o700); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ // 一个几乎不可能存活、但格式合法的 pid。
68
+ if err := os.WriteFile(filepath.Join(lockDir, name+".lock"), []byte("999999\n"+strconv.FormatInt(time.Now().UnixNano(), 10)+"\n"), 0o600); err != nil {
69
+ t.Fatal(err)
70
+ }
71
+
72
+ done := make(chan *Handle, 1)
73
+ go func() {
74
+ h, err := Begin(BeginOptions{NoPersist: true, Workspace: ws})
75
+ if err != nil {
76
+ done <- nil
77
+ return
78
+ }
79
+ done <- h
80
+ }()
81
+ select {
82
+ case h := <-done:
83
+ if h == nil {
84
+ t.Fatal("陈旧锁应被回收,第二轮应拿到锁")
85
+ }
86
+ h.Finish("", StateDone)
87
+ case <-time.After(3 * time.Second):
88
+ t.Fatal("陈旧锁未被回收:第二轮一直等")
89
+ }
90
+ }
@@ -190,7 +190,7 @@ func Generate(ctx context.Context, in Request) (Result, error) {
190
190
  start := time.Now()
191
191
 
192
192
  /* 建议一律不思考;所有引擎都尽量走官方开关:
193
- llm 用 no-think 变体 / reasoning_effort=none,claude --thinking disabled,
193
+ llm 传 reasoning_effort=none,claude --thinking disabled,
194
194
  codex effort=none,codebuddy settings alwaysThinkingEnabled=false,
195
195
  dsh InitializeParams.reasoningEffort=off。没有可靠开关的引擎(trae/openclaw/arkclaw)
196
196
  仍会被 NoThinking 标记,失败后再降级 llm;不在这里预先改道。 */
@@ -228,7 +228,8 @@ func Generate(ctx context.Context, in Request) (Result, error) {
228
228
  }
229
229
 
230
230
  // fastLLMModel 找 models.json 里第一条「不思考」的变体(extraBody.thinking.type=disabled)。
231
- // 建议是短文本,思考只会把 1s 拖成 4s;有这种条目就用它,不硬编码某个模型 id。
231
+ // 仅用于**没有显式选模型**的建议默认值:M3.1-Flash 明确拒绝 reasoning_effort=none,
232
+ // 默认走首条会直接 400。显式选中的模型只传参数,不换模型。
232
233
  func fastLLMModel() string {
233
234
  list, err := agent.LoadLLMConfigModels()
234
235
  if err != nil {
@@ -254,22 +255,6 @@ func llmModelDisablesThinking(m agent.LLMConfigModel) bool {
254
255
  return strings.EqualFold(strings.TrimSpace(fmt.Sprint(th["type"])), "disabled")
255
256
  }
256
257
 
257
- // noThinkLLMModel 给建议选一个真正不思考的 llm 模型:
258
- // 用户选的模型本身就是 no-think 变体 → 用它;否则用 models.json 里第一条
259
- // thinking.disabled 变体;没有变体才退回用户选的模型(无法关闭,至少不阻断)。
260
- func noThinkLLMModel(selected string) string {
261
- selected = strings.TrimSpace(selected)
262
- if selected != "" {
263
- if m, ok := agent.LookupLLMConfigModel(selected); ok && llmModelDisablesThinking(m) {
264
- return m.ID
265
- }
266
- }
267
- if fast := fastLLMModel(); fast != "" {
268
- return fast
269
- }
270
- return selected
271
- }
272
-
273
258
  // tryGenerate 用指定引擎跑一次 ToolsOff + JSONSchema 的短调用。
274
259
  func tryGenerate(ctx context.Context, in Request, name string, budget time.Duration, start time.Time) (Result, error) {
275
260
  engine := agent.Lookup(name)
@@ -283,19 +268,17 @@ func tryGenerate(ctx context.Context, in Request, name string, budget time.Durat
283
268
  引擎的兜底(minimal 仍比默认高档轻);能真正关闭的引擎看 NoThinking。 */
284
269
  reasoning := "minimal"
285
270
  model := strings.TrimSpace(in.Model)
286
- if name == DefaultEngine {
287
- /* 建议一律不思考:llm 有 no-think 变体就用它(默认引擎不配模型时也走这里)。
288
- 用户显式选了会思考的模型(如 MiniMax-M3.1-Flash)时,宁可换同文件的
289
- no-think 变体,也不让建议在思考上多花几秒;配置里没有变体才保留原模型。 */
290
- model = noThinkLLMModel(model)
271
+ if name == DefaultEngine && model == "" {
272
+ // 仅默认值:显式选了模型就只传 reasoning_effort=none,不换模型。
273
+ model = fastLLMModel()
291
274
  }
292
275
  runner := &agent.Runner{Engine: engine, Timeout: budget}
293
276
  resp, err := runner.Run(attemptCtx, agent.Request{
294
277
  Model: model,
295
278
  Tools: agent.ToolsOff,
296
279
  /* NoThinking:claude → --thinking disabled,codex → effort none,
297
- llm 换成 thinking.disabled 变体,codebuddy → settings:false,
298
- dsh → initialize.reasoningEffort=off。 */
280
+ codebuddy → settings:false,dsh → initialize.reasoningEffort=off,
281
+ llm → reasoning_effort=none(不自动换模型)。 */
299
282
  NoThinking: true,
300
283
  Reasoning: reasoning,
301
284
  MaxTokens: 300,
@@ -259,10 +259,4 @@ func TestFastLLMModelPicksNoThinkVariant(t *testing.T) {
259
259
  if got := fastLLMModel(); got != "fast-nothink" {
260
260
  t.Fatalf("fastLLMModel = %q, want fast-nothink", got)
261
261
  }
262
- if got := noThinkLLMModel("slow"); got != "fast-nothink" {
263
- t.Fatalf("noThinkLLMModel(slow) = %q, want fast-nothink(用户选了会思考的模型也要换成 no-think)", got)
264
- }
265
- if got := noThinkLLMModel("fast-nothink"); got != "fast-nothink" {
266
- t.Fatalf("noThinkLLMModel(fast-nothink) = %q, want 原样保留", got)
267
- }
268
262
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.3.2",
3
- "versionCode": 20302,
4
- "builtAt": "2026-10-23T03:26:46.892Z"
2
+ "version": "2.4.2",
3
+ "versionCode": 20402,
4
+ "builtAt": "2026-10-23T11:49:45.889Z"
5
5
  }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magic-agent",
3
- "version": "2.3.2",
3
+ "version": "2.4.2",
4
4
  "description": "专业的 agent CLI 代理工具 - 统一 claude / codebuddy / trae / llm / codex / openclaw 引擎",
5
5
  "license": "MIT",
6
6
  "bin": {