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,447 @@
1
+ // Package session - 会话登记表:让「停止指定会话」成为可能。
2
+ //
3
+ // 用户需求(2026-09-18):「要支持停止指定会话」。
4
+ //
5
+ // 为什么需要一张登记表:magic-agent 把引擎 CLI 放在**独立进程组**里跑
6
+ // (Setpgid,见 agent/runcmd.go),所以它和 magic-agent 自己不在同一组 ——
7
+ // 调用方(观物台等)`kill(-magic-agent-pid)` 带不走引擎子进程,会出现
8
+ // 「界面已停止、引擎还在跑」。更麻烦的是进程句柄只活在调用方内存里:
9
+ // 应用重启后,之前 detach 出去的会话就再也停不掉了。
10
+ //
11
+ // 于是每次调用都落一条记录(引擎子进程 pid + 引擎/模型/工作目录 + 会话 id),
12
+ // 之后任何进程都能用 `magic-agent --stop <session_id|run_id>` 精确停掉它。
13
+ //
14
+ // 文件位置:$MAGIC_AGENT_SESSIONS > ~/.magic-agent/sessions/<run_id>.json
15
+ // 每条一个文件(无锁、无共享写),过期记录(默认 24h)在 Begin/List/Stop 时顺手清理。
16
+ //
17
+ // 记录里 session_id 可能一开始不知道:引擎的会话 id 只在**收尾行**里给
18
+ // (见 magic-agent 的 result envelope),所以:
19
+ //
20
+ // 开始时就已知(--session <id> 续接)→ 直接写进记录
21
+ // 开始时未知(新会话) → 结束时回填(Finish),期间用 run_id 寻址
22
+ //
23
+ // 因此 --stop 接受两种 id:会话 id(session_id)或运行 id(run_id,`--sessions` 可见)。
24
+ package session
25
+
26
+ import (
27
+ "encoding/json"
28
+ "fmt"
29
+ "os"
30
+ "path/filepath"
31
+ "sort"
32
+ "strconv"
33
+ "strings"
34
+ "time"
35
+
36
+ "github.com/darren/magic-agent/internal/agent"
37
+ )
38
+
39
+ // EnvDir 覆盖登记表目录的环境变量名。
40
+ const EnvDir = "MAGIC_AGENT_SESSIONS"
41
+
42
+ // Retention 记录保留时长:过期的(按 UpdatedAt)在每次读写时清理。
43
+ // 留着而不是用完即删,是为了让「停一条早就结束的会话」能答出「已结束」而不是「没找到」。
44
+ const Retention = 24 * time.Hour
45
+
46
+ // 记录状态。
47
+ //
48
+ // ⚠️ 词表对齐 agents-anywhere 的 `TimelineStatus`(2026-09-29):同一个意思在两个
49
+ // 界面上说同一个词,才谈得上「统一」。**只统一会话侧**(这个包的记录回答的是
50
+ // 「一条会话活得怎么样」);需求看板那套(待验证 / 已完成…)是另一个轴,不动。
51
+ //
52
+ // 三处细分与 anywhere 对齐(此前都挤在 stopped / failed 里):
53
+ //
54
+ // · cancelled 被 `--stop` 停掉 —— 调用方**主动取消**这条会话
55
+ // · interrupted 用户**中途打断当前轮**(`--control interrupt`),会话本身仍可继续
56
+ // · waiting_approval 引擎停下来等用户(提问 / 工具待授权)—— 会话还活着,但没在跑,
57
+ // 界面上该显示「等审批」而不是「生成中」
58
+ const (
59
+ StateRunning = "running" // 正在跑
60
+ StateWaitingApproval = "waiting_approval" // 卡在等授权 / 等用户回答
61
+ StateDone = "done" // 正常结束
62
+ StateFailed = "failed" // 报错结束
63
+ StateCancelled = "cancelled" // 被 --stop 取消
64
+ StateInterrupted = "interrupted" // 当前轮被打断(会话仍可继续)
65
+ StateGone = "gone" // 记录里是 running,但进程已不在(自然退出/被别处杀掉)
66
+ )
67
+
68
+ // normalizeState 把磁盘上的**旧状态值**归一成当前词表。
69
+ //
70
+ // 为什么必须有:`stopped` 在 2026-09-29 之前由 `--stop` 写入,一次性全部改写磁盘上的
71
+ // 记录既没必要也不安全(还有别的版本在读同一份目录)。所以在**读**的时候翻。
72
+ // 认不出来的值原样返回 —— 宁可显示一个陌生词,也不要把它悄悄改成别的意思。
73
+ func normalizeState(s string) string {
74
+ if s == "stopped" {
75
+ return StateCancelled
76
+ }
77
+ return s
78
+ }
79
+
80
+ // isTerminalOverride 这个已落盘的状态是否**优先于**收尾时的 failed。
81
+ // 它记的是「谁、以什么方式结束了它」,比一句 failed 信息量更大。
82
+ // (readRecord 已把老值 stopped 归一成 cancelled,这里不必再认 stopped。)
83
+ func isTerminalOverride(s string) bool {
84
+ return s == StateCancelled || s == StateInterrupted
85
+ }
86
+
87
+ // Record 一条会话记录(也是磁盘上的 JSON 形状)。
88
+ type Record struct {
89
+ RunID string `json:"run_id"`
90
+ SessionID string `json:"session_id,omitempty"`
91
+ PID int `json:"pid"`
92
+ KillGroup bool `json:"kill_group"` // true = 按进程组杀(引擎子进程自成组)
93
+ Engine string `json:"engine"`
94
+ Model string `json:"model,omitempty"`
95
+ Workspace string `json:"workspace,omitempty"`
96
+ PromptHead string `json:"prompt_head,omitempty"`
97
+ State string `json:"state"`
98
+ StartedAt time.Time `json:"started_at"`
99
+ UpdatedAt time.Time `json:"updated_at"`
100
+
101
+ // Append 追加入口的 unix socket 路径(常驻会话 / --keep-alive 才有)。
102
+ // 空 = 该会话不可追加(--append 会明确告知原因,不静默)。
103
+ Append string `json:"append,omitempty"`
104
+ }
105
+
106
+ // Dir 返回登记表目录($MAGIC_AGENT_SESSIONS 优先)。
107
+ func Dir() string {
108
+ if p := strings.TrimSpace(os.Getenv(EnvDir)); p != "" {
109
+ return p
110
+ }
111
+ home, err := os.UserHomeDir()
112
+ if err != nil || home == "" {
113
+ return ""
114
+ }
115
+ return filepath.Join(home, ".magic-agent", "sessions")
116
+ }
117
+
118
+ // BeginOptions 开始一条会话记录所需的输入。
119
+ type BeginOptions struct {
120
+ Engine string
121
+ Model string
122
+ Workspace string
123
+ SessionID string // 续接时已知;新会话为空
124
+ PromptHead string // 提示词开头(便于人辨认)
125
+ PID int // 可先填自身 pid 兜底(引擎子进程起来后会被 SetPID 覆盖)
126
+ }
127
+
128
+ // Handle 一条运行中会话的句柄(负责回写 pid 与收尾状态)。
129
+ type Handle struct {
130
+ rec Record
131
+ path string
132
+ }
133
+
134
+ // RunID 本次运行的 id。
135
+ func (h *Handle) RunID() string { return h.rec.RunID }
136
+
137
+ // Record 当前记录快照。
138
+ func (h *Handle) Record() Record { return h.rec }
139
+
140
+ // SetPID 记录引擎子进程 pid(由 agent.WithSpawnHook 在 Start 之后回调)。
141
+ // group=true 表示该 pid 自成进程组(引擎 CLI 的正常情况)。
142
+ func (h *Handle) SetPID(pid int, group bool) {
143
+ if h == nil || pid <= 0 {
144
+ return
145
+ }
146
+ h.rec.PID = pid
147
+ h.rec.KillGroup = group
148
+ h.write()
149
+ }
150
+
151
+ // SetSessionID 回填会话 id(续接时开始就知道;新会话在收尾时才知道)。
152
+ func (h *Handle) SetSessionID(id string) {
153
+ if h == nil || strings.TrimSpace(id) == "" || h.rec.SessionID == id {
154
+ return
155
+ }
156
+ h.rec.SessionID = strings.TrimSpace(id)
157
+ h.write()
158
+ }
159
+
160
+ // Mark 就地改状态(只在 running 与 waiting_approval 之间来回时生效)。
161
+ //
162
+ // 为什么需要一个**中途**写状态的入口:引擎会停下来等用户(提问 / 工具待授权),此时这条
163
+ // 会话还活着,但**没在跑** —— 界面上该显示「等审批」而不是「生成中」。这是 2026-09-29
164
+ // 统一会话状态词表时新增的写入点(值取自 anywhere 的 waiting_approval)。
165
+ //
166
+ // ⚠️ 只允许在这两个值之间切:一旦写进终态(done / failed / cancelled / interrupted),
167
+ //
168
+ // 这里不许改回去。终态是「这条会话结束了」的结论,让一个迟到的流事件把它翻回
169
+ // running,等于让历史撒谎。
170
+ func (h *Handle) Mark(state string) {
171
+ if h == nil || state == "" {
172
+ return
173
+ }
174
+ cur := normalizeState(h.rec.State)
175
+ if cur != StateRunning && cur != StateWaitingApproval {
176
+ return
177
+ }
178
+ if state != StateRunning && state != StateWaitingApproval {
179
+ return
180
+ }
181
+ if cur == state {
182
+ return
183
+ }
184
+ h.rec.State = state
185
+ h.write()
186
+ }
187
+
188
+ // Finish 收尾:写最终状态(done / failed / cancelled / interrupted)+ 回填会话 id。
189
+ //
190
+ // 一个刻意的小保护:被别人 `--stop` 停掉、或用户中途打断时,磁盘上已经是
191
+ // cancelled / interrupted;本进程随后因被杀而以 failed 收尾 —— 不能把它覆盖成
192
+ // failed,否则「是谁、以什么方式结束的」就丢了。
193
+ // ⚠️ 2026-09-29 起保护名单扩到两个值(此前只有 stopped):对齐 anywhere 的
194
+ //
195
+ // TimelineStatus 时把「取消」与「打断」分了开,保护漏一个就等于白分。
196
+ func (h *Handle) Finish(sessionID, state string) {
197
+ if h == nil {
198
+ return
199
+ }
200
+ if state == StateFailed {
201
+ if cur, err := readRecord(h.path); err == nil && isTerminalOverride(cur.State) {
202
+ state = cur.State
203
+ }
204
+ }
205
+ if s := strings.TrimSpace(sessionID); s != "" {
206
+ h.rec.SessionID = s
207
+ }
208
+ if state != "" {
209
+ h.rec.State = state
210
+ }
211
+ h.write()
212
+ }
213
+
214
+ // KillChild 立即杀掉本条会话的进程(进程组 / 单进程),用于信号处理路径。
215
+ func (h *Handle) KillChild() {
216
+ if h == nil || h.rec.PID <= 0 {
217
+ return
218
+ }
219
+ _ = agent.KillPID(h.rec.PID, h.rec.KillGroup)
220
+ }
221
+
222
+ // write 落盘(失败静默:登记表只是可运维性,不能因为它写不动就让调用失败)。
223
+ func (h *Handle) write() {
224
+ h.rec.UpdatedAt = time.Now()
225
+ data, err := json.MarshalIndent(h.rec, "", " ")
226
+ if err != nil {
227
+ return
228
+ }
229
+ dir := filepath.Dir(h.path)
230
+ if err := os.MkdirAll(dir, 0o700); err != nil {
231
+ return
232
+ }
233
+ // 先写临时文件再改名:避免读到写了一半的 JSON。
234
+ tmp := h.path + ".tmp"
235
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
236
+ return
237
+ }
238
+ _ = os.Rename(tmp, h.path)
239
+ }
240
+
241
+ // Begin 新建一条 running 记录。
242
+ func Begin(o BeginOptions) (*Handle, error) {
243
+ dir := Dir()
244
+ if dir == "" {
245
+ return nil, fmt.Errorf("cannot resolve sessions dir")
246
+ }
247
+ if err := os.MkdirAll(dir, 0o700); err != nil {
248
+ return nil, err
249
+ }
250
+ prune(dir)
251
+
252
+ now := time.Now()
253
+ rec := Record{
254
+ // run_id 用 base36 压短:它同时决定 socket 文件名,而 unix socket 路径有
255
+ // 硬上限(macOS sun_path 104 字节),长 id + 长 HOME 会直接 bind 失败。
256
+ RunID: "run-" + strconv.FormatInt(now.UnixNano(), 36) + "-" + strconv.FormatInt(int64(os.Getpid()), 36),
257
+ SessionID: strings.TrimSpace(o.SessionID),
258
+ PID: o.PID,
259
+ KillGroup: false, // 自身 pid 兜底时按单进程杀(不能杀调用方的进程组)
260
+ Engine: o.Engine,
261
+ Model: o.Model,
262
+ Workspace: o.Workspace,
263
+ PromptHead: head(o.PromptHead, 80),
264
+ State: StateRunning,
265
+ StartedAt: now,
266
+ UpdatedAt: now,
267
+ }
268
+ h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json")}
269
+ h.write()
270
+ return h, nil
271
+ }
272
+
273
+ // head 截取开头 n 个字符(按 rune,避免把中文切坏)。
274
+ func head(s string, n int) string {
275
+ s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
276
+ r := []rune(s)
277
+ if len(r) <= n {
278
+ return s
279
+ }
280
+ return string(r[:n]) + "…"
281
+ }
282
+
283
+ // List 返回全部记录(新的在前);顺带清理过期记录。
284
+ func List() ([]Record, error) {
285
+ dir := Dir()
286
+ if dir == "" {
287
+ return nil, fmt.Errorf("cannot resolve sessions dir")
288
+ }
289
+ prune(dir)
290
+ entries, err := os.ReadDir(dir)
291
+ if err != nil {
292
+ if os.IsNotExist(err) {
293
+ return nil, nil
294
+ }
295
+ return nil, err
296
+ }
297
+ out := make([]Record, 0, len(entries))
298
+ for _, e := range entries {
299
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
300
+ continue
301
+ }
302
+ if rec, err := readRecord(filepath.Join(dir, e.Name())); err == nil {
303
+ out = append(out, rec)
304
+ }
305
+ }
306
+ sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) })
307
+ return out, nil
308
+ }
309
+
310
+ // Find 按 run_id 或 session_id 精确匹配一条记录。
311
+ // 同 id 有多条时优先「running」的那条,其次取最新。
312
+ func Find(id string) (Record, bool, error) {
313
+ want := strings.TrimSpace(id)
314
+ if want == "" {
315
+ return Record{}, false, fmt.Errorf("empty session id")
316
+ }
317
+ all, err := List()
318
+ if err != nil {
319
+ return Record{}, false, err
320
+ }
321
+ var best Record
322
+ found := false
323
+ for _, r := range all {
324
+ if r.RunID != want && r.SessionID != want {
325
+ continue
326
+ }
327
+ switch {
328
+ case !found:
329
+ best, found = r, true
330
+ case r.State == StateRunning && best.State != StateRunning:
331
+ best = r
332
+ case r.State == best.State && r.StartedAt.After(best.StartedAt):
333
+ best = r
334
+ }
335
+ }
336
+ return best, found, nil
337
+ }
338
+
339
+ // StopResult 停止一条会话的结果。
340
+ type StopResult struct {
341
+ Record Record `json:"record"`
342
+ Stopped bool `json:"stopped"` // 是否真的发出了终止并确认进程消失
343
+ Reason string `json:"reason,omitempty"`
344
+ }
345
+
346
+ // Stop 停止指定会话:先 SIGTERM,宽限期内不退再 SIGKILL,最后回写状态。
347
+ //
348
+ // 幂等:进程已经不在了 → Stopped=false + reason(不报错),并把状态改成 gone/stopped。
349
+ // 找不到 id → 返回错误(多半是 id 写错了,要让调用方知道)。
350
+ func Stop(id string) (StopResult, error) {
351
+ rec, found, err := Find(id)
352
+ if err != nil {
353
+ return StopResult{}, err
354
+ }
355
+ if !found {
356
+ return StopResult{}, fmt.Errorf("no such session %q(--sessions 可列出全部)", id)
357
+ }
358
+
359
+ if rec.State != StateRunning {
360
+ return StopResult{Record: rec, Reason: "会话已结束(state=" + rec.State + ")"}, nil
361
+ }
362
+ if rec.PID <= 0 || !agent.PIDAlive(rec.PID) {
363
+ rec.State = StateGone
364
+ rec.UpdatedAt = time.Now()
365
+ writeRecord(rec)
366
+ return StopResult{Record: rec, Reason: "进程已不在(可能已自然结束)"}, nil
367
+ }
368
+
369
+ _ = agent.TerminatePID(rec.PID, rec.KillGroup)
370
+ if !waitGone(rec.PID, 2*time.Second) {
371
+ _ = agent.KillPID(rec.PID, rec.KillGroup)
372
+ if !waitGone(rec.PID, 2*time.Second) {
373
+ return StopResult{Record: rec, Reason: "已发信号但进程仍在(可能不属于当前用户)"},
374
+ fmt.Errorf("session %q (pid %d) 未能终止", id, rec.PID)
375
+ }
376
+ }
377
+ /* `--stop` = 调用方**主动取消** → cancelled(2026-09-29 对齐 anywhere 的 TimelineStatus;
378
+ 老值 `stopped` 在读入时归一,见 normalizeState)。
379
+ ⚠️ StopResult.Stopped 那个布尔**不改名**:它回答的是「这次停成功了吗」,
380
+ 与状态词表无关,改了会连带 CLI 的 `-o json` 契约一起动。 */
381
+ rec.State = StateCancelled
382
+ rec.UpdatedAt = time.Now()
383
+ writeRecord(rec)
384
+ return StopResult{Record: rec, Stopped: true}, nil
385
+ }
386
+
387
+ // waitGone 轮询等进程消失(每 50ms 一次)。
388
+ func waitGone(pid int, d time.Duration) bool {
389
+ deadline := time.Now().Add(d)
390
+ for {
391
+ if !agent.PIDAlive(pid) {
392
+ return true
393
+ }
394
+ if time.Now().After(deadline) {
395
+ return false
396
+ }
397
+ time.Sleep(50 * time.Millisecond)
398
+ }
399
+ }
400
+
401
+ // readRecord 读一条记录(坏文件当作不存在)。
402
+ func readRecord(path string) (Record, error) {
403
+ data, err := os.ReadFile(path)
404
+ if err != nil {
405
+ return Record{}, err
406
+ }
407
+ var rec Record
408
+ if err := json.Unmarshal(data, &rec); err != nil {
409
+ return Record{}, err
410
+ }
411
+ /* 旧记录里的 `stopped` 在这里翻成 `cancelled`(见 normalizeState)——
412
+ 读的时候归一,就不必去批量改写磁盘上已有的记录。 */
413
+ rec.State = normalizeState(rec.State)
414
+ return rec, nil
415
+ }
416
+
417
+ // writeRecord 覆盖写一条记录(按 RunID 定位文件名)。
418
+ func writeRecord(rec Record) {
419
+ dir := Dir()
420
+ if dir == "" || rec.RunID == "" {
421
+ return
422
+ }
423
+ data, err := json.MarshalIndent(rec, "", " ")
424
+ if err != nil {
425
+ return
426
+ }
427
+ _ = os.WriteFile(filepath.Join(dir, rec.RunID+".json"), data, 0o600)
428
+ }
429
+
430
+ // prune 删除过期记录(按 UpdatedAt;读不动的坏文件一并清掉)。
431
+ func prune(dir string) {
432
+ entries, err := os.ReadDir(dir)
433
+ if err != nil {
434
+ return
435
+ }
436
+ cutoff := time.Now().Add(-Retention)
437
+ for _, e := range entries {
438
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
439
+ continue
440
+ }
441
+ p := filepath.Join(dir, e.Name())
442
+ rec, err := readRecord(p)
443
+ if err != nil || rec.UpdatedAt.Before(cutoff) {
444
+ _ = os.Remove(p)
445
+ }
446
+ }
447
+ }
@@ -0,0 +1,241 @@
1
+ package session
2
+
3
+ // session_append.go - 常驻会话的「追加消息」通道(unix domain socket)。
4
+ //
5
+ // 用户需求(2026-09-18):「按路线 2 实现,先支持 claude 和 codebuddy 的追加功能」。
6
+ //
7
+ // 机制:claude/codebuddy 的 `--input-format stream-json` 允许在**同一个进程**里持续
8
+ // 接收 user 消息(= 会话中追加需求)。所以常驻会话(`--keep-alive`)会在自己的
9
+ // 会话记录里登记一个 unix socket 路径,任何进程都能:
10
+ //
11
+ // magic-agent --append <session_id> -p "追加内容"
12
+ //
13
+ // 由 `AppendMessage` 连上去把消息交给常驻进程,常驻进程再写进引擎的 stdin。
14
+ //
15
+ // 为什么用 unix socket 而不是 TCP/FIFO:
16
+ // - 权限天然受控:socket 文件 0600 + 目录 0700 → 只有同用户能追加,不需要额外 token;
17
+ // - FIFO 的「最后一个写者关闭 → 读端 EOF」语义会把引擎的 stdin 关掉,不能用;
18
+ // - TCP 需要暴露端口 + token 校验,在单机场景纯属多余。
19
+ //
20
+ // Windows 没有 unix socket → `ListenAppend` 明确报错(`--keep-alive` 会被挡下并说明原因),
21
+ // 不做静默降级。
22
+
23
+ import (
24
+ "bufio"
25
+ "encoding/json"
26
+ "fmt"
27
+ "io"
28
+ "net"
29
+ "os"
30
+ "path/filepath"
31
+ "runtime"
32
+ "strings"
33
+ "time"
34
+ )
35
+
36
+ // AppendProtocolVersion 追加协议版本(客户端/服务端握手用,便于以后演进)。
37
+ const AppendProtocolVersion = 1
38
+
39
+ // maxUnixSocketPath unix socket 路径的保守上限:macOS 的 sun_path 是 104 字节
40
+ // (Linux 108),留点余量取 100。
41
+ const maxUnixSocketPath = 100
42
+
43
+ // appendRequest 客户端 → 常驻进程 的一条消息。
44
+ type appendRequest struct {
45
+ Version int `json:"version"`
46
+ Text string `json:"text"`
47
+ }
48
+
49
+ // appendResponse 常驻进程 → 客户端的应答。
50
+ type appendResponse struct {
51
+ OK bool `json:"ok"`
52
+ Error string `json:"error,omitempty"`
53
+ }
54
+
55
+ // Appender 常驻会话的追加入口(一个 unix socket)。
56
+ type Appender struct {
57
+ ln net.Listener
58
+ path string
59
+ }
60
+
61
+ // Path 返回 socket 路径(写进会话记录,客户端据此连接)。
62
+ func (a *Appender) Path() string { return a.path }
63
+
64
+ // Serve 把收到的消息推给 ch(阻塞直到 ln 关闭或 ch 关闭)。
65
+ //
66
+ // 不关闭 ch —— 通道的生命周期归调用方(CLI):空闲超时/收到停止信号时由它关闭,
67
+ // 从而让引擎 stdin EOF、常驻会话优雅收尾。
68
+ func (a *Appender) Serve(ch chan<- string) {
69
+ for {
70
+ conn, err := a.ln.Accept()
71
+ if err != nil {
72
+ return // ln 已关闭
73
+ }
74
+ go a.handle(conn, ch)
75
+ }
76
+ }
77
+
78
+ // handle 处理一条连接:读一行 JSON,推入通道,回一行应答。
79
+ func (a *Appender) handle(conn net.Conn, ch chan<- string) {
80
+ defer conn.Close()
81
+ _ = conn.SetReadDeadline(time.Now().Add(30 * time.Second))
82
+
83
+ line, err := bufio.NewReader(conn).ReadString('\n')
84
+ if err != nil && strings.TrimSpace(line) == "" {
85
+ return
86
+ }
87
+ var req appendRequest
88
+ if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &req); err != nil {
89
+ writeAppendResponse(conn, appendResponse{Error: "请求不是合法 JSON: " + err.Error()})
90
+ return
91
+ }
92
+ if strings.TrimSpace(req.Text) == "" {
93
+ writeAppendResponse(conn, appendResponse{Error: "追加内容为空"})
94
+ return
95
+ }
96
+ select {
97
+ case ch <- req.Text:
98
+ writeAppendResponse(conn, appendResponse{OK: true})
99
+ case <-time.After(5 * time.Second):
100
+ // 通道满(追加太快):明确告知,不假装成功
101
+ writeAppendResponse(conn, appendResponse{Error: "追加队列已满,稍后再试"})
102
+ }
103
+ }
104
+
105
+ // writeAppendResponse 回一行 JSON 应答(尽力而为,写不出去就算了)。
106
+ func writeAppendResponse(w io.Writer, resp appendResponse) {
107
+ data, err := json.Marshal(resp)
108
+ if err != nil {
109
+ return
110
+ }
111
+ _, _ = w.Write(append(data, '\n'))
112
+ }
113
+
114
+ // Close 关掉监听并删掉 socket 文件(幂等)。
115
+ func (a *Appender) Close() {
116
+ if a == nil {
117
+ return
118
+ }
119
+ if a.ln != nil {
120
+ _ = a.ln.Close()
121
+ }
122
+ if a.path != "" {
123
+ _ = os.Remove(a.path)
124
+ }
125
+ }
126
+
127
+ // ListenAppend 为一条会话开追加入口,并把路径写进会话记录(客户端据此找到它)。
128
+ //
129
+ // 目录/文件权限:sessions 目录 0700、socket 文件 0600 → 仅同用户可连。
130
+ func ListenAppend(h *Handle) (*Appender, error) {
131
+ if h == nil {
132
+ return nil, fmt.Errorf("no session handle")
133
+ }
134
+ if runtime.GOOS == "windows" {
135
+ return nil, fmt.Errorf("常驻会话(--keep-alive)与 --append 目前只支持 macOS/Linux:Windows 没有 unix domain socket")
136
+ }
137
+ dir := Dir()
138
+ if dir == "" {
139
+ return nil, fmt.Errorf("cannot resolve sessions dir")
140
+ }
141
+ if err := os.MkdirAll(dir, 0o700); err != nil {
142
+ return nil, err
143
+ }
144
+ path := filepath.Join(dir, h.RunID()+".sock")
145
+ // unix socket 路径有硬上限(macOS sun_path 104 / Linux 108 字节),超了 bind 会报
146
+ // 一句看不出原因的 "invalid argument"。提前拦住并给出可操作建议。
147
+ if len(path) > maxUnixSocketPath {
148
+ return nil, fmt.Errorf("追加入口路径过长(%d 字节,上限约 %d):%s;"+
149
+ "请把登记表目录换到短路径,例如 MAGIC_AGENT_SESSIONS=/tmp/magic-agent-sessions",
150
+ len(path), maxUnixSocketPath, path)
151
+ }
152
+ _ = os.Remove(path) // 清掉可能残留的同名 socket
153
+ ln, err := net.Listen("unix", path)
154
+ if err != nil {
155
+ return nil, fmt.Errorf("listen %s: %w", path, err)
156
+ }
157
+ if err := os.Chmod(path, 0o600); err != nil {
158
+ _ = ln.Close()
159
+ return nil, fmt.Errorf("chmod %s: %w", path, err)
160
+ }
161
+ h.SetAppendPath(path)
162
+ return &Appender{ln: ln, path: path}, nil
163
+ }
164
+
165
+ // AppendResult 一次追加的结果。
166
+ type AppendResult struct {
167
+ Record Record `json:"record"`
168
+ Queued bool `json:"queued"`
169
+ Reason string `json:"reason,omitempty"`
170
+ }
171
+
172
+ // AppendMessage 把一条消息追加进指定会话(session_id 或 run_id 都收)。
173
+ //
174
+ // 失败分两种,调用方按退出码区分:
175
+ // - 找不到 id / 记录里没有追加入口 → 返回 error(exit 1,附可操作提示)
176
+ // - 常驻进程已经退出(连接被拒)→ 返回 error(exit 1,提示会话已结束)
177
+ func AppendMessage(id, text string) (AppendResult, error) {
178
+ msg := strings.TrimSpace(text)
179
+ if msg == "" {
180
+ return AppendResult{}, fmt.Errorf("追加内容为空")
181
+ }
182
+ rec, found, err := Find(id)
183
+ if err != nil {
184
+ return AppendResult{}, err
185
+ }
186
+ if !found {
187
+ return AppendResult{}, fmt.Errorf("no such session %q(--sessions 可列出全部)", id)
188
+ }
189
+ if strings.TrimSpace(rec.Append) == "" {
190
+ reason := "该会话没有追加入口(未用 --keep-alive 启动)"
191
+ if rec.State != StateRunning {
192
+ reason = "会话已结束(state=" + rec.State + ")"
193
+ }
194
+ return AppendResult{Record: rec, Reason: reason},
195
+ fmt.Errorf("%s;常驻会话要用 --stream --keep-alive 启动,已结束的会话请用 --session <id> 续接", reason)
196
+ }
197
+ if rec.State != StateRunning {
198
+ return AppendResult{Record: rec, Reason: "会话已结束(state=" + rec.State + ")"},
199
+ fmt.Errorf("会话已结束(state=%s);请用 --session <id> 续接", rec.State)
200
+ }
201
+
202
+ conn, derr := net.DialTimeout("unix", rec.Append, 5*time.Second)
203
+ if derr != nil {
204
+ // 记录说在跑、socket 却连不上:常驻进程多半刚退出(或崩了)
205
+ rec.State = StateGone
206
+ rec.UpdatedAt = time.Now()
207
+ writeRecord(rec)
208
+ return AppendResult{Record: rec, Reason: "常驻进程已不在(连接失败)"},
209
+ fmt.Errorf("连不上会话 %s 的追加入口(%s):常驻进程可能已退出;请用 --sessions 复核", id, rec.Append)
210
+ }
211
+ defer conn.Close()
212
+ _ = conn.SetDeadline(time.Now().Add(15 * time.Second))
213
+
214
+ req, _ := json.Marshal(appendRequest{Version: AppendProtocolVersion, Text: msg})
215
+ if _, werr := conn.Write(append(req, '\n')); werr != nil {
216
+ return AppendResult{Record: rec, Reason: "写入失败"}, fmt.Errorf("写入追加入口失败: %w", werr)
217
+ }
218
+ line, rerr := bufio.NewReader(conn).ReadString('\n')
219
+ if rerr != nil && strings.TrimSpace(line) == "" {
220
+ return AppendResult{Record: rec, Reason: "没收到应答"}, fmt.Errorf("追加入口没回应: %w", rerr)
221
+ }
222
+ var resp appendResponse
223
+ if jerr := json.Unmarshal([]byte(strings.TrimSpace(line)), &resp); jerr != nil {
224
+ return AppendResult{Record: rec, Reason: "应答不是 JSON"}, fmt.Errorf("追加入口应答异常: %s", strings.TrimSpace(line))
225
+ }
226
+ if !resp.OK {
227
+ return AppendResult{Record: rec, Reason: resp.Error}, fmt.Errorf("追加被拒: %s", resp.Error)
228
+ }
229
+ rec.UpdatedAt = time.Now()
230
+ writeRecord(rec)
231
+ return AppendResult{Record: rec, Queued: true}, nil
232
+ }
233
+
234
+ // SetAppendPath 把追加入口路径写进记录(由 ListenAppend 调用)。
235
+ func (h *Handle) SetAppendPath(path string) {
236
+ if h == nil {
237
+ return
238
+ }
239
+ h.rec.Append = path
240
+ h.write()
241
+ }