magic-agent 1.0.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,259 @@
1
+ package agent
2
+
3
+ // runcmd.go - 进程组感知的 CLI 执行辅助(平台无关部分)。
4
+ //
5
+ // 三家 CLI 都是 node 启动器,内部还会 spawn worker 子进程。
6
+ // exec.CommandContext 超时只杀直接子进程,CLI 的 worker 会变孤儿
7
+ // 继续跑(实测残留多个 node 进程)。这里统一用进程组:
8
+ //
9
+ // 1. configureProcAttr: 让 CLI 及其全部后代进独立进程组(组长 = CLI);
10
+ // 2. ctx 结束/超时时 killProcessGroup 杀掉整组;
11
+ // 3. cmd.Wait() 自然回收。
12
+ //
13
+ // 平台差异(Setpgid/负 pid kill vs Windows taskkill /T)分别放在
14
+ // runcmd_unix.go 与 runcmd_windows.go。
15
+
16
+ import (
17
+ "bytes"
18
+ "context"
19
+ "fmt"
20
+ "io"
21
+ "os"
22
+ "os/exec"
23
+ "strings"
24
+ "time"
25
+ )
26
+
27
+ // spawnHookKey ctx 里携带「子进程已启动」回调的键(见 WithSpawnHook)。
28
+ type spawnHookKey struct{}
29
+
30
+ // WithSpawnHook 注册「引擎子进程刚启动」的回调(参数是子进程 pid)。
31
+ //
32
+ // 用途:会话登记表要记住**引擎子进程**的 pid —— 它自成进程组(Setpgid),
33
+ // 与 magic-agent 自己不在同一组,所以调用方杀 magic-agent 的进程组带不走它。
34
+ // 记录到 pid 后,`magic-agent --stop <会话>` 才能精确杀掉这条会话的引擎。
35
+ //
36
+ // 回调在 Start() 之后同步调用,只做写文件这类轻量事(不阻塞读流)。
37
+ func WithSpawnHook(ctx context.Context, fn func(pid int)) context.Context {
38
+ if fn == nil {
39
+ return ctx
40
+ }
41
+ return context.WithValue(ctx, spawnHookKey{}, fn)
42
+ }
43
+
44
+ // notifySpawn 通知 ctx 上注册的 spawn 回调(无回调时什么都不做)。
45
+ func notifySpawn(ctx context.Context, pid int) {
46
+ if ctx == nil || pid <= 0 {
47
+ return
48
+ }
49
+ if fn, ok := ctx.Value(spawnHookKey{}).(func(int)); ok && fn != nil {
50
+ fn(pid)
51
+ }
52
+ }
53
+
54
+ // runCLI 在独立进程组里执行 bin args,返回 (stdout, stderr, err)。
55
+ // 超时/取消时杀掉整个进程组(含 CLI 的全部后代进程)。
56
+ func runCLI(ctx context.Context, bin string, args ...string) (string, string, error) {
57
+ return runCLIIn(ctx, "", bin, args...)
58
+ }
59
+
60
+ // runCLIIn 同 runCLI,但可指定工作目录 dir(空 = 继承调用方 cwd)。
61
+ // workspace 的主要落地手段就是它:claude / codebuddy / trae / openclaw 都没有
62
+ // 「工作目录」flag,**进程 cwd 就是它们的原生方式**(相对路径、CLAUDE.md 发现、
63
+ // git 上下文都跟着它走);codex 另有原生 `-C/--cd`(见 codex.go buildArgs)。
64
+ func runCLIIn(ctx context.Context, dir string, bin string, args ...string) (string, string, error) {
65
+ return runCLIEnvIn(ctx, dir, nil, bin, args...)
66
+ }
67
+
68
+ // runCLIEnv 在 runCLI 基础上为子进程追加环境变量(extra 为 K=V 切片,
69
+ // 追加在继承环境之后 —— Go exec 语义:重复 key 取最后一个,故 extra 覆盖继承值)。
70
+ func runCLIEnv(ctx context.Context, extra []string, bin string, args ...string) (string, string, error) {
71
+ return runCLIEnvIn(ctx, "", extra, bin, args...)
72
+ }
73
+
74
+ // runCLIEnvIn = runCLIIn + runCLIEnv(工作目录 + 追加环境变量)。
75
+ func runCLIEnvIn(ctx context.Context, dir string, extra []string, bin string, args ...string) (string, string, error) {
76
+ cmd := exec.Command(bin, args...)
77
+ if dir != "" {
78
+ cmd.Dir = dir
79
+ }
80
+ configureProcAttr(cmd)
81
+
82
+ /* stdout/stderr 用**自建管道 + 自己 drain**,不直挂 bytes.Buffer:
83
+ Go 只在目标是 *os.File 时才不做「内部拷贝 goroutine」,而 **cmd.Wait() 会等那些
84
+ goroutine 结束** —— 只要子进程还有后代握着管道写端(引擎 CLI 的 node worker 逃出
85
+ 进程组时就是这样),Wait 就永远不返回:症状是「引擎已经死了,magic-agent 却挂着不退」。
86
+ (流式路径 2026-09-18 踩过一次,见 stream.go 的同类注释;这里按同一姿势一并修。) */
87
+ stdoutR, stdoutW, perr := os.Pipe()
88
+ if perr != nil {
89
+ return "", "", perr
90
+ }
91
+ stderrR, stderrW, perr := os.Pipe()
92
+ if perr != nil {
93
+ stdoutR.Close()
94
+ stdoutW.Close()
95
+ return "", "", perr
96
+ }
97
+ var stdout, stderr bytes.Buffer
98
+ cmd.Stdout = stdoutW
99
+ cmd.Stderr = stderrW
100
+ cmd.Env = envWithDefaults()
101
+ if len(extra) > 0 {
102
+ cmd.Env = append(cmd.Env, extra...)
103
+ }
104
+
105
+ if err := cmd.Start(); err != nil {
106
+ stdoutR.Close()
107
+ stdoutW.Close()
108
+ stderrR.Close()
109
+ stderrW.Close()
110
+ return "", "", err
111
+ }
112
+ notifySpawn(ctx, cmd.Process.Pid) // 会话登记表据此记住引擎子进程 pid
113
+ stdoutW.Close()
114
+ stderrW.Close()
115
+ drained := make(chan struct{}, 2)
116
+ go func() { defer stdoutR.Close(); _, _ = io.Copy(&stdout, stdoutR); drained <- struct{}{} }()
117
+ go func() { defer stderrR.Close(); _, _ = io.Copy(&stderr, stderrR); drained <- struct{}{} }()
118
+
119
+ // 看门狗:ctx 结束 → 杀整组。
120
+ waitErr := make(chan error, 1)
121
+ go func() { waitErr <- cmd.Wait() }()
122
+
123
+ select {
124
+ case err := <-waitErr:
125
+ waitDrained(drained, 2, 10*time.Second) // 进程已退:等 drain 读完余量(有限等待防后代握管道挂死)
126
+ return stdout.String(), stderr.String(), err
127
+ case <-ctx.Done():
128
+ killProcessGroup(cmd)
129
+ select {
130
+ case <-waitErr:
131
+ case <-time.After(10 * time.Second):
132
+ }
133
+ waitDrained(drained, 2, 5*time.Second)
134
+ return stdout.String(), stderr.String(), fmt.Errorf("process group killed: %w", ctx.Err())
135
+ }
136
+ }
137
+
138
+ // waitDrained 等 n 个 drain goroutine 收尾,最多等 d(防后代进程握着写端导致永久挂起)。
139
+ func waitDrained(ch <-chan struct{}, n int, d time.Duration) {
140
+ deadline := time.After(d)
141
+ for i := 0; i < n; i++ {
142
+ select {
143
+ case <-ch:
144
+ case <-deadline:
145
+ return
146
+ }
147
+ }
148
+ }
149
+
150
+ // envWithDefaults 继承当前环境(CLI 依赖 HOME/PATH 等基础变量)。
151
+ func envWithDefaults() []string {
152
+ return environ()
153
+ }
154
+
155
+ // newStreamCmd 构造流式 CLI 进程(进程组 + 环境继承,stdout 留给调用方接管)。
156
+ func newStreamCmd(bin string, args []string) *exec.Cmd {
157
+ return newStreamCmdIn("", bin, args)
158
+ }
159
+
160
+ // newStreamCmdIn 同 newStreamCmd,可指定工作目录(workspace,见 runCLIIn 注释)。
161
+ func newStreamCmdIn(dir string, bin string, args []string) *exec.Cmd {
162
+ cmd := exec.Command(bin, args...)
163
+ if dir != "" {
164
+ cmd.Dir = dir
165
+ }
166
+ configureProcAttr(cmd)
167
+ cmd.Env = envWithDefaults()
168
+ return cmd
169
+ }
170
+
171
+ // streamStderrBuf 流式进程的 stderr 缓冲(保留尾部 32KB 供错误摘要)。
172
+ type streamStderrBuf struct {
173
+ buf [32 * 1024]byte
174
+ n int
175
+ }
176
+
177
+ func (b *streamStderrBuf) Write(p []byte) (int, error) {
178
+ if room := len(b.buf) - b.n; room > 0 {
179
+ if len(p) <= room {
180
+ copy(b.buf[b.n:], p)
181
+ b.n += len(p)
182
+ return len(p), nil
183
+ }
184
+ copy(b.buf[b.n:], p[:room])
185
+ b.n = len(b.buf)
186
+ }
187
+ return len(p), nil
188
+ }
189
+
190
+ // String 尾部内容(去 ANSI)。
191
+ func (b *streamStderrBuf) String() string {
192
+ return stripANSI(string(b.buf[:b.n]))
193
+ }
194
+
195
+ // stderrSummary 从 CLI 的 stderr 里提取人可读的失败摘要:
196
+ // 取最后一行非空输出(CLI 报错通常在末尾),去 ANSI 色码,截断。
197
+ func stderrSummary(stderr string) string {
198
+ lines := strings.Split(strings.TrimSpace(stderr), "\n")
199
+ for i := len(lines) - 1; i >= 0; i-- {
200
+ if s := strings.TrimSpace(lines[i]); s != "" {
201
+ return truncateStr(stripANSI(s), 500)
202
+ }
203
+ }
204
+ return ""
205
+ }
206
+
207
+ // wrapCliError 统一包装引擎 CLI 的执行失败。
208
+ //
209
+ // 外层文本 "<engine> CLI: <cliCauseError>"(cliCauseError.Error() 自带
210
+ // stderr 摘要),错误链经 %w 保持完整 —— WriteError 由此提取 reason。
211
+ //
212
+ // stderr 为空时(典型:超时被杀)不追加 stderr 摘要,避免拿 err 自身
213
+ // 回填造成重复。
214
+ func wrapCliError(engine, stdout, stderr string, err error) error {
215
+ summary := stderrSummary(stderr)
216
+ return fmt.Errorf("%s CLI: %w", engine, &cliCauseError{cause: err, summary: summary})
217
+ }
218
+
219
+ // cliCauseError CLI 失败的根因载体。
220
+ // Error() 输出格式:
221
+ //
222
+ // 有摘要 "<cause> (stderr: <摘要>)"
223
+ // 无摘要 "<cause>"(超时 / fork 失败类)
224
+ type cliCauseError struct {
225
+ cause error
226
+ summary string
227
+ }
228
+
229
+ func (e *cliCauseError) Error() string {
230
+ if e.summary != "" {
231
+ return fmt.Sprintf("%v (stderr: %s)", e.cause, e.summary)
232
+ }
233
+ return e.cause.Error()
234
+ }
235
+
236
+ func (e *cliCauseError) Cause() error { return e.cause }
237
+
238
+ // Summary stderr 摘要;空表示无(超时/fork 失败类)。
239
+ func (e *cliCauseError) Summary() string { return e.summary }
240
+
241
+ // Unwrap 支持 errors.Is/As 沿链下钻。
242
+ func (e *cliCauseError) Unwrap() error { return e.cause }
243
+
244
+ // stripANSI 去除 ANSI 转义序列(色码),避免错误信息里混入 "\x1b[31m"。
245
+ func stripANSI(s string) string {
246
+ var b strings.Builder
247
+ inEsc := false
248
+ for _, r := range s {
249
+ switch {
250
+ case r == 0x1b:
251
+ inEsc = true
252
+ case inEsc && (r == 'm' || r == 'K' || r == 'H'):
253
+ inEsc = false // CSI 序列终结符
254
+ case !inEsc:
255
+ b.WriteRune(r)
256
+ }
257
+ }
258
+ return b.String()
259
+ }
@@ -0,0 +1,81 @@
1
+ //go:build !windows
2
+
3
+ package agent
4
+
5
+ // runcmd_test.go - 进程组杀灭回归测试(POSIX only:依赖 sh 脚本与进程组信号)。
6
+ //
7
+ // 场景:CLI 脚本 spawn 一个长活后台子进程(模拟 codebuddy 的 node
8
+ // worker),runCLI 超时后整个进程组(含后台子进程)必须被杀干净。
9
+
10
+ import (
11
+ "context"
12
+ "os"
13
+ "path/filepath"
14
+ "syscall"
15
+ "testing"
16
+ "time"
17
+ )
18
+
19
+ func TestRunCLIKillsProcessGroupOnTimeout(t *testing.T) {
20
+ dir := t.TempDir()
21
+ marker := filepath.Join(dir, "alive")
22
+ cli := filepath.Join(dir, "slow-cli")
23
+ // 脚本:启动即 touch marker(消除负载下的启动竞态),后台循环持续
24
+ // 写 marker(模拟 worker 存活),前台 sleep 30s。
25
+ script := "#!/bin/sh\ntouch " + marker + "\n(while true; do touch " + marker + "; sleep 0.2; done) &\nsleep 30\n"
26
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+
30
+ ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
31
+ defer cancel()
32
+ start := time.Now()
33
+ _, _, err := runCLI(ctx, cli)
34
+ if err == nil {
35
+ t.Fatal("expected timeout error")
36
+ }
37
+ if elapsed := time.Since(start); elapsed > 5*time.Second {
38
+ t.Errorf("kill took too long: %v", elapsed)
39
+ }
40
+
41
+ // 等一小段时间,检查 marker 不再更新(进程组已死)。
42
+ stat1, _ := os.Stat(marker)
43
+ time.Sleep(600 * time.Millisecond)
44
+ stat2, _ := os.Stat(marker)
45
+ if stat1 == nil {
46
+ t.Fatal("marker should exist (worker was alive)")
47
+ }
48
+ if stat2 != nil && stat2.ModTime().After(stat1.ModTime()) {
49
+ t.Errorf("background worker still alive after group kill (mtime advanced: %v -> %v)", stat1.ModTime(), stat2.ModTime())
50
+ }
51
+
52
+ // 残留进程检查:该脚本名下不应有进程。
53
+ if err := syscall.Kill(0, 0); err != nil {
54
+ t.Logf("signal self check: %v", err)
55
+ }
56
+ }
57
+
58
+ func TestRunCLISuccess(t *testing.T) {
59
+ dir := t.TempDir()
60
+ cli := filepath.Join(dir, "ok-cli")
61
+ if err := os.WriteFile(cli, []byte("#!/bin/sh\necho OUT\necho ERR >&2\n"), 0o755); err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ stdout, stderr, err := runCLI(context.Background(), cli)
65
+ if err != nil {
66
+ t.Fatalf("runCLI: %v", err)
67
+ }
68
+ if stdout != "OUT\n" {
69
+ t.Errorf("stdout = %q", stdout)
70
+ }
71
+ if stderr != "ERR\n" {
72
+ t.Errorf("stderr = %q", stderr)
73
+ }
74
+ }
75
+
76
+ func TestRunCLIStartFailure(t *testing.T) {
77
+ _, _, err := runCLI(context.Background(), "/nonexistent/cli-xyz")
78
+ if err == nil {
79
+ t.Fatal("expected start error")
80
+ }
81
+ }
@@ -0,0 +1,56 @@
1
+ //go:build !windows
2
+
3
+ package agent
4
+
5
+ // runcmd_unix.go - Unix (darwin/linux) 进程组实现。
6
+ //
7
+ // Setpgid 让子进程自成进程组(pgid = 子进程 pid),超时时用负 pid
8
+ // 向整组发 SIGKILL,CLI 内部 spawn 的 node worker 一并清理。
9
+
10
+ import (
11
+ "errors"
12
+ "os/exec"
13
+ "syscall"
14
+ )
15
+
16
+ // configureProcAttr 让子进程进入独立进程组。
17
+ func configureProcAttr(cmd *exec.Cmd) {
18
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
19
+ }
20
+
21
+ // killProcessGroup 杀掉整个进程组(负 pid = 进程组)。
22
+ func killProcessGroup(cmd *exec.Cmd) {
23
+ if cmd.Process == nil {
24
+ return
25
+ }
26
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
27
+ }
28
+
29
+ // killTarget 把 (pid, group) 换算成 syscall.Kill 的目标:
30
+ // 进程组用负 pid(子 CLI 自成一组,pgid = 子 pid),单进程用正 pid。
31
+ func killTarget(pid int, group bool) int {
32
+ if group {
33
+ return -pid
34
+ }
35
+ return pid
36
+ }
37
+
38
+ // TerminatePID 按 pid 发 SIGTERM(group=true → 整个进程组)。
39
+ func TerminatePID(pid int, group bool) error {
40
+ return syscall.Kill(killTarget(pid, group), syscall.SIGTERM)
41
+ }
42
+
43
+ // KillPID 按 pid 发 SIGKILL(group=true → 整个进程组)。
44
+ func KillPID(pid int, group bool) error {
45
+ return syscall.Kill(killTarget(pid, group), syscall.SIGKILL)
46
+ }
47
+
48
+ // PIDAlive 报告 pid 是否还活着(signal 0 只做权限/存在性检查)。
49
+ // EPERM 也算活着(进程在,只是不归我们管)。
50
+ func PIDAlive(pid int) bool {
51
+ if pid <= 0 {
52
+ return false
53
+ }
54
+ err := syscall.Kill(pid, 0)
55
+ return err == nil || errors.Is(err, syscall.EPERM)
56
+ }
@@ -0,0 +1,66 @@
1
+ //go:build windows
2
+
3
+ package agent
4
+
5
+ // runcmd_windows.go - Windows 进程树实现。
6
+ //
7
+ // Windows 没有 POSIX 进程组语义,等价做法:
8
+ // - 启动时置 CREATE_NEW_PROCESS_GROUP 让 CLI 自成一组(隔离 Ctrl-C、
9
+ // 便于整组信号);
10
+ // - 超时时用 `taskkill /T /F /PID <pid>` 强制杀掉整棵进程树
11
+ // (/T 含所有后代),避免 node worker 变孤儿。
12
+ //
13
+ // 注释里刻意不写裸反斜杠,避免转义歧义。
14
+
15
+ import (
16
+ "errors"
17
+ "os/exec"
18
+ "strconv"
19
+ "strings"
20
+ "syscall"
21
+ )
22
+
23
+ // createNewProcessGroup 对应 Windows CREATE_NEW_PROCESS_GROUP 标志。
24
+ const createNewProcessGroup = 0x00000200
25
+
26
+ // configureProcAttr 让子进程自成进程组。
27
+ func configureProcAttr(cmd *exec.Cmd) {
28
+ cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup}
29
+ }
30
+
31
+ // killProcessGroup 用 taskkill 杀掉进程树(/T 后代 + /F 强制)。
32
+ func killProcessGroup(cmd *exec.Cmd) {
33
+ if cmd.Process == nil {
34
+ return
35
+ }
36
+ pid := strconv.Itoa(cmd.Process.Pid)
37
+ // 优先整树杀;失败再退回单进程 Kill。
38
+ if err := exec.Command("taskkill", "/T", "/F", "/PID", pid).Run(); err != nil {
39
+ _ = cmd.Process.Kill()
40
+ }
41
+ }
42
+
43
+ // TerminatePID Windows 无 SIGTERM 语义 → 与 KillPID 同路(taskkill /T /F 杀整棵树)。
44
+ func TerminatePID(pid int, group bool) error {
45
+ return KillPID(pid, group)
46
+ }
47
+
48
+ // KillPID 按 pid 杀进程树(group 参数在 Windows 无意义:taskkill /T 总是带后代)。
49
+ func KillPID(pid int, group bool) error {
50
+ if pid <= 0 {
51
+ return errors.New("invalid pid")
52
+ }
53
+ return exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
54
+ }
55
+
56
+ // PIDAlive 用 tasklist 查该 pid 是否还在。
57
+ func PIDAlive(pid int) bool {
58
+ if pid <= 0 {
59
+ return false
60
+ }
61
+ out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/NH").Output()
62
+ if err != nil {
63
+ return false
64
+ }
65
+ return strings.Contains(string(out), strconv.Itoa(pid))
66
+ }
@@ -0,0 +1,180 @@
1
+ package agent
2
+
3
+ // runner.go - 超时 + 重试编排。
4
+ //
5
+ // Runner 在 Engine 之上叠加跨引擎一致的调用语义:
6
+ //
7
+ // - 每次尝试独立超时(context.WithTimeout),超时错误可重试;
8
+ // - 可重试错误分类:网络类(connection refused / reset / EOF / timeout /
9
+ // signal killed)与限流类(429 / rate limit / 503 overloaded)重试,
10
+ // 其余(参数错、鉴权错、空输出、明确非重试 CLI 错误)快速失败;
11
+ // - 指数退避:attempt N 失败后等待 backoff * 2^(N-1),上限 maxBackoff;
12
+ // 重试等待可被 ctx 取消打断;
13
+ // - 重试次数语义:retries=2 表示「最多 1 + 2 = 3 次尝试」。
14
+ //
15
+ // 使用:
16
+ //
17
+ // r := &agent.Runner{Engine: e, Timeout: 3*time.Minute, Retries: 2}
18
+ // resp, err := r.Run(ctx, agent.Request{...})
19
+
20
+ import (
21
+ "context"
22
+ "errors"
23
+ "fmt"
24
+ "io"
25
+ "math/rand"
26
+ "os"
27
+ "strings"
28
+ "time"
29
+ )
30
+
31
+ // stderr 用于测试替换的 stderr 句柄。
32
+ var stderr io.Writer = os.Stderr
33
+
34
+ // Runner 单引擎的调用编排器。
35
+ type Runner struct {
36
+ // Engine 目标引擎(必填)。
37
+ Engine Engine
38
+
39
+ // Timeout 单次尝试超时;0 = 引擎默认。
40
+ Timeout time.Duration
41
+
42
+ // Retries 额外重试次数(总尝试 = 1 + Retries)。
43
+ Retries int
44
+
45
+ // Backoff 首次重试前等待;之后指数翻倍。0 = 2s。
46
+ Backoff time.Duration
47
+
48
+ // MaxBackoff 退避上限。0 = 30s。
49
+ MaxBackoff time.Duration
50
+
51
+ // Verbose 打印重试过程到 stderr。
52
+ Verbose bool
53
+ }
54
+
55
+ // defaultTimeout 按引擎取默认超时。
56
+ func (r *Runner) defaultTimeout() time.Duration {
57
+ switch r.Engine.(type) {
58
+ case *ClaudeEngine:
59
+ return DefaultClaudeTimeout
60
+ case *CodeBuddyEngine:
61
+ return DefaultCodeBuddyTimeout
62
+ case *CodeBuddyAIEngine:
63
+ return DefaultCodeBuddyTimeout // 同族 CLI,超时语义一致
64
+ case *TraeEngine:
65
+ return DefaultTraeTimeout
66
+ case *LLMEngine:
67
+ return DefaultLLMTimeout
68
+ case *ArkClawEngine:
69
+ return DefaultArkClawTimeout
70
+ case *CodeBuddyGatewayEngine:
71
+ return DefaultCBGatewayTimeout
72
+ case *DshEngine:
73
+ return DefaultDshTimeout
74
+ }
75
+ return 5 * time.Minute
76
+ }
77
+
78
+ // isRetryable 判断错误是否值得重试。
79
+ func isRetryable(err error) bool {
80
+ if err == nil {
81
+ return false
82
+ }
83
+ if errors.Is(err, context.DeadlineExceeded) {
84
+ return true // 单次尝试超时 → 重新来一遍
85
+ }
86
+ msg := strings.ToLower(err.Error())
87
+ for _, kw := range []string{
88
+ "429", "rate limit", "rate_limit", "overloaded",
89
+ "connection refused", "connection reset", "broken pipe",
90
+ "eof", "timeout", "timed out",
91
+ "signal: killed", "context deadline exceeded",
92
+ "temporarily unavailable", "502", "503", "504",
93
+ } {
94
+ if strings.Contains(msg, kw) {
95
+ return true
96
+ }
97
+ }
98
+ return false
99
+ }
100
+
101
+ // Run 执行带超时与重试的调用。ctx 取消/超时整体打断(不算可重试)。
102
+ func (r *Runner) Run(ctx context.Context, req Request) (Response, error) {
103
+ if r.Engine == nil {
104
+ return Response{}, fmt.Errorf("runner: engine is nil")
105
+ }
106
+ if req.Engine == "" {
107
+ req.Engine = r.Engine.Name()
108
+ }
109
+
110
+ timeout := r.Timeout
111
+ if timeout == 0 {
112
+ timeout = r.defaultTimeout()
113
+ }
114
+ backoff := r.Backoff
115
+ if backoff == 0 {
116
+ backoff = 2 * time.Second
117
+ }
118
+ maxBackoff := r.MaxBackoff
119
+ if maxBackoff == 0 {
120
+ maxBackoff = 30 * time.Second
121
+ }
122
+
123
+ totalAttempts := 1 + r.Retries
124
+ var lastErr error
125
+
126
+ for attempt := 1; attempt <= totalAttempts; attempt++ {
127
+ // 整体 ctx 已取消 → 直接终止,不再尝试。
128
+ if err := ctx.Err(); err != nil {
129
+ if lastErr != nil {
130
+ return Response{}, fmt.Errorf("aborted after %d attempts: %w (last error: %v)", attempt-1, err, lastErr)
131
+ }
132
+ return Response{}, err
133
+ }
134
+
135
+ req.Timeout = timeout
136
+ attemptCtx, cancel := context.WithTimeout(ctx, timeout)
137
+ resp, err := r.Engine.Complete(attemptCtx, req)
138
+ cancel()
139
+
140
+ if err == nil {
141
+ resp.Attempts = attempt
142
+ if resp.Engine == "" {
143
+ resp.Engine = req.Engine
144
+ }
145
+ return resp, nil
146
+ }
147
+ lastErr = err
148
+
149
+ if r.Verbose {
150
+ fmt.Fprintf(stderr, " ⏳ %s 第 %d/%d 次尝试失败: %v\n", r.Engine.Name(), attempt, totalAttempts, err)
151
+ }
152
+
153
+ if !isRetryable(err) {
154
+ return Response{}, fmt.Errorf("%w (attempts=%d, non-retryable)", err, attempt)
155
+ }
156
+ if attempt == totalAttempts {
157
+ break
158
+ }
159
+
160
+ // 指数退避 + 少量抖动,等待可被整体 ctx 打断。
161
+ wait := backoff * time.Duration(1<<uint(attempt-1))
162
+ if wait > maxBackoff {
163
+ wait = maxBackoff
164
+ }
165
+ jitter := time.Duration(rand.Int63n(int64(wait)/4 + 1))
166
+ wait += jitter
167
+ if r.Verbose {
168
+ fmt.Fprintf(stderr, " ⏳ %s 将在 %v 后重试...\n", r.Engine.Name(), wait)
169
+ }
170
+ timer := time.NewTimer(wait)
171
+ select {
172
+ case <-ctx.Done():
173
+ timer.Stop()
174
+ return Response{}, fmt.Errorf("aborted during backoff: %w (last error: %v)", ctx.Err(), lastErr)
175
+ case <-timer.C:
176
+ }
177
+ }
178
+
179
+ return Response{}, fmt.Errorf("%s: all %d attempts failed: %w", r.Engine.Name(), totalAttempts, lastErr)
180
+ }