magic-agent 1.0.3 → 1.0.49

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -11,6 +11,11 @@
11
11
  // 于是每次调用都落一条记录(引擎子进程 pid + 引擎/模型/工作目录 + 会话 id),
12
12
  // 之后任何进程都能用 `magic-agent --stop <session_id|run_id>` 精确停掉它。
13
13
  //
14
+ // ⚠️ 2026-10-12 起落盘是** opt-in** 的:CLI 默认不开(BeginOptions.NoPersist),
15
+ // 用户要的是「默认不留痕」—— 手工跑一轮 / CI / 脚本编排事后没人寻址。
16
+ // 开了 --save-registry 的调用方(magic-client)才落;没开的仍会分配 run_id
17
+ // (常驻会话的追加入口按它命名,见 AppendSocketPath),只是磁盘上没有这条记录。
18
+ //
14
19
  // 文件位置:$MAGIC_AGENT_SESSIONS > ~/.magic-agent/sessions/<run_id>.json
15
20
  // 每条一个文件(无锁、无共享写),过期记录(默认 24h)在 Begin/List/Stop 时顺手清理。
16
21
  //
@@ -21,6 +26,12 @@
21
26
  // 开始时未知(新会话) → 结束时回填(Finish),期间用 run_id 寻址
22
27
  //
23
28
  // 因此 --stop 接受两种 id:会话 id(session_id)或运行 id(run_id,`--sessions` 可见)。
29
+ //
30
+ // 登记表是**全机共享**的:客户端(magic-client)与直接跑的 CLI(用户手工、CI、
31
+ // 别的 magic-* 应用)都往同一份目录里写。所以每条记录还带两个「谁发起的」维度:
32
+ //
33
+ // Source 设备形态(mobile | pc)—— 界面显示手机 / 电脑标志
34
+ // Client 发起方应用(如 magic-client)—— 客户端列表据此只显示自己发起的会话
24
35
  package session
25
36
 
