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,486 @@
1
+ package cli
2
+
3
+ // session.go - CLI 侧的会话登记与「停止指定会话」入口。
4
+ //
5
+ // 用户需求(2026-09-18):「要支持停止指定会话」。
6
+ //
7
+ // 三件事:
8
+ // ① 每次调用(非流式 / 流式)都落一条会话记录 —— 见 ask.go 的 beginSession/finishSession;
9
+ // ② `magic-agent --stop <session_id|run_id>`:按记录精确杀掉该会话的引擎进程组;
10
+ // ③ 进程收到 SIGINT/SIGTERM 时,先带走自己的引擎子进程再退出 —— 否则调用方
11
+ // kill 掉 magic-agent 只会留下一个还在跑的引擎孤儿(子 CLI 自成进程组,杀不到)。
12
+ //
13
+ // 登记表本身在 internal/session(无锁、一条一个文件)。
14
+
15
+ import (
16
+ "encoding/json"
17
+ "fmt"
18
+ "io"
19
+ "os"
20
+ "os/signal"
21
+ "path/filepath"
22
+ "strings"
23
+ "sync"
24
+ "syscall"
25
+ "time"
26
+
27
+ "github.com/spf13/cobra"
28
+
29
+ "github.com/darren/magic-agent/internal/agent"
30
+ "github.com/darren/magic-agent/internal/session"
31
+ )
32
+
33
+ // exitInterrupted 被 SIGINT/SIGTERM 终止时的退出码(128 + 2)。
34
+ const exitInterrupted = 130
35
+
36
+ // 当前活动会话(一次进程通常只有一个:CLI 是一次调用一次进程)。
37
+ var (
38
+ activeMu sync.Mutex
39
+ activeSession *session.Handle
40
+ )
41
+
42
+ // setActiveSession 记下当前活动会话(供信号处理路径使用)。
43
+ func setActiveSession(h *session.Handle) {
44
+ activeMu.Lock()
45
+ activeSession = h
46
+ activeMu.Unlock()
47
+ }
48
+
49
+ // clearActiveSession 清掉当前活动会话(只在仍是同一个句柄时清,避免误清后来者)。
50
+ func clearActiveSession(h *session.Handle) {
51
+ activeMu.Lock()
52
+ if activeSession == h {
53
+ activeSession = nil
54
+ }
55
+ activeMu.Unlock()
56
+ }
57
+
58
+ // stopActiveSession 杀掉当前活动会话的引擎进程(信号处理路径用)。
59
+ func stopActiveSession() {
60
+ activeMu.Lock()
61
+ h := activeSession
62
+ activeMu.Unlock()
63
+ if h == nil {
64
+ return
65
+ }
66
+ h.KillChild()
67
+ h.Finish("", session.StateCancelled)
68
+ }
69
+
70
+ // installSignalStop 注册「被终止时先带走引擎子进程」的处理。
71
+ //
72
+ // 为什么必须做:引擎 CLI 在**独立进程组**里(Setpgid),调用方对 magic-agent
73
+ // 发 SIGTERM / kill(-magic-agent-pid) 时,引擎子进程不在那一组里 → 会变成孤儿继续跑
74
+ // (观物台「停止」按钮踩的就是这个)。这里在退出前补一刀。
75
+ func installSignalStop() {
76
+ ch := make(chan os.Signal, 1)
77
+ signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
78
+ go func() {
79
+ <-ch
80
+ stopActiveSession()
81
+ os.Exit(exitInterrupted)
82
+ }()
83
+ }
84
+
85
+ // keepAlive 常驻会话的运行时状态(追加入口 + 空闲收工)。
86
+ //
87
+ // 数据流:`--append` 客户端 → unix socket → Appender.Serve → in → 转发(touch 活动时间)
88
+ // → appendCh → 引擎(写进 claude/codebuddy 的 stdin,成为下一轮 user 消息)。
89
+ // 收工:关闭 in → 转发收尾并关闭 appendCh → 引擎 stdin EOF → 子进程收尾退出。
90
+ type keepAlive struct {
91
+ appender *session.Appender
92
+ appendCh chan string // 引擎读这条
93
+ in chan string // Appender 写这条
94
+
95
+ closeOnce sync.Once
96
+ mu sync.Mutex
97
+ last time.Time
98
+ idle time.Duration // 0 = 本轮结束就收工(追加窗口只在任务运行期间)
99
+ closed bool // 收工后为 true:Push 据此拒收(与 close(in) 同锁,避免向已关闭通道发送)
100
+ }
101
+
102
+ // kaEnabled 常驻会话是否生效:只在「流式 + 引擎支持追加」时才有意义 —— 不满足就静默
103
+ // 不启用(不影响原有调用形态)。显式传 `--keep-alive` 却不满足条件的报错在 prepareAsk
104
+ // 里(那里能拿到 cmd 与引擎)。
105
+ //
106
+ // 默认值语义**按引擎区分**(agent.AppendDefaultOn):claude/codebuddy 默认常驻
107
+ // (用户 2026-09-18 的要求);dsh 默认关、要显式 `--keep-alive` —— 它的 SDK 通道本来
108
+ // 就是一次一轮的形态,默认挂 5 分钟空闲窗口会让既有调用方以为命令卡住了。
109
+ func kaEnabled(cmd *cobra.Command, opts *askOptions, engine string) bool {
110
+ if !opts.stream || !agent.AppendSupportOf(engine) {
111
+ return false
112
+ }
113
+ if flagChanged(cmd, "keep-alive") {
114
+ return opts.keepAlive // 显式开关优先于引擎默认值
115
+ }
116
+ return agent.AppendDefaultOn(engine)
117
+ }
118
+
119
+ // startKeepAlive 启动常驻会话:校验能力 → 开追加入口(写进会话记录)→ 起转发与看门狗。
120
+ func startKeepAlive(cmd *cobra.Command, opts *askOptions, engine string, h *session.Handle) (*keepAlive, error) {
121
+ if !agent.AppendSupportOf(engine) {
122
+ return nil, &usageError{fmt.Errorf(
123
+ "--keep-alive 暂不支持 %s 引擎(当前支持 claude、codebuddy、codebuddy-ai:靠 stream-json 输入持续收 user 消息;"+
124
+ "dsh:靠 SDK 通道对同一会话继续 prompt)", engine)}
125
+ }
126
+ if h == nil {
127
+ return nil, &usageError{fmt.Errorf("--keep-alive 需要会话登记表可用(检查目录:%s)", session.Dir())}
128
+ }
129
+ if opts.idle < 0 {
130
+ return nil, &usageError{fmt.Errorf("--idle 不能为负(收到 %v;0 = 本轮结束就收工)", opts.idle)}
131
+ }
132
+ ap, err := session.ListenAppend(h)
133
+ if err != nil {
134
+ return nil, fmt.Errorf("--keep-alive: %w", err)
135
+ }
136
+ ka := &keepAlive{
137
+ appender: ap,
138
+ appendCh: make(chan string, 16),
139
+ in: make(chan string, 16),
140
+ last: time.Now(),
141
+ idle: opts.idle,
142
+ }
143
+ go ap.Serve(ka.in)
144
+ go func() {
145
+ for msg := range ka.in {
146
+ ka.touch()
147
+ ka.appendCh <- msg
148
+ }
149
+ close(ka.appendCh)
150
+ }()
151
+ if opts.idle > 0 {
152
+ // 空闲看门狗:最后一轮结束后 idle 内没人追加 → 收工。
153
+ tick := opts.idle / 5
154
+ if tick < time.Second {
155
+ tick = time.Second
156
+ }
157
+ go func() {
158
+ t := time.NewTicker(tick)
159
+ defer t.Stop()
160
+ for range t.C {
161
+ if time.Since(ka.activity()) >= opts.idle {
162
+ ka.shutdown()
163
+ return
164
+ }
165
+ }
166
+ }()
167
+ }
168
+ // 提示只在「人看的场景(-o text)」或显式传了 --keep-alive 时打:
169
+ // 默认常驻时保持 stderr 干净,机器调用方(-o json)不受影响。
170
+ if strings.EqualFold(strings.TrimSpace(opts.output), "text") || flagChanged(cmd, "keep-alive") {
171
+ fmt.Fprintf(cmd.ErrOrStderr(),
172
+ "magic-agent: 常驻会话已开启(run_id=%s);可用 `magic-agent --append %s -p \"追加内容\"` 追加,空闲 %v 后自动收工\n",
173
+ h.RunID(), h.RunID(), opts.idle)
174
+ }
175
+ return ka, nil
176
+ }
177
+
178
+ // wrapOnEvent 包一层 onEvent:任何引擎事件都算「有活动」,避免看门狗把在跑的一轮掐掉。
179
+ // idle == 0 时语义是「追加窗口只在任务运行期间」——所以**本轮一结束就收工**。
180
+ func (k *keepAlive) wrapOnEvent(next func(agent.StreamEvent)) func(agent.StreamEvent) {
181
+ return func(ev agent.StreamEvent) {
182
+ k.touch()
183
+ if next != nil {
184
+ next(ev)
185
+ }
186
+ if k.idle == 0 && ev.Kind == agent.KindTurnEnd {
187
+ k.shutdown()
188
+ }
189
+ }
190
+ }
191
+
192
+ // touch 记录活动时间。
193
+ func (k *keepAlive) touch() {
194
+ k.mu.Lock()
195
+ k.last = time.Now()
196
+ k.mu.Unlock()
197
+ }
198
+
199
+ // activity 最近一次活动时间。
200
+ func (k *keepAlive) activity() time.Time {
201
+ k.mu.Lock()
202
+ defer k.mu.Unlock()
203
+ return k.last
204
+ }
205
+
206
+ // shutdown 收工:关闭输入通道(幂等)→ 引擎 stdin 随之 EOF。
207
+ //
208
+ // ⚠️ closed 与 close(k.in) 必须在**同一把锁**里完成:Push 持锁发送,
209
+ // 否则「Push 判定未关闭 → shutdown 关通道 → Push 发送」会 panic(向已关闭通道发送)。
210
+ func (k *keepAlive) shutdown() {
211
+ k.closeOnce.Do(func() {
212
+ k.mu.Lock()
213
+ defer k.mu.Unlock()
214
+ k.closed = true
215
+ close(k.in)
216
+ })
217
+ }
218
+
219
+ // Push 把一条 user 消息推进常驻会话(与 --append 走同一条入口)。
220
+ //
221
+ // 用途:`--control` 的 answer 命令 —— 审批 / 提问的答案只能作为**下一轮 user 消息**
222
+ // 喂给模型(headless 下原地作答拿不到答案,见 agent.KindAsk)。
223
+ // 返回 false = 常驻已收工或缓冲区已满;调用方据此回一条 control_error,不静默丢。
224
+ func (k *keepAlive) Push(msg string) bool {
225
+ if k == nil || strings.TrimSpace(msg) == "" {
226
+ return false
227
+ }
228
+ k.mu.Lock()
229
+ defer k.mu.Unlock()
230
+ if k.closed {
231
+ return false
232
+ }
233
+ select {
234
+ case k.in <- msg:
235
+ k.last = time.Now() // 与 touch() 同义;这里已持锁,不能再调 touch()
236
+ return true
237
+ default:
238
+ return false
239
+ }
240
+ }
241
+
242
+ // Close 关掉追加入口(幂等)。
243
+ func (k *keepAlive) Close() {
244
+ if k == nil {
245
+ return
246
+ }
247
+ k.shutdown()
248
+ k.appender.Close()
249
+ }
250
+
251
+ // runAppend 向常驻会话追加一条消息(内容用 -p 或位置参数给)。
252
+ //
253
+ // 退出码:0 已投递;1 找不到会话 / 会话没有追加入口 / 常驻进程已退出;2 参数错。
254
+ func runAppend(cmd *cobra.Command, args []string, opts *askOptions) error {
255
+ id := strings.TrimSpace(opts.appendTo)
256
+ if id == "" {
257
+ return &usageError{fmt.Errorf("--append 需要一个 session_id 或 run_id(--sessions 可列出全部)")}
258
+ }
259
+ text := strings.TrimSpace(opts.prompt)
260
+ if text == "" {
261
+ text = strings.TrimSpace(strings.Join(args, " "))
262
+ }
263
+ if text == "" {
264
+ return &usageError{fmt.Errorf("--append 需要追加内容:用 -p \"...\" 或位置参数给")}
265
+ }
266
+
267
+ res, err := session.AppendMessage(id, text)
268
+ if err != nil {
269
+ return fmt.Errorf("--append %s: %w", id, err)
270
+ }
271
+ if opts.output == "text" {
272
+ _, _ = fmt.Fprintf(cmd.OutOrStdout(), "magic-agent: 已追加到会话 %s(%s)\n", pickSessionName(res.Record), res.Record.Engine)
273
+ return nil
274
+ }
275
+ out := struct {
276
+ Type string `json:"type"`
277
+ ID string `json:"id"`
278
+ Queued bool `json:"queued"`
279
+ RunID string `json:"run_id"`
280
+ SessionID string `json:"session_id,omitempty"`
281
+ Engine string `json:"engine"`
282
+ State string `json:"state"`
283
+ Reason string `json:"reason,omitempty"`
284
+ }{
285
+ Type: "append",
286
+ ID: id,
287
+ Queued: res.Queued,
288
+ RunID: res.Record.RunID,
289
+ SessionID: res.Record.SessionID,
290
+ Engine: res.Record.Engine,
291
+ State: res.Record.State,
292
+ Reason: res.Reason,
293
+ }
294
+ return printJSON(cmd.OutOrStdout(), out)
295
+ }
296
+
297
+ // pickSessionName 记录的可读名字(优先会话 id)。
298
+ func pickSessionName(rec session.Record) string {
299
+ if rec.SessionID != "" {
300
+ return rec.SessionID
301
+ }
302
+ return rec.RunID
303
+ }
304
+
305
+ // runSessions 列出会话登记表(JSON 数组,新→旧)。
306
+ func runSessions(cmd *cobra.Command, opts *askOptions) error {
307
+ records, err := session.List()
308
+ if err != nil {
309
+ return err
310
+ }
311
+ if records == nil {
312
+ records = []session.Record{}
313
+ }
314
+ type row struct {
315
+ RunID string `json:"run_id"`
316
+ SessionID string `json:"session_id,omitempty"`
317
+ PID int `json:"pid"`
318
+ Engine string `json:"engine"`
319
+ Model string `json:"model,omitempty"`
320
+ State string `json:"state"`
321
+ Alive bool `json:"alive"`
322
+ Workspace string `json:"workspace,omitempty"`
323
+ PromptHead string `json:"prompt_head,omitempty"`
324
+ StartedAt string `json:"started_at"`
325
+ UpdatedAt string `json:"updated_at"`
326
+ // HasLog 这条会话有没有**可回放的事件日志**(`--session-log` 读的就是它)。
327
+ // 为什么让 CLI 来说这件事:日志**只在流式调用时写**(见 sessionlog.go 的 sessionWriter),
328
+ // 所以非流式跑出来的会话没有历史可回放 —— 客户端要拿它判断「这条点开有东西看吗」,
329
+ // 而让客户端自己去拼日志路径就是把本仓库的内部布局当接口用。
330
+ HasLog bool `json:"has_log"`
331
+ }
332
+ logDir, _ := sessionLogDir()
333
+ rows := make([]row, 0, len(records))
334
+ for _, r := range records {
335
+ rows = append(rows, row{
336
+ RunID: r.RunID,
337
+ SessionID: r.SessionID,
338
+ PID: r.PID,
339
+ Engine: r.Engine,
340
+ Model: r.Model,
341
+ State: r.State,
342
+ Alive: r.State == session.StateRunning,
343
+ Workspace: r.Workspace,
344
+ PromptHead: r.PromptHead,
345
+ StartedAt: r.StartedAt.Format("2006-01-02 15:04:05"),
346
+ UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
347
+ HasLog: hasSessionLog(logDir, r.SessionID),
348
+ })
349
+ }
350
+ return printJSON(cmd.OutOrStdout(), rows)
351
+ }
352
+
353
+ // hasSessionLog 这条记录有没有事件日志:日志按 `session_id` 命名,所以没有 id 的
354
+ // (一次性调用)自然没有;还要看文件在不在、非空 —— 空文件等于没有可回放的内容。
355
+ func hasSessionLog(dir, sessionID string) bool {
356
+ if dir == "" || sessionID == "" {
357
+ return false
358
+ }
359
+ st, err := os.Stat(filepath.Join(dir, sessionID+".jsonl"))
360
+ return err == nil && st.Size() > 0
361
+ }
362
+
363
+ // runSessionLog 读一段会话的**事件历史**:`--session-log <id> [--after <seq>]`。
364
+ //
365
+ // 为什么这条命令必须存在:会话的「查询」原先只有 HTTP 插件能做(`/desk/session/{id}/messages`),
366
+ // 而 magic-agent **自己不跑服务** —— 调用方(掌天瓶等)是 exec 本 CLI 来拿数据的。
367
+ // 缺了它,调用方就只能自己去解析 <session_id>.jsonl 的磁盘格式,那是把内部格式当接口用。
368
+ //
369
+ // 输出形状与插件的同名接口**逐字段对齐**(全量 {session,events};增量另带
370
+ // count / after / lastSeq / snapshotRequired),这样两条路可以互换,调用方只写一份解析。
371
+ //
372
+ // 退出码:0 = 读到了(没有事件也是 0,空历史不是错);1 = 读失败;2 = 参数错(空 id)。
373
+ func runSessionLog(cmd *cobra.Command, opts *askOptions) error {
374
+ id := strings.TrimSpace(opts.sessionLog)
375
+ if id == "" {
376
+ return &usageError{fmt.Errorf("--session-log 需要会话 id:传 session_id 或 run_id(--sessions 可列出全部)")}
377
+ }
378
+ dir, err := sessionLogDir()
379
+ if err != nil {
380
+ return fmt.Errorf("--session-log %s: 找不到历史目录: %w", id, err)
381
+ }
382
+
383
+ if flagChanged(cmd, "after") {
384
+ evs, maxSeq, err := ReadSessionLogAfter(dir, id, opts.logAfter)
385
+ if err != nil {
386
+ return fmt.Errorf("--session-log %s: %w", id, err)
387
+ }
388
+ if evs == nil {
389
+ evs = []sessionEvent{}
390
+ }
391
+ return printJSON(cmd.OutOrStdout(), struct {
392
+ Session string `json:"session"`
393
+ Events []sessionEvent `json:"events"`
394
+ Count int `json:"count"`
395
+ After uint64 `json:"after"`
396
+ LastSeq uint64 `json:"lastSeq"`
397
+ // 调用方游标比磁盘还新 ⇒ 增量不可信,去拉全量。
398
+ // ⚠️ 判据是**严格大于**:after == lastSeq 是「已经追平」,那是正常的空增量。
399
+ SnapshotRequired bool `json:"snapshotRequired"`
400
+ }{Session: id, Events: evs, Count: len(evs), After: opts.logAfter,
401
+ LastSeq: maxSeq, SnapshotRequired: opts.logAfter > maxSeq})
402
+ }
403
+
404
+ evs, err := ReadSessionLog(dir, id)
405
+ if err != nil {
406
+ return fmt.Errorf("--session-log %s: %w", id, err)
407
+ }
408
+ if evs == nil {
409
+ evs = []sessionEvent{}
410
+ }
411
+ return printJSON(cmd.OutOrStdout(), struct {
412
+ Session string `json:"session"`
413
+ Events []sessionEvent `json:"events"`
414
+ }{Session: id, Events: evs})
415
+ }
416
+
417
+ // runStop 停止指定会话:session_id 或 run_id 都收。
418
+ //
419
+ // 退出码约定:
420
+ //
421
+ // 0 已停掉,或本来就没事(进程已不在 / 会话已结束)—— 幂等,重复点「停止」不算错
422
+ // 1 找不到该 id(多半是写错了;--sessions 可列全部)
423
+ // 2 参数错(空 id 等)
424
+ func runStop(cmd *cobra.Command, opts *askOptions) error {
425
+ id := strings.TrimSpace(opts.stop)
426
+ if id == "" {
427
+ return &usageError{fmt.Errorf("--stop 需要一个 session_id 或 run_id(--sessions 可列出全部)")}
428
+ }
429
+ res, err := session.Stop(id)
430
+ if err != nil {
431
+ return fmt.Errorf("--stop %s: %w", id, err)
432
+ }
433
+
434
+ if opts.output == "text" {
435
+ _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", stopText(res))
436
+ return nil
437
+ }
438
+ out := struct {
439
+ Type string `json:"type"`
440
+ ID string `json:"id"`
441
+ Stopped bool `json:"stopped"`
442
+ RunID string `json:"run_id"`
443
+ SessionID string `json:"session_id,omitempty"`
444
+ PID int `json:"pid"`
445
+ Engine string `json:"engine"`
446
+ State string `json:"state"`
447
+ Reason string `json:"reason,omitempty"`
448
+ }{
449
+ Type: "stop",
450
+ ID: id,
451
+ Stopped: res.Stopped,
452
+ RunID: res.Record.RunID,
453
+ SessionID: res.Record.SessionID,
454
+ PID: res.Record.PID,
455
+ Engine: res.Record.Engine,
456
+ State: res.Record.State,
457
+ Reason: res.Reason,
458
+ }
459
+ return writeJSONLine(cmd.OutOrStdout(), out)
460
+ }
461
+
462
+ // stopText 人看的停止结果。
463
+ func stopText(res session.StopResult) string {
464
+ who := res.Record.SessionID
465
+ if who == "" {
466
+ who = res.Record.RunID
467
+ }
468
+ if res.Stopped {
469
+ return fmt.Sprintf("magic-agent: 已停止会话 %s(%s pid=%d)", who, res.Record.Engine, res.Record.PID)
470
+ }
471
+ reason := res.Reason
472
+ if reason == "" {
473
+ reason = "无需停止"
474
+ }
475
+ return fmt.Sprintf("magic-agent: 会话 %s 未在运行:%s(state=%s)", who, reason, res.Record.State)
476
+ }
477
+
478
+ // writeJSONLine 写一行 JSON(复用 printJSON 的编码方式,单独留个名便于阅读)。
479
+ func writeJSONLine(w io.Writer, v any) error {
480
+ data, err := json.Marshal(v)
481
+ if err != nil {
482
+ return err
483
+ }
484
+ _, err = fmt.Fprintln(w, string(data))
485
+ return err
486
+ }
@@ -0,0 +1,202 @@
1
+ package cli
2
+
3
+ // session_test.go - CLI 侧「停止指定会话」的测试。
4
+ //
5
+ // 用户需求(2026-09-18):「要支持停止指定会话」。
6
+ //
7
+ // 覆盖:--sessions 列出登记表 / --stop 的三种结果(真停掉 / 已结束幂等 / 未知 id 报错)/
8
+ // --stop "" 的参数校验 / 每次调用都会落一条记录(state=done 且回填 session_id)/
9
+ // 引擎子进程 pid 经 spawn 钩子写进记录(--stop 才能杀对进程)。
10
+
11
+ import (
12
+ "context"
13
+ "encoding/json"
14
+ "os"
15
+ "strings"
16
+ "testing"
17
+
18
+ "github.com/darren/magic-agent/internal/agent"
19
+ "github.com/darren/magic-agent/internal/session"
20
+ )
21
+
22
+ // sessionRows 解析 --sessions 的 JSON 输出。
23
+ type sessionRow struct {
24
+ RunID string `json:"run_id"`
25
+ SessionID string `json:"session_id"`
26
+ PID int `json:"pid"`
27
+ Engine string `json:"engine"`
28
+ State string `json:"state"`
29
+ Alive bool `json:"alive"`
30
+ }
31
+
32
+ func parseSessions(t *testing.T, out string) []sessionRow {
33
+ t.Helper()
34
+ var rows []sessionRow
35
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
36
+ t.Fatalf("--sessions 输出不是 JSON 数组: %v (%s)", err, out)
37
+ }
38
+ return rows
39
+ }
40
+
41
+ // 一次普通调用结束后,登记表里应留下一条 done 记录,并回填 session_id。
42
+ func TestAskRegistersSession(t *testing.T) {
43
+ dir := t.TempDir()
44
+ t.Setenv(session.EnvDir, dir)
45
+ registerFake(&stringEngine{name: "fake-sess-reg", text: "ok"})
46
+
47
+ if _, _, err := runAskCmd(t, "", "-e", "fake-sess-reg", "你好"); err != nil {
48
+ t.Fatalf("ask: %v", err)
49
+ }
50
+
51
+ rows := parseSessions(t, mustSessions(t))
52
+ if len(rows) != 1 {
53
+ t.Fatalf("登记表条数 = %d want 1: %+v", len(rows), rows)
54
+ }
55
+ r := rows[0]
56
+ if r.Engine != "fake-sess-reg" || r.State != session.StateDone {
57
+ t.Errorf("记录不对: %+v", r)
58
+ }
59
+ if r.Alive {
60
+ t.Error("已结束的会话不该标 alive")
61
+ }
62
+ // 记录文件确实落在指定目录
63
+ entries, _ := os.ReadDir(dir)
64
+ if len(entries) != 1 {
65
+ t.Errorf("目录里文件数 = %d want 1", len(entries))
66
+ }
67
+ }
68
+
69
+ // 流式调用同样落记录。
70
+ func TestStreamAskRegistersSession(t *testing.T) {
71
+ dir := t.TempDir()
72
+ t.Setenv(session.EnvDir, dir)
73
+ registerFake(&streamingEngine{name: "fake-sess-stream", text: "ok"})
74
+
75
+ if _, _, err := runAskCmd(t, "", "-e", "fake-sess-stream", "--stream", "hi"); err != nil {
76
+ t.Fatalf("stream ask: %v", err)
77
+ }
78
+ rows := parseSessions(t, mustSessions(t))
79
+ if len(rows) != 1 || rows[0].State != session.StateDone {
80
+ t.Fatalf("流式调用也该落一条 done 记录: %+v", rows)
81
+ }
82
+ }
83
+
84
+ // 引擎返回的 session_id 要回填进记录(这样「停这条会话」能用会话 id 寻址)。
85
+ //
86
+ // 注:这里**不能**用真引擎来验「子进程 pid 被写进记录」—— cli 包测试进程里的注册表
87
+ // 是假的(先 Register 了假引擎,agent.Engines() 就不会 initEngines(),真引擎根本不在)。
88
+ // spawn 钩子本身在 internal/agent 用真引擎 + 假 CLI 验(TestSpawnHookReceivesChildPID)。
89
+ func TestAskBackfillsSessionID(t *testing.T) {
90
+ dir := t.TempDir()
91
+ t.Setenv(session.EnvDir, dir)
92
+ registerFake(&sidEngine{name: "fake-sess-sid", text: "ok", sid: "sid-from-engine"})
93
+
94
+ if _, _, err := runAskCmd(t, "", "-e", "fake-sess-sid", "hi"); err != nil {
95
+ t.Fatalf("ask: %v", err)
96
+ }
97
+ rows := parseSessions(t, mustSessions(t))
98
+ if len(rows) != 1 {
99
+ t.Fatalf("登记表条数 = %d want 1", len(rows))
100
+ }
101
+ if rows[0].SessionID != "sid-from-engine" {
102
+ t.Errorf("session_id = %q want sid-from-engine(收尾要回填)", rows[0].SessionID)
103
+ }
104
+ // 用会话 id 也能 --stop 找到它(此处已结束 → 幂等,不报错)
105
+ if _, _, err := runAskCmd(t, "", "--stop", "sid-from-engine"); err != nil {
106
+ t.Errorf("按 session_id 停止不该报错: %v", err)
107
+ }
108
+ }
109
+
110
+ // sidEngine 固定返回一个 session_id 的假引擎(验回填)。
111
+ type sidEngine struct {
112
+ name string
113
+ text string
114
+ sid string
115
+ }
116
+
117
+ func (s *sidEngine) Name() string { return s.name }
118
+ func (s *sidEngine) Detect() (bool, string) { return true, "fake://" + s.name }
119
+ func (s *sidEngine) Complete(ctx context.Context, req agent.Request) (agent.Response, error) {
120
+ return agent.Response{Text: s.text, Model: "fake-model", SessionID: s.sid, Latency: 1_000_000}, nil
121
+ }
122
+
123
+ // --stop:未知 id → 报错(exit 1 语义,不是 usageError)。
124
+ func TestStopUnknownIDErrors(t *testing.T) {
125
+ t.Setenv(session.EnvDir, t.TempDir())
126
+ _, _, err := runAskCmd(t, "", "--stop", "no-such-session")
127
+ if err == nil {
128
+ t.Fatal("未知 id 应报错")
129
+ }
130
+ if _, ok := err.(*usageError); ok {
131
+ t.Errorf("未知 id 不是参数错(不该 exit 2): %v", err)
132
+ }
133
+ if !strings.Contains(err.Error(), "no such session") {
134
+ t.Errorf("错误信息应点明找不到: %v", err)
135
+ }
136
+ }
137
+
138
+ // --stop 空值 → 参数错(exit 2),而不是掉进「提问」分支报 empty prompt。
139
+ func TestStopEmptyIDIsUsageError(t *testing.T) {
140
+ t.Setenv(session.EnvDir, t.TempDir())
141
+ _, _, err := runAskCmd(t, "", "--stop", "")
142
+ if err == nil {
143
+ t.Fatal("应报错")
144
+ }
145
+ if _, ok := err.(*usageError); !ok {
146
+ t.Errorf("空 id 应是 usageError(exit 2), got %T: %v", err, err)
147
+ }
148
+ }
149
+
150
+ // --stop:已结束的会话 → 幂等(exit 0,stopped=false + reason)。
151
+ func TestStopFinishedSessionIsIdempotent(t *testing.T) {
152
+ dir := t.TempDir()
153
+ t.Setenv(session.EnvDir, dir)
154
+ registerFake(&stringEngine{name: "fake-sess-fin", text: "ok"})
155
+
156
+ if _, _, err := runAskCmd(t, "", "-e", "fake-sess-fin", "hi"); err != nil {
157
+ t.Fatalf("ask: %v", err)
158
+ }
159
+ runID := parseSessions(t, mustSessions(t))[0].RunID
160
+
161
+ stdout, _, err := runAskCmd(t, "", "--stop", runID)
162
+ if err != nil {
163
+ t.Fatalf("停一条已结束的会话不该报错: %v", err)
164
+ }
165
+ var out struct {
166
+ Type string `json:"type"`
167
+ Stopped bool `json:"stopped"`
168
+ State string `json:"state"`
169
+ Reason string `json:"reason"`
170
+ }
171
+ if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &out); err != nil {
172
+ t.Fatalf("--stop 输出不是 JSON: %v (%s)", err, stdout)
173
+ }
174
+ if out.Type != "stop" || out.Stopped {
175
+ t.Errorf("应 stopped=false: %+v", out)
176
+ }
177
+ if !strings.Contains(out.Reason, "已结束") {
178
+ t.Errorf("reason = %q", out.Reason)
179
+ }
180
+ }
181
+
182
+ // --sessions 空登记表 → 空数组(不是 null,调用方好解析)。
183
+ func TestSessionsEmptyIsArray(t *testing.T) {
184
+ t.Setenv(session.EnvDir, t.TempDir())
185
+ out, _, err := runAskCmd(t, "", "--sessions")
186
+ if err != nil {
187
+ t.Fatalf("--sessions: %v", err)
188
+ }
189
+ if got := strings.TrimSpace(out); got != "[]" {
190
+ t.Errorf("空登记表应输出 [], got %q", got)
191
+ }
192
+ }
193
+
194
+ // mustSessions 跑 --sessions 并返回 stdout。
195
+ func mustSessions(t *testing.T) string {
196
+ t.Helper()
197
+ out, _, err := runAskCmd(t, "", "--sessions")
198
+ if err != nil {
199
+ t.Fatalf("--sessions: %v", err)
200
+ }
201
+ return out
202
+ }