26
37
  import (
@@ -39,9 +50,62 @@ import (
39
50
  // EnvDir 覆盖登记表目录的环境变量名。
40
51
  const EnvDir = "MAGIC_AGENT_SESSIONS"
41
52
 
42
- // Retention 记录保留时长:过期的(按 UpdatedAt)在每次读写时清理。
43
- // 留着而不是用完即删,是为了让「停一条早就结束的会话」能答出「已结束」而不是「没找到」。
44
- const Retention = 24 * time.Hour
53
+ // EnvSource 覆盖「会话来源端」的环境变量名(mobile | pc)。
54
+ // 由发起方(HTTP 插件按请求 Origin 判定)注入,core 落到 Record.Source,
55
+ // 界面上据此显示设备标志。空 = 未知(旧记录 / 直接跑 CLI)。
56
+ const EnvSource = "MAGIC_AGENT_SOURCE"
57
+
58
+ // EnvClient 覆盖「会话来源客户端」的环境变量名(如 magic-client)。
59
+ //
60
+ // 与 EnvSource 是**两个维度**,不能合并:Source 回答「手机还是电脑」,
61
+ // Client 回答「哪个客户端应用发起的」。一台机器上可以同时跑着 magic-client 与
62
+ // 直接跑的 CLI(用户手工 `magic-agent -p …`、CI、别的 magic-* 应用),它们共用
63
+ // 同一份登记表 —— 客户端列表要能只显示**自己发起的**那些(用户 2026-10-12:
64
+ // 「不是 magic-agent 客户端调用的任务不显示」)。
65
+ //
66
+ // 空 = 不是客户端发起的(直接跑 CLI / 旧记录)。
67
+ const EnvClient = "MAGIC_AGENT_CLIENT"
68
+
69
+ // 来源端取值(只认这两个;其它一律归一成空 = 未知)。
70
+ const (
71
+ SourceMobile = "mobile"
72
+ SourcePC = "pc"
73
+ )
74
+
75
+ // normalizeSource 把来源端归一:只认 mobile / pc,其余(含空)返回 ""。
76
+ // 不认识的词一律当「未知」,宁可界面显示默认设备,也不要把陌生的值当真。
77
+ func normalizeSource(s string) string {
78
+ switch strings.ToLower(strings.TrimSpace(s)) {
79
+ case SourceMobile:
80
+ return SourceMobile
81
+ case SourcePC:
82
+ return SourcePC
83
+ default:
84
+ return ""
85
+ }
86
+ }
87
+
88
+ // normalizeClient 归一来源客户端标识:去空白 + 按 rune 截断到 64。
89
+ //
90
+ // 为什么**不**像 Source 那样只认固定几个值:客户端身份是开放词表(每个 magic-*
91
+ // 应用各一个,见 Profile.AppID),core 不该替它们枚举 —— 认得出的原样留下,
92
+ // 认不出的也只是「不显示」而已(客户端按它过滤时对不上就当不是自己的)。
93
+ func normalizeClient(s string) string {
94
+ s = strings.TrimSpace(s)
95
+ if s == "" {
96
+ return ""
97
+ }
98
+ r := []rune(s)
99
+ if len(r) > 64 {
100
+ return string(r[:64])
101
+ }
102
+ return s
103
+ }
104
+
105
+ /* 登记表**永不按时间清理**(2026-10-18,用户:「永远保留啊 没有24小时限制」)。
106
+ 此前是 `Retention = 24h` + prune 按 UpdatedAt 物理删 —— 后果是历史任务自己从列表里
107
+ 消失:移动端 / 桌面端的「任务」页直接读这份登记表(`--sessions`),删了就等于没跑过。
108
+ 代价可忽略(一条记录约 450 字节)。只清**读不出来的**坏文件,见 dropUnreadable。 */
45
109
 
46
110
  // 记录状态。
47
111
  //
@@ -86,12 +150,23 @@ func isTerminalOverride(s string) bool {
86
150
 
87
151
  // Record 一条会话记录(也是磁盘上的 JSON 形状)。
88
152
  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"`
153
+ RunID string `json:"run_id"`
154
+ SessionID string `json:"session_id,omitempty"`
155
+ PID int `json:"pid"`
156
+ KillGroup bool `json:"kill_group"` // true = 按进程组杀(引擎子进程自成组)
157
+ Engine string `json:"engine"`
158
+ Model string `json:"model,omitempty"`
159
+ // Source 这条会话的**来源端**:mobile | pc(空 = 未知/旧记录)。
160
+ // 由发起方经 MAGIC_AGENT_SOURCE 传入(HTTP 插件按请求 Origin 判定),
161
+ // 界面据此显示设备标志(手机 / 电脑)。
162
+ Source string `json:"source,omitempty"`
163
+ // Client 这条会话的**来源客户端**(如 magic-client;空 = 不是客户端发起的)。
164
+ // 由发起方经 MAGIC_AGENT_CLIENT 传入(magic-client 插件注入自己的 AppID),
165
+ // 客户端列表据此**只显示自己发起的会话**。
166
+ //
167
+ // 与 Source 的分工:Source 是设备形态(手机/电脑),Client 是发起方应用。
168
+ // 两者都缺一不可 —— 同一台机器上既有客户端发起的、也有直接跑 CLI 的会话。
169
+ Client string `json:"client,omitempty"`
95
170
  Workspace string `json:"workspace,omitempty"`
96
171
  PromptHead string `json:"prompt_head,omitempty"`
97
172
  State string `json:"state"`
@@ -119,16 +194,32 @@ func Dir() string {
119
194
  type BeginOptions struct {
120
195
  Engine string
121
196
  Model string
197
+ Source string // 来源端(mobile | pc);空 = 未知
198
+ Client string // 来源客户端(如 magic-client);空 = 不是客户端发起的
122
199
  Workspace string
123
200
  SessionID string // 续接时已知;新会话为空
124
201
  PromptHead string // 提示词开头(便于人辨认)
125
202
  PID int // 可先填自身 pid 兜底(引擎子进程起来后会被 SetPID 覆盖)
203
+ // NoPersist 只在内存里维护这条会话,**不落盘**登记表。
204
+ //
205
+ // 为什么要它(用户 2026-10-12:「会话登记也不需要」):CLI 默认不留痕 ——
206
+ // 手工跑一轮 / CI / 脚本编排不该在 ~/.magic-agent/sessions/ 里堆记录。
207
+ // 但 run_id 仍要(常驻会话的追加入口按它命名,见 AppendSocketPath),
208
+ // 所以「不落盘」不等于「没有这条会话」,只是 --sessions / --stop 看不见它。
209
+ //
210
+ // ⚠️ 取反命名(NoPersist 而非 Persist):默认零值仍是「落盘」,
211
+ // 既有调用方与测试不必逐个改语义 —— 只有 CLI 显式关它。
212
+ NoPersist bool
126
213
  }
127
214
 
128
215
  // Handle 一条运行中会话的句柄(负责回写 pid 与收尾状态)。
129
216
  type Handle struct {
130
217
  rec Record
131
218
  path string
219
+ // noPersist 这条会话不落盘(BeginOptions.NoPersist):句柄照常分配 run_id、
220
+ // 照常接受 pid / 状态 / 收尾回写,只是**不写文件** —— 调用方(CLI 默认)
221
+ // 因此不留痕,而 run_id 仍能当追加入口的地址(见 AppendSocketPath)。
222
+ noPersist bool
132
223
  }
133
224
 
134
225
  // RunID 本次运行的 id。
@@ -220,7 +311,13 @@ func (h *Handle) KillChild() {
220
311
  }
221
312
 
222
313
  // write 落盘(失败静默:登记表只是可运维性,不能因为它写不动就让调用失败)。
314
+ //
315
+ // noPersist 时**直接跳过** —— 磁盘上根本没有这条记录,SetPID / Mark / Finish
316
+ // 这些回写退化成纯内存操作(值仍留在 h.rec 上,进程内读得到)。
223
317
  func (h *Handle) write() {
318
+ if h.noPersist {
319
+ return
320
+ }
224
321
  h.rec.UpdatedAt = time.Now()
225
322
  data, err := json.MarshalIndent(h.rec, "", " ")
226
323
  if err != nil {
@@ -238,7 +335,7 @@ func (h *Handle) write() {
238
335
  _ = os.Rename(tmp, h.path)
239
336
  }
240
337
 
241
- // Begin 新建一条 running 记录。
338
+ // Begin 新建一条 running 记录(NoPersist 时只在内存里建,不落盘)。
242
339
  func Begin(o BeginOptions) (*Handle, error) {
243
340
  dir := Dir()
244
341
  if dir == "" {
@@ -247,7 +344,10 @@ func Begin(o BeginOptions) (*Handle, error) {
247
344
  if err := os.MkdirAll(dir, 0o700); err != nil {
248
345
  return nil, err
249
346
  }
250
- prune(dir)
347
+ // 只在真的要落盘时才顺手清坏记录:不留痕的调用没必要为别人清理目录。
348
+ if !o.NoPersist {
349
+ dropUnreadable(dir)
350
+ }
251
351
 
252
352
  now := time.Now()
253
353
  rec := Record{
@@ -259,14 +359,18 @@ func Begin(o BeginOptions) (*Handle, error) {
259
359
  KillGroup: false, // 自身 pid 兜底时按单进程杀(不能杀调用方的进程组)
260
360
  Engine: o.Engine,
261
361
  Model: o.Model,
362
+ Source: normalizeSource(o.Source),
363
+ Client: normalizeClient(o.Client),
262
364
  Workspace: o.Workspace,
263
365
  PromptHead: head(o.PromptHead, 80),
264
366
  State: StateRunning,
265
367
  StartedAt: now,
266
368
  UpdatedAt: now,
267
369
  }
268
- h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json")}
269
- h.write()
370
+ // path 照样算出来:追加入口的 socket 与它同目录同前缀(见 AppendSocketPath),
371
+ // 句柄里留着它,SetAppendPath 之类也不用知道自己是不是在存档模式。
372
+ h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json"), noPersist: o.NoPersist}
373
+ h.write() // noPersist 时这里什么都不做
270
374
  return h, nil
271
375
  }
272
376
 
@@ -280,13 +384,13 @@ func head(s string, n int) string {
280
384
  return string(r[:n]) + "…"
281
385
  }
282
386
 
283
- // List 返回全部记录(新的在前);顺带清理过期记录。
387
+ // List 返回全部记录(新的在前);顺带清理读不出来的坏文件。
284
388
  func List() ([]Record, error) {
285
389
  dir := Dir()
286
390
  if dir == "" {
287
391
  return nil, fmt.Errorf("cannot resolve sessions dir")
288
392
  }
289
- prune(dir)
393
+ dropUnreadable(dir)
290
394
  entries, err := os.ReadDir(dir)
291
395
  if err != nil {
292
396
  if os.IsNotExist(err) {
@@ -300,6 +404,9 @@ func List() ([]Record, error) {
300
404
  continue
301
405
  }
302
406
  if rec, err := readRecord(filepath.Join(dir, e.Name())); err == nil {
407
+ if normalizeDead(&rec) {
408
+ writeRecord(rec)
409
+ }
303
410
  out = append(out, rec)
304
411
  }
305
412
  }
@@ -307,6 +414,27 @@ func List() ([]Record, error) {
307
414
  return out, nil
308
415
  }
309
416
 
417
+ // normalizeDead 把「记录写着在跑,但进程已经不在了」的记录读侧归一成 gone。
418
+ //
419
+ // 为什么必须在读侧做(2026-10-19):core 被外部杀掉时(客户端断连、终端退出、
420
+ // 宿主服务重启)来不及走 Finish 写终态,磁盘上就永远停着 running ——
421
+ // 界面上表现为任务「一直不结束」,移动端的串行门(同空间存在 running 就排队)
422
+ // 还会被这条死记录永久挡住,新任务永远发不出去。
423
+ //
424
+ // 与 Stop() 的「进程已不在 → gone」判定同口径;PID 缺失(<=0)的老记录无从判断,
425
+ // 保持原样。返回 true 表示状态被改过,调用方需要回写。
426
+ func normalizeDead(rec *Record) bool {
427
+ if rec.State != StateRunning && rec.State != StateWaitingApproval {
428
+ return false
429
+ }
430
+ if rec.PID <= 0 || agent.PIDAlive(rec.PID) {
431
+ return false
432
+ }
433
+ rec.State = StateGone
434
+ rec.UpdatedAt = time.Now()
435
+ return true
436
+ }
437
+
310
438
  // Find 按 run_id 或 session_id 精确匹配一条记录。
311
439
  // 同 id 有多条时优先「running」的那条,其次取最新。
312
440
  func Find(id string) (Record, bool, error) {
@@ -357,6 +485,11 @@ func Stop(id string) (StopResult, error) {
357
485
  }
358
486
 
359
487
  if rec.State != StateRunning {
488
+ /* gone 是 List() 在读侧归一的「进程已不在」,和下面那条死进程分支是同一件事,
489
+ 原因文案保持一致(Stop 的调用方据此区分「本来就结束了」与「进程没了」)。 */
490
+ if rec.State == StateGone {
491
+ return StopResult{Record: rec, Reason: "进程已不在(可能已自然结束)"}, nil
492
+ }
360
493
  return StopResult{Record: rec, Reason: "会话已结束(state=" + rec.State + ")"}, nil
361
494
  }
362
495
  if rec.PID <= 0 || !agent.PIDAlive(rec.PID) {
@@ -427,20 +560,22 @@ func writeRecord(rec Record) {
427
560
  _ = os.WriteFile(filepath.Join(dir, rec.RunID+".json"), data, 0o600)
428
561
  }
429
562
 
430
- // prune 删除过期记录(按 UpdatedAt;读不动的坏文件一并清掉)。
431
- func prune(dir string) {
563
+ // dropUnreadable 清掉读不出来的登记文件(写了一半、非法 JSON)。
564
+ //
565
+ // ⚠️ **不按时间清理**:登记表就是「历史任务列表」的数据源,按 UpdatedAt 删过期记录等于
566
+ // 让跑过的任务自己消失(见文件头那条注释)。坏文件仍然要清 —— 它永远读不出来,
567
+ // 留着只是噪音,而且每次 List 都要为它白读一遍。
568
+ func dropUnreadable(dir string) {
432
569
  entries, err := os.ReadDir(dir)
433
570
  if err != nil {
434
571
  return
435
572
  }
436
- cutoff := time.Now().Add(-Retention)
437
573
  for _, e := range entries {
438
574
  if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
439
575
  continue
440
576
  }
441
577
  p := filepath.Join(dir, e.Name())
442
- rec, err := readRecord(p)
443
- if err != nil || rec.UpdatedAt.Before(cutoff) {
578
+ if _, err := readRecord(p); err != nil {
444
579
  _ = os.Remove(p)
445
580
  }
446
581
  }
@@ -124,24 +124,52 @@ func (a *Appender) Close() {
124
124
  }
125
125
  }
126
126
 
127
- // ListenAppend 为一条会话开追加入口,并把路径写进会话记录(客户端据此找到它)。
127
+ // AppendTransportAvailable 报告本平台是否具备「追加入口」的传输能力。
128
+ //
129
+ // 存在的意义(2026-10-01):Windows 没有 unix domain socket,
130
+ // ListenAppend 在那里必然失败。调用方(cli 层 kaEnabled)据此**提前**判定,
131
+ // 让「默认值触发的常驻」在 Windows 上静默不启用 —— 而不是让用户撞上
132
+ // 「--keep-alive: 常驻会话目前只支持 macOS/Linux」这种
133
+ // 「我没要求它开、它却报错」的错。
134
+ //
135
+ // 显式传 --keep-alive 时仍应报错(用户明确要了这件事,要说清为什么不行),
136
+ // 那个判断在 cli 层的 prepareAsk,不在这里。
137
+ func AppendTransportAvailable() bool {
138
+ return runtime.GOOS != "windows"
139
+ }
140
+
141
+ // AppendSocketPath 按约定推出某条会话的追加入口路径:<sessions dir>/<id>.sock。
142
+ //
143
+ // 为什么要它独立于登记表(用户 2026-10-12:默认不留档):socket 路径本来就**只由
144
+ // run_id 决定**,登记表只是把它「发布」出去让调用方能发现。不落盘时没有发布,
145
+ // 但按同一个约定推就能找到 —— 于是「默认开着常驻、却追加不了」这个坑不会出现。
146
+ // ListenAppend 与 AppendMessage 都走这里,保证两端算出的是同一个路径。
147
+ func AppendSocketPath(id string) string {
148
+ dir := Dir()
149
+ if dir == "" || strings.TrimSpace(id) == "" {
150
+ return ""
151
+ }
152
+ return filepath.Join(dir, strings.TrimSpace(id)+".sock")
153
+ }
154
+
155
+ // ListenAppend 为一条会话开追加入口,并把路径写进会话记录(存档模式下客户端据此找到它)。
128
156
  //
129
157
  // 目录/文件权限:sessions 目录 0700、socket 文件 0600 → 仅同用户可连。
130
158
  func ListenAppend(h *Handle) (*Appender, error) {
131
159
  if h == nil {
132
160
  return nil, fmt.Errorf("no session handle")
133
161
  }
134
- if runtime.GOOS == "windows" {
162
+ if !AppendTransportAvailable() {
135
163
  return nil, fmt.Errorf("常驻会话(--keep-alive)与 --append 目前只支持 macOS/Linux:Windows 没有 unix domain socket")
136
164
  }
137
- dir := Dir()
138
- if dir == "" {
165
+ path := AppendSocketPath(h.RunID())
166
+ if path == "" {
139
167
  return nil, fmt.Errorf("cannot resolve sessions dir")
140
168
  }
169
+ dir := filepath.Dir(path)
141
170
  if err := os.MkdirAll(dir, 0o700); err != nil {
142
171
  return nil, err
143
172
  }
144
- path := filepath.Join(dir, h.RunID()+".sock")
145
173
  // unix socket 路径有硬上限(macOS sun_path 104 / Linux 108 字节),超了 bind 会报
146
174
  // 一句看不出原因的 "invalid argument"。提前拦住并给出可操作建议。
147
175
  if len(path) > maxUnixSocketPath {
@@ -158,7 +186,7 @@ func ListenAppend(h *Handle) (*Appender, error) {
158
186
  _ = ln.Close()
159
187
  return nil, fmt.Errorf("chmod %s: %w", path, err)
160
188
  }
161
- h.SetAppendPath(path)
189
+ h.SetAppendPath(path) // 存档模式下写进记录;不落盘时是空操作
162
190
  return &Appender{ln: ln, path: path}, nil
163
191
  }
164
192
 
@@ -171,6 +199,11 @@ type AppendResult struct {
171
199
 
172
200
  // AppendMessage 把一条消息追加进指定会话(session_id 或 run_id 都收)。
173
201
  //
202
+ // 两条寻址路径(默认不留档的必然结果):
203
+ // 1. 登记表里有这条记录 → 读它的 Append 字段(存档模式的老路,session_id 也认);
204
+ // 2. **没有记录** → 按约定推 <dir>/<run_id>.sock 再试(不落盘时唯一可走的路,
205
+ // 此时只认 run_id —— session_id 要靠记录映射,没有记录就无从查起)。
206
+ //
174
207
  // 失败分两种,调用方按退出码区分:
175
208
  // - 找不到 id / 记录里没有追加入口 → 返回 error(exit 1,附可操作提示)
176
209
  // - 常驻进程已经退出(连接被拒)→ 返回 error(exit 1,提示会话已结束)
@@ -184,7 +217,20 @@ func AppendMessage(id, text string) (AppendResult, error) {
184
217
  return AppendResult{}, err
185
218
  }
186
219
  if !found {
187
- return AppendResult{}, fmt.Errorf("no such session %q(--sessions 可列出全部)", id)
220
+ // 没有登记记录:多半是这条会话没开 --save-registry。按约定找 socket。
221
+ path := AppendSocketPath(id)
222
+ if path == "" {
223
+ return AppendResult{}, fmt.Errorf("cannot resolve sessions dir")
224
+ }
225
+ if _, serr := os.Stat(path); serr != nil {
226
+ return AppendResult{}, fmt.Errorf(
227
+ "no such session %q(既没有登记记录,也没有追加入口 %s;"+
228
+ "该会话多半是默认不留档跑起来的 —— 常驻会话要用 --save-registry 启动才能按 id 追加)", id, path)
229
+ }
230
+ if aerr := appendOverSocket(path, msg); aerr != nil {
231
+ return AppendResult{}, aerr
232
+ }
233
+ return AppendResult{Queued: true}, nil
188
234
  }
189
235
  if strings.TrimSpace(rec.Append) == "" {
190
236
  reason := "该会话没有追加入口(未用 --keep-alive 启动)"
@@ -199,36 +245,48 @@ func AppendMessage(id, text string) (AppendResult, error) {
199
245
  fmt.Errorf("会话已结束(state=%s);请用 --session <id> 续接", rec.State)
200
246
  }
201
247
 
202
- conn, derr := net.DialTimeout("unix", rec.Append, 5*time.Second)
203
- if derr != nil {
248
+ if aerr := appendOverSocket(rec.Append, msg); aerr != nil {
204
249
  // 记录说在跑、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)
250
+ if strings.Contains(aerr.Error(), "连不上") {
251
+ rec.State = StateGone
252
+ rec.UpdatedAt = time.Now()
253
+ writeRecord(rec)
254
+ }
255
+ return AppendResult{Record: rec, Reason: "常驻进程已不在(连接失败)"}, aerr
256
+ }
257
+ rec.UpdatedAt = time.Now()
258
+ writeRecord(rec)
259
+ return AppendResult{Record: rec, Queued: true}, nil
260
+ }
261
+
262
+ // appendOverSocket 连上追加入口投递一条消息并等应答。
263
+ //
264
+ // 单独抽出来是为了让「有记录」与「按约定找 socket」两条路径共用同一套协议 ——
265
+ // 协议改一处就够;两处各写一遍的话,改一处忘一处是迟早的事。
266
+ func appendOverSocket(path, msg string) error {
267
+ conn, derr := net.DialTimeout("unix", path, 5*time.Second)
268
+ if derr != nil {
269
+ return fmt.Errorf("连不上会话的追加入口(%s):常驻进程可能已退出;请用 --sessions 复核", path)
210
270
  }
211
271
  defer conn.Close()
212
272
  _ = conn.SetDeadline(time.Now().Add(15 * time.Second))
213
273
 
214
274
  req, _ := json.Marshal(appendRequest{Version: AppendProtocolVersion, Text: msg})
215
275
  if _, werr := conn.Write(append(req, '\n')); werr != nil {
216
- return AppendResult{Record: rec, Reason: "写入失败"}, fmt.Errorf("写入追加入口失败: %w", werr)
276
+ return fmt.Errorf("写入追加入口失败: %w", werr)
217
277
  }
218
278
  line, rerr := bufio.NewReader(conn).ReadString('\n')
219
279
  if rerr != nil && strings.TrimSpace(line) == "" {
220
- return AppendResult{Record: rec, Reason: "没收到应答"}, fmt.Errorf("追加入口没回应: %w", rerr)
280
+ return fmt.Errorf("追加入口没回应: %w", rerr)
221
281
  }
222
282
  var resp appendResponse
223
283
  if jerr := json.Unmarshal([]byte(strings.TrimSpace(line)), &resp); jerr != nil {
224
- return AppendResult{Record: rec, Reason: "应答不是 JSON"}, fmt.Errorf("追加入口应答异常: %s", strings.TrimSpace(line))
284
+ return fmt.Errorf("追加入口应答异常: %s", strings.TrimSpace(line))
225
285
  }
226
286
  if !resp.OK {
227
- return AppendResult{Record: rec, Reason: resp.Error}, fmt.Errorf("追加被拒: %s", resp.Error)
287
+ return fmt.Errorf("追加被拒: %s", resp.Error)
228
288
  }
229
- rec.UpdatedAt = time.Now()
230
- writeRecord(rec)
231
- return AppendResult{Record: rec, Queued: true}, nil
289
+ return nil
232
290
  }
233
291
 
234
292
  // SetAppendPath 把追加入口路径写进记录(由 ListenAppend 调用)。
@@ -3,7 +3,7 @@ package session
3
3
  // session_test.go - 会话登记表单元测试。
4
4
  //
5
5
  // 覆盖:Begin 落盘(running)/ pid 与 session_id 回填 / 按 run_id 与 session_id 查找 /
6
- // Stop(真杀进程、幂等、未知 id、已结束)/ 过期清理 / Finish 不覆盖 stopped。
6
+ // Stop(真杀进程、幂等、未知 id、已结束)/ 老记录保留(只清坏文件)/ Finish 不覆盖 stopped。
7
7
  //
8
8
  // 全部用临时目录(t.Setenv(EnvDir)),不碰用户真实登记表。
9
9
 
@@ -193,22 +193,24 @@ func TestFinishKeepsStoppedState(t *testing.T) {
193
193
  }
194
194
  }
195
195
 
196
- func TestListPrunesExpiredRecords(t *testing.T) {
196
+ // 登记表**不按时间清理**:老记录必须留着(2026-10-18,用户:「永远保留啊 没有24小时限制」)。
197
+ // 只有读不出来的坏文件才清 —— 它永远读不出来,留着只是噪音。
198
+ func TestListKeepsOldRecordsDropsBroken(t *testing.T) {
197
199
  dir := isolate(t)
198
200
 
199
- // 手工塞一条 25 小时前的记录(Retention = 24h)
201
+ // 手工塞一条 25 天前的记录:以前会被当「过期」删掉,现在必须原样留着。
200
202
  old := Record{
201
203
  RunID: "run-old",
202
204
  Engine: "claude",
203
205
  State: StateDone,
204
- StartedAt: time.Now().Add(-25 * time.Hour),
205
- UpdatedAt: time.Now().Add(-25 * time.Hour),
206
+ StartedAt: time.Now().Add(-25 * 24 * time.Hour),
207
+ UpdatedAt: time.Now().Add(-25 * 24 * time.Hour),
206
208
  }
207
209
  data, _ := json.Marshal(old)
208
210
  if err := os.WriteFile(filepath.Join(dir, "run-old.json"), data, 0o600); err != nil {
209
211
  t.Fatal(err)
210
212
  }
211
- // 再来一条坏的(非法 JSON)也应被清掉
213
+ // 坏文件(非法 JSON)读不出来 → 清掉。
212
214
  if err := os.WriteFile(filepath.Join(dir, "run-broken.json"), []byte("{not json"), 0o600); err != nil {
213
215
  t.Fatal(err)
214
216
  }
@@ -217,11 +219,14 @@ func TestListPrunesExpiredRecords(t *testing.T) {
217
219
  if err != nil {
218
220
  t.Fatalf("List: %v", err)
219
221
  }
220
- if len(all) != 0 {
221
- t.Errorf("过期/坏记录应被清掉, got %+v", all)
222
+ if len(all) != 1 || all[0].RunID != "run-old" {
223
+ t.Errorf("老记录应保留、坏记录应被清掉, got %+v", all)
222
224
  }
223
- if _, err := os.Stat(filepath.Join(dir, "run-old.json")); !os.IsNotExist(err) {
224
- t.Error("过期文件应已删除")
225
+ if _, err := os.Stat(filepath.Join(dir, "run-old.json")); err != nil {
226
+ t.Error("老记录文件不该被删除")
227
+ }
228
+ if _, err := os.Stat(filepath.Join(dir, "run-broken.json")); !os.IsNotExist(err) {
229
+ t.Error("坏文件应已删除")
225
230
  }
226
231
  }
227
232
 
@@ -235,3 +240,112 @@ func TestDirPrefersEnv(t *testing.T) {
235
240
  t.Errorf("Dir() = %q want /tmp/x-sessions", got)
236
241
  }
237
242
  }
243
+
244
+ // NoPersist:默认不留痕时磁盘上什么都没有,但 run_id 仍要照常分配
245
+ // (常驻会话的追加入口按它命名 —— 见 AppendSocketPath)。
246
+ func TestBeginNoPersistLeavesNoRecord(t *testing.T) {
247
+ dir := t.TempDir()
248
+ t.Setenv(EnvDir, dir)
249
+ h, err := Begin(BeginOptions{Engine: "claude", NoPersist: true})
250
+ if err != nil {
251
+ t.Fatalf("Begin: %v", err)
252
+ }
253
+ if h.RunID() == "" {
254
+ t.Fatal("不留盘也要有 run_id(追加入口靠它命名)")
255
+ }
256
+ // 句柄内的回写照常可用(纯内存),只是不落文件。
257
+ h.SetPID(4321, true)
258
+ h.Mark(StateWaitingApproval)
259
+ h.Finish("sid-x", StateDone)
260
+ if got := h.Record().State; got != StateDone {
261
+ t.Errorf("内存里的状态应是 done,实际 %q", got)
262
+ }
263
+ ents, _ := os.ReadDir(dir)
264
+ for _, e := range ents {
265
+ if strings.HasSuffix(e.Name(), ".json") {
266
+ t.Errorf("NoPersist 不该落登记记录,却有 %s", e.Name())
267
+ }
268
+ }
269
+ if recs, err := List(); err != nil || len(recs) != 0 {
270
+ t.Errorf("List() 应为空:%+v (err=%v)", recs, err)
271
+ }
272
+ }
273
+
274
+ // AppendSocketPath 与 ListenAppend 必须算出同一个路径 —— 不留档时 --append
275
+ // 靠的就是这个约定(没有登记表发布 socket 地址)。
276
+ func TestAppendSocketPathMatchesListen(t *testing.T) {
277
+ // 用短目录(isolate):unix socket 路径有 104 字节硬上限,t.TempDir() 那种长路径
278
+ // 会直接 bind 失败 —— 这正是 isolate 存在的理由。
279
+ isolate(t)
280
+ h, err := Begin(BeginOptions{Engine: "claude", NoPersist: true})
281
+ if err != nil {
282
+ t.Fatalf("Begin: %v", err)
283
+ }
284
+ ap, err := ListenAppend(h)
285
+ if err != nil {
286
+ t.Fatalf("ListenAppend: %v", err)
287
+ }
288
+ defer ap.Close()
289
+ if got, want := ap.Path(), AppendSocketPath(h.RunID()); got != want {
290
+ t.Errorf("socket 路径不一致:建出来 %q,按约定推 %q", got, want)
291
+ }
292
+ if _, serr := os.Stat(AppendSocketPath(h.RunID())); serr != nil {
293
+ t.Errorf("按约定推出的路径上应有 socket 文件:%v", serr)
294
+ }
295
+ }
296
+
297
+ // 来源端归一(2026-10-11):只认 mobile / pc,其余(含空、大小写、带空白)一律 ""。
298
+ // 认错比认不出更糟,所以陌生值必须落回未知(界面走默认设备)。
299
+ func TestNormalizeSource(t *testing.T) {
300
+ cases := map[string]string{
301
+ "mobile": "mobile", "MOBILE": "mobile", " mobile ": "mobile",
302
+ "pc": "pc", "PC": "pc",
303
+ "": "", " ": "", "desktop": "", "ios": "",
304
+ }
305
+ for in, want := range cases {
306
+ if got := normalizeSource(in); got != want {
307
+ t.Errorf("normalizeSource(%q) = %q want %q", in, got, want)
308
+ }
309
+ }
310
+ }
311
+
312
+ // 来源客户端(2026-10-12)是**开放词表**:各 magic-* 应用各一个(见 Profile.AppID),
313
+ // core 不该枚举它。所以这里只钉两件事:空白归一为空、超长按 rune 截断(不切坏中文)。
314
+ func TestNormalizeClient(t *testing.T) {
315
+ for _, c := range []struct{ in, want string }{
316
+ {"magic-client", "magic-client"},
317
+ {" magic-client ", "magic-client"},
318
+ {"", ""},
319
+ {" ", ""},
320
+ } {
321
+ if got := normalizeClient(c.in); got != c.want {
322
+ t.Errorf("normalizeClient(%q) = %q want %q", c.in, got, c.want)
323
+ }
324
+ }
325
+ long := strings.Repeat("客户端", 40) // 120 rune > 64
326
+ got := normalizeClient(long)
327
+ // 判据是「截断后仍是一个完整 rune 序列」:直接比前 64 个 rune,
328
+ // 顺带钉住边界 —— 64 不是 3 的整数倍,所以末位落在「客」上(按字节切就会切出半个字)。
329
+ want := string([]rune(long)[:64])
330
+ if got != want {
331
+ t.Errorf("超长应按 rune 截到 64:got %d rune want %d rune", len([]rune(got)), len([]rune(want)))
332
+ }
333
+ }
334
+
335
+ // Begin 要把来源客户端落进记录(客户端列表据此过滤),且过长的值被截断。
336
+ func TestBeginRecordsClient(t *testing.T) {
337
+ dir := t.TempDir()
338
+ t.Setenv(EnvDir, dir)
339
+ h, err := Begin(BeginOptions{Engine: "claude", Client: " magic-client "})
340
+ if err != nil {
341
+ t.Fatalf("Begin: %v", err)
342
+ }
343
+ if got := h.Record().Client; got != "magic-client" {
344
+ t.Errorf("Record.Client = %q want magic-client", got)
345
+ }
346
+ // 落盘后读回来仍是它(客户端重启后列表还要靠它过滤)。
347
+ recs, err := List()
348
+ if err != nil || len(recs) != 1 || recs[0].Client != "magic-client" {
349
+ t.Errorf("List 读回的 client 不对: %+v (err=%v)", recs, err)
350
+ }
351
+ }