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
@@ -13,8 +13,17 @@ package cli
13
13
  // 否则一次 EIO 就把流式变成报错(结果链就是 magic-agent 退 = 插件 SSE 收尾 = 用户界面卡死)。
14
14
  // 2. **最多一次重发**:每条事件写一次;不重试,不重读。不重试是因为重写会污染 seq 序列
15
15
  // (历史接口靠 seq 游标),且消息内容重复展示 = 体验差。
16
- // 3. **session_id 未知前也能暂存**:不同引擎在流的不同位置暴露 session_id(init / assistant / result),
17
- // 而最早的 thinking / tool_use 事件可能先到。开文件必须 lazy —— 拿到 id 再开。
16
+ // 3. **session_id 未知也先落盘**(2026-10-15 改):不同引擎在流的不同位置暴露
17
+ // session_id(init / assistant / result),最早的 thinking / tool_use 可能先到。
18
+ // 此前是「拿到 id 才开文件」,于是三件事一起坏掉:
19
+ // · id 之前的事件全留在内存 buffer 里(上限 64 条,超了丢);
20
+ // · 引擎**整轮都不给 id** 的形态(dsh 的 headless 回退轮)收工时 buffer 直接丢光 ——
21
+ // `--save-history` 白开,磁盘上一个字节都没有(静默失败);
22
+ // · 正在跑的会话在别的客户端眼里「有记录、没内容」:文件还不知道叫什么名字,
23
+ // `/desk/session/<id>/messages` 读出来永远是空历史。
24
+ // 现在:core 在 beginSession 时就拿到 run_id → 立刻落到 `<run_id>.jsonl`;
25
+ // 引擎的 session_id 到达后**改名**成 `<session_id>.jsonl`(终名,见 renameTo)。
26
+ // 读侧用 session.ResolveLogID 把 run_id 翻成 session_id,两个名字都读得到。
18
27
  //
19
28
  // 落盘动作走 goroutine + buffered channel:调用方 (onEvent) 只做「塞进 channel 即返回」,
20
29
  // IO 慢的副作用不会传到流路径。
@@ -24,8 +33,10 @@ import (
24
33
  "encoding/json"
25
34
  "errors"
26
35
  "fmt"
36
+ "io"
27
37
  "os"
28
38
  "path/filepath"
39
+ "strings"
29
40
  "sync"
30
41
  "sync/atomic"
31
42
  "time"
@@ -56,9 +67,11 @@ func sessionLogDir() (string, error) {
56
67
  //
57
68
  // Kind 取自两处,合并成一份词表:
58
69
  // - 引擎事件(internal/agent/stream.go 的 StreamEventKind):thinking / text /
59
- // tool_use / tool_result / ask / turn_end;
70
+ // tool_use / tool_result / ask / image(助手图片块,Text=data URI)/ turn_end /
71
+ // notice(过程提示,如 max_tokens 截断);
60
72
  // - core 自己补的:user(用户提问,见 ask.go runStreamAsk —— 引擎事件里没有它,
61
- // 但历史缺了它就只剩 agent 单方面说话)、error(失败兜底)。
73
+ // 但历史缺了它就只剩 agent 单方面说话)、command(斜杠命令的开跑/收尾一对,
74
+ // 同在 runStreamAsk 依输入形态合成)、error(失败兜底)。
62
75
  //
63
76
  // 注意:Thinking 文本可能很大(claude 长思考),Text 可能有工具调用摘要(tool_use),
64
77
  // 都直接 JSON 序列化。文件按行追加,断电最坏丢最后一行(不是大问题,流仍在跑)。
@@ -76,14 +89,36 @@ type sessionEvent struct {
76
89
  ToolKind string `json:"tool_kind,omitempty"`
77
90
  SessionID string `json:"session_id,omitempty"`
78
91
  Ask *agent.AskRequest `json:"ask,omitempty"`
92
+ /* 状态收敛四元组(2026-10-02)。落盘它们是为了让**历史回放与实时渲染
93
+ 画出同一种东西**:回放侧不必自己猜「这条消息到哪算完」,
94
+ 按 (item_id, item_revision) 覆盖即可。缺了它们,历史里每条 text
95
+ 都只是孤立的增量片段(与实时流同源但无法收敛)。 */
96
+ ItemID string `json:"item_id,omitempty"`
97
+ ItemRevision uint64 `json:"item_revision,omitempty"`
98
+ Status string `json:"status,omitempty"`
99
+ /* Snapshot 是「该 item 到此刻的累积整段」——**只读不写**(2026-10-20)。
100
+ 写盘时恒为空(见 appendLine):每条增量都带一份 ⇒ 二次方膨胀
101
+ (实测一条会话 1.8GB,95.6% 是它)。
102
+ 字段保留是为了读**旧档**(2026-10-20 之前写的日志里它是收敛结果)。 */
103
+ Snapshot string `json:"snapshot,omitempty"`
104
+ // Error / Reason 异常收尾的原因(仅 turn_failed),与事件流同源。
105
+ Error string `json:"error,omitempty"`
106
+ Reason string `json:"reason,omitempty"`
107
+ // Attachments 用户提问携带的附件(仅 kind:"user" 有)。引擎事件里没有附件信息
108
+ // (`image` 事件只对应助手**产出**),所以只有落在这里,历史回放才复原得出
109
+ // 「那一轮用了哪张图」—— 移动端据此在历史气泡里画出缩略图。
110
+ Attachments []agent.Attachment `json:"attachments,omitempty"`
79
111
  }
80
112
 
81
113
  // sessionWriter 每条流式调用一个实例;开着就一直 append 到
82
- // `<dir>/<session_id>.jsonl`,session_id 还没出来时事件先**丢进 buffer**等 id。
114
+ // `<dir>/<session_id>.jsonl` —— session_id 还没出来时先写 `<dir>/<run_id>.jsonl`,
115
+ // 拿到 id 后**改名**(见 write / renameTo)。
83
116
  //
84
- // ⚠️ **不重发**:buffer 等 id 时最多暂存 ~64 条事件;满了就丢(按 round-robin),
85
- // 并 emit 一行 warning。我们接受这个损失 —— 历史只对「点进去看历史」这件事有用,
86
- // 极少数早期事件丢了用户察觉不到(call 号还在 SSE 流里);但「突然不写盘」绝对不行。
117
+ // ⚠️ **不丢内容**(2026-10-15 起):两道兜底 —— ① 一有 run_id 就先落盘(core 在
118
+ // beginSession 时就有);② 连 run_id 都没有(登记表开不出来、或纯测试构造)时才进
119
+ // pending buffer(上限 64 条,满了按 ring-buffer 丢并计数)。
120
+ // 曾经只有 ②:引擎整轮不给 id 时整段历史在收工时被丢光,而「正在跑的会话读出来是空的」
121
+ // 是**静默失败** —— 比少几条早期事件糟得多。
87
122
  type sessionWriter struct {
88
123
  dir string
89
124
  runID uint64
@@ -96,32 +131,76 @@ type sessionWriter struct {
96
131
  file *os.File
97
132
  w *bufio.Writer
98
133
  filePath string
134
+ fileName string // 当前文件名(不含 .jsonl);空 = 还没开文件
99
135
  overflow atomic.Uint64 // 被 ring-buffer 丢弃的事件计数(诊断用)
136
+ // poke 让主 goroutine 催一句「现在按最新名字把文件开出来 / 改名」,
137
+ // 不必等下一个事件 —— 否则「先 push 了 user 事件、随后才拿到 run_id」
138
+ // 或「id 刚到手就进入长时间静默」这两种情况下,文件会迟迟不出现。
139
+ poke chan struct{}
140
+ // flushTick 周期性把缓冲刷到磁盘(1s)。bufio 要攒满 32KB 才写,
141
+ // 而「正在跑的会话」必须能被**别的客户端**读到(它们直接读文件)——
142
+ // 内容一直躺在内存里,读出来就是空历史(静默失败)。见 flush。
143
+ flushTick *time.Ticker
100
144
 
101
145
  // sessionID 跨 goroutine 共享:CLI 的 OnSessionID 写入(流路径),
102
146
  // sw.write 读(写盘 goroutine)。atomic.Pointer[string] 保证可见性。
103
147
  sessionID atomic.Pointer[string]
148
+ // runIDStr 本次运行的**登记 id**(session.Handle.RunID()),由 CLI 在 beginSession
149
+ // 之后经 setRunID 写入、写盘 goroutine 读 —— 与 sessionID 同一套可见性做法。
150
+ runIDStr atomic.Pointer[string]
104
151
  }
105
152
 
106
153
  // newSessionWriter 创建并启动一个 sessionWriter(仅包内测试用,单测不依赖 onSessionID)。
107
154
  func newSessionWriter(dir string, runID uint64) *sessionWriter {
108
155
  sw := &sessionWriter{
109
- dir: dir,
110
- runID: runID,
111
- stop: make(chan struct{}),
112
- done: make(chan struct{}),
113
- in: make(chan sessionEvent, 128),
156
+ dir: dir,
157
+ runID: runID,
158
+ stop: make(chan struct{}),
159
+ done: make(chan struct{}),
160
+ in: make(chan sessionEvent, 128),
161
+ poke: make(chan struct{}, 1),
162
+ flushTick: time.NewTicker(time.Second),
114
163
  }
115
164
  initID := ""
116
165
  sw.sessionID.Store(&initID)
166
+ sw.runIDStr.Store(&initID)
117
167
  go sw.loop()
118
168
  return sw
119
169
  }
120
170
 
121
171
  // setSessionID 由 CLI 端 OnSessionID 调用 —— 流里见到 session_id 就推给 writer。
172
+ // 顺带催一次写盘:名字从 run_id 换成 session_id 要**当场**发生,不能等下一个事件
173
+ // (id 到手后可能长时间没事件,那段时间里按 session_id 去读会读到空文件)。
174
+ //
175
+ // ⚠️ 空 id 直接跳过(与 setRunID 同一口径):写进空 session_id,name() 就退回 run_id ——
176
+ // 等于把已经改成终名的文件**又改回** run_id。而「只有前几条事件带 id」的引擎形态很常见
177
+ // (CLI 的 onEvent 兜底就是逐事件推的,见 ask.go),这个守卫是必须的。
122
178
  func (sw *sessionWriter) setSessionID(id string) {
123
- s := id
179
+ s := strings.TrimSpace(id)
180
+ if s == "" {
181
+ return
182
+ }
124
183
  sw.sessionID.Store(&s)
184
+ sw.pokeWriter()
185
+ }
186
+
187
+ // setRunID 由 CLI 在 beginSession 之后调用:把本次运行的登记 id 交给 writer。
188
+ // 有了它,引擎还没吐 session_id 时日志就先落在 <run_id>.jsonl(见 write 的说明)。
189
+ func (sw *sessionWriter) setRunID(id string) {
190
+ s := strings.TrimSpace(id)
191
+ if s == "" {
192
+ return
193
+ }
194
+ sw.runIDStr.Store(&s)
195
+ sw.pokeWriter()
196
+ }
197
+
198
+ // pokeWriter 非阻塞地催一句写盘 goroutine(poke 满/未初始化都直接返回)。
199
+ func (sw *sessionWriter) pokeWriter() {
200
+ select {
201
+ case sw.poke <- struct{}{}:
202
+ default:
203
+ }
125
204
  }
126
205
 
127
206
  // currentSessionID 在 sw.write 里读;不可与 OnSessionID 配对交换字段(race)。
@@ -129,6 +208,19 @@ func (sw *sessionWriter) currentSessionID() string {
129
208
  return *sw.sessionID.Load()
130
209
  }
131
210
 
211
+ // currentRunID 本次运行的登记 id(空 = 登记表没开出来)。
212
+ func (sw *sessionWriter) currentRunID() string {
213
+ return *sw.runIDStr.Load()
214
+ }
215
+
216
+ // name 当前该用的日志名:优先 session_id(续接时一开始就是终名),否则退回 run_id。
217
+ func (sw *sessionWriter) name() string {
218
+ if id := sw.currentSessionID(); id != "" {
219
+ return id
220
+ }
221
+ return sw.currentRunID()
222
+ }
223
+
132
224
  // newSessionWriterAuto 解析目录 + 起 writer。
133
225
  // 解析失败:返回 nil + error,让调用方决定「不写盘继续」还是「中断流」。
134
226
  func newSessionWriterAuto(dirFn func() (string, error)) (*sessionWriter, error) {
@@ -176,11 +268,12 @@ func (sw *sessionWriter) close() {
176
268
 
177
269
  // loop 是单 goroutine 的 IO 循环 —— 拉事件、拿会话 id、写盘、刷文件。
178
270
  //
179
- // 顺序的简化:把「拿 session_id」和「打开文件」放在事件处理里。第一条事件没 id
180
- // → 进 pending;有 id 之后**只**开一次文件并把 pending 也写进去。之后每条事件
181
- // 直接落盘,不再有"没 id"的问题。
271
+ // 顺序的简化:把「拿 session_id / run_id」和「打开 / 改名文件」都放进 ensureFile,
272
+ // 每次写之前调一次。于是三种时序都对:一开始就知道 id(续接,直接终名)、
273
+ // 先有 run_id 后有 id(改名)、连 run_id 都没有(暂存等名字)。
182
274
  func (sw *sessionWriter) loop() {
183
275
  defer close(sw.done)
276
+ defer sw.flushTick.Stop()
184
277
  defer func() {
185
278
  if sw.w != nil {
186
279
  _ = sw.w.Flush()
@@ -199,34 +292,68 @@ func (sw *sessionWriter) loop() {
199
292
  return
200
293
  }
201
294
  }
295
+ case <-sw.poke:
296
+ // 只是「名字变了,把文件开出来 / 改名」——不写事件。
297
+ sw.ensureFile()
298
+ case <-sw.flushTick.C:
299
+ sw.flush()
202
300
  case ev := <-sw.in:
203
301
  sw.write(ev)
204
302
  }
205
303
  }
206
304
  }
207
305
 
208
- // write 是单条事件的落盘。id 还没出现就暂存;id 出现后开文件并把暂存的也写进去。
306
+ // flush 把缓冲区写进内核(**不** fsync):让「还没收尾的会话」在磁盘上就看得见。
307
+ //
308
+ // 为什么必须有(2026-10-15):bufio 要攒满 32KB 才落盘,而正在跑的会话会被**别的
309
+ // 客户端**直接读文件(/desk/session/<id>/messages、--session-log)—— 内容全在内存里
310
+ // 时它们读出来是空历史,看起来像「这条会话没有内容」。1s 一次 write(2) 的代价可忽略,
311
+ // 也远小于「每行 fsync」。
312
+ func (sw *sessionWriter) flush() {
313
+ if sw.w != nil {
314
+ _ = sw.w.Flush()
315
+ }
316
+ }
317
+
318
+ // write 是单条事件的落盘:先保证文件是最新名字的那个,再追加一行。
209
319
  func (sw *sessionWriter) write(ev sessionEvent) {
210
- // 1. 拿到当前 session_id(来自 accumulator;流早期可能为空)。
320
+ // 1. 拿当前 session_id(每个引擎暴露它的位置不同,早期可能为空)。
211
321
  if ev.SessionID == "" {
212
322
  ev.SessionID = sw.currentSessionID()
213
323
  }
214
- if ev.SessionID == "" {
215
- // 还没 id:进 pending,等后续事件触发「打开文件」的动作。
216
- sw.mu.Lock()
217
- if len(sw.pending) < 64 {
218
- sw.pending = append(sw.pending, ev)
219
- } else {
220
- sw.overflow.Add(1)
221
- }
222
- sw.mu.Unlock()
324
+ sw.ensureFile()
325
+ if sw.file == nil {
326
+ // 连名字都没有(登记表开不出来、或纯测试构造):暂存等名字。
327
+ sw.buffer(ev)
223
328
  return
224
329
  }
225
- // 2. 还没开文件:开 + 刷 pending。文件路径 = dir/<session_id>.jsonl。
330
+ sw.appendLine(ev)
331
+ }
332
+
333
+ // buffer 暂存一条「还没有文件名」的事件;只有 pending 也满时才丢(并计数)。
334
+ func (sw *sessionWriter) buffer(ev sessionEvent) {
335
+ sw.mu.Lock()
336
+ if len(sw.pending) < 64 {
337
+ sw.pending = append(sw.pending, ev)
338
+ } else {
339
+ sw.overflow.Add(1)
340
+ }
341
+ sw.mu.Unlock()
342
+ }
343
+
344
+ // ensureFile 保证「该写的文件已打开、且用的是当前该用的名字」:
345
+ //
346
+ // · 还没开 → 有名字就开,并把暂存的早期事件按序补写(首次不丢);
347
+ // · 已经开着但名字过期(期间拿到了 session_id)→ 改名到终名。
348
+ func (sw *sessionWriter) ensureFile() {
226
349
  if sw.file == nil {
227
- if err := sw.openFile(ev.SessionID); err != nil {
350
+ name := sw.name()
351
+ if name == "" {
352
+ return
353
+ }
354
+ if err := sw.openFile(name); err != nil {
228
355
  // 开不开得出来(磁盘满 / 权限):本条丢弃,**不抛错**。下一条再来还试。
229
- fmt.Fprintf(os.Stderr, "magic-agent: 持久化会话失败(%s):%v(流继续)\n", ev.SessionID, err)
356
+ fmt.Fprintf(os.Stderr, "magic-agent: 持久化会话失败(%s):%v(流继续)\n", name, err)
230
357
  return
231
358
  }
232
359
  // pending 也写进去(首次不丢早期事件)。
@@ -237,26 +364,160 @@ func (sw *sessionWriter) write(ev sessionEvent) {
237
364
  for _, pev := range pend {
238
365
  sw.appendLine(pev)
239
366
  }
367
+ return
368
+ }
369
+ if want := sw.name(); want != "" && want != sw.fileName {
370
+ sw.renameTo(want)
240
371
  }
241
- sw.appendLine(ev)
242
372
  }
243
373
 
244
- // openFile 在 <dir> 下创建/追加 <session_id>.jsonl。权限 0600(与 session 登记表一致)。
374
+ // renameTo 把日志文件改名到终名(`<run_id>.jsonl` → `<session_id>.jsonl`)。
375
+ //
376
+ // 关旧文件 → 改名 → 按新名重新 append 打开;每一步失败都只警告、流继续。
377
+ // ⚠️ 终名文件**已经存在**时不能直接 os.Rename —— Unix 下它会静默覆盖,
378
+ // 等于把那条会话已有的历史删掉(续接 / 重跑同一会话时会撞上)。
379
+ // 这种情况改为把内容追加合并进去,再删掉临时名。
380
+ func (sw *sessionWriter) renameTo(name string) {
381
+ oldPath, newPath := sw.filePath, filepath.Join(sw.dir, name+".jsonl")
382
+ if sw.w != nil {
383
+ _ = sw.w.Flush()
384
+ }
385
+ if sw.file != nil {
386
+ _ = sw.file.Close()
387
+ }
388
+ sw.file, sw.w, sw.filePath, sw.fileName = nil, nil, "", ""
389
+ if _, err := os.Stat(newPath); err == nil {
390
+ appendFileInto(newPath, oldPath)
391
+ } else if err := os.Rename(oldPath, newPath); err != nil {
392
+ fmt.Fprintf(os.Stderr, "magic-agent: 会话日志改名失败(%s → %s):%v(流继续)\n", oldPath, newPath, err)
393
+ }
394
+ if err := sw.openFile(name); err != nil {
395
+ fmt.Fprintf(os.Stderr, "magic-agent: 改名后重新打开会话日志失败(%s):%v(流继续)\n", newPath, err)
396
+ }
397
+ }
398
+
399
+ // appendFileInto 把 src 的内容追加到 dst 末尾,然后删掉 src。
400
+ // 尽力而为:失败(含 dst 打不开)只警告,不改调用方的控制流。
401
+ func appendFileInto(dst, src string) {
402
+ in, err := os.Open(src)
403
+ if err != nil {
404
+ return
405
+ }
406
+ defer in.Close()
407
+ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_APPEND, 0o600)
408
+ if err != nil {
409
+ fmt.Fprintf(os.Stderr, "magic-agent: 会话日志合并失败(打不开 %s):%v\n", dst, err)
410
+ return
411
+ }
412
+ defer out.Close()
413
+ if _, err := io.Copy(out, in); err != nil {
414
+ fmt.Fprintf(os.Stderr, "magic-agent: 会话日志合并失败(%s → %s):%v\n", src, dst, err)
415
+ return
416
+ }
417
+ _ = os.Remove(src)
418
+ }
419
+
420
+ // openFile 在 <dir> 下创建/追加 <name>.jsonl。权限 0600(与 session 登记表一致)。
245
421
  //
246
422
  // ⚠️ 用 append 模式 + 0600:不截断已有文件(续接同一会话不会重复第一条事件),
247
423
  // 权限只有同用户能读 —— 跨用户读自己用 read-only 调用就好了。
248
- func (sw *sessionWriter) openFile(id string) error {
249
- sw.filePath = filepath.Join(sw.dir, id+".jsonl")
424
+ //
425
+ // ⚠️ **seq 要接在文件末尾**(2026-10-16):这个文件是**跨轮追加**的(同一会话每轮一次
426
+ // 调用 = 一个新进程 = 一个新 writer),而 seq 原先只是「本进程内的行号」,每轮从 1 重来。
427
+ // 于是同一个文件里会出现多条 `seq=1`:
428
+ //
429
+ // · 客户端回放按 seq 去重(防重复投递)→ 第二轮起的事件被当成重复帧丢掉,
430
+ // 界面上**只显示第一轮**(移动端实测报障);
431
+ // · `--session-log --after <seq>` 的增量续读游标同时失去意义。
432
+ //
433
+ // 所以打开时先把计数器接到文件里已有的最大 seq 之后。
434
+ func (sw *sessionWriter) openFile(name string) error {
435
+ sw.filePath = filepath.Join(sw.dir, name+".jsonl")
250
436
  f, err := os.OpenFile(sw.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
251
437
  if err != nil {
252
438
  return err
253
439
  }
254
440
  sw.file = f
255
- sw.w = bufio.NewWriterSize(f, 32*1024) // 32KB 缓冲;写完一行 Flush,由 timer 兜底
441
+ sw.fileName = name
442
+ sw.w = bufio.NewWriterSize(f, 32*1024) // 32KB 缓冲;另有 1s 的定时 flush 兜底(见 flush)
443
+ if n := readMaxSeq(sw.filePath); n > 0 {
444
+ sw.seq.Store(n)
445
+ }
256
446
  return nil
257
447
  }
258
448
 
449
+ // logSeqWindow 往回读的窗口:与文件大小无关地拿到末尾的 seq(正常一条事件远小于它)。
450
+ const logSeqWindow = 1 << 20
451
+
452
+ // readMaxSeq 读日志文件里**已落盘的最大 seq**(给新 writer 接号用,见 openFile)。
453
+ //
454
+ // 先只看尾部一个窗口;窗口里没有完整行时(例如上一轮最后一条是十几 MB 的 result)
455
+ // 退回整文件扫描。任何失败都返回 0(退化成「从 1 开始」,与旧行为一致,不影响写盘)。
456
+ func readMaxSeq(path string) uint64 {
457
+ f, err := os.Open(path)
458
+ if err != nil {
459
+ return 0
460
+ }
461
+ defer f.Close()
462
+ st, err := f.Stat()
463
+ if err != nil || st.Size() == 0 {
464
+ return 0
465
+ }
466
+ start := st.Size() - logSeqWindow
467
+ if start < 0 {
468
+ start = 0
469
+ }
470
+ buf := make([]byte, st.Size()-start)
471
+ if _, err := f.ReadAt(buf, start); err == nil {
472
+ if n := lastSeqIn(buf); n > 0 {
473
+ return n
474
+ }
475
+ }
476
+ // 兜底:整文件扫一遍(窗口里没有可解析的行时才走到这里)。
477
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
478
+ return 0
479
+ }
480
+ var max uint64
481
+ sc := bufio.NewScanner(f)
482
+ sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
483
+ for sc.Scan() {
484
+ if n := lastSeqIn(sc.Bytes()); n > max {
485
+ max = n
486
+ }
487
+ }
488
+ return max
489
+ }
490
+
491
+ // lastSeqIn 从一段(可能是多行的)字节里找**最后一条**能解析出行号的 JSON。
492
+ // 末尾常是写了一半的行(进程被杀),解析失败跳过即可。
493
+ func lastSeqIn(b []byte) uint64 {
494
+ lines := strings.Split(string(b), "\n")
495
+ for i := len(lines) - 1; i >= 0; i-- {
496
+ line := strings.TrimSpace(lines[i])
497
+ if line == "" || line[0] != '{' {
498
+ continue
499
+ }
500
+ var ev sessionEvent
501
+ if json.Unmarshal([]byte(line), &ev) == nil && ev.Seq > 0 {
502
+ return ev.Seq
503
+ }
504
+ }
505
+ return 0
506
+ }
507
+
259
508
  // appendLine 写一行 JSON。错(写满 / 设备断开)只 log、不 panic、不影响流路径。
509
+ //
510
+ // ⚠️ **落盘不写 snapshot**(2026-10-20):snapshot 是「该 item 到此刻的累积整段」,
511
+ // 每条增量事件都带一份 ⇒ 长会话二次方膨胀。实测一条会话 18.6 万条事件、
512
+ // 思考流被切成 5799 个碎片:日志 1.8GB,其中 1.83GB(95.6%)是 snapshot,
513
+ // 真正的增量正文只有 16MB。
514
+ //
515
+ // 为什么可以不留:回放侧只用增量 `text` 自己拼
516
+ // (client-ui/shared/conv/09-stream-apply.js 的 appendThinking / appendText),
517
+ // 全仓没有任何消费方读**落盘**的事件级 snapshot。实时流(--stream 的 NDJSON)
518
+ // 仍然照旧带 snapshot —— 那是「按 item 覆盖」的多端同步需要的,与存档是两回事。
519
+ //
520
+ // ⚠️ 字段本身保留(读旧档要认,见 sessionEventFromJSON):只是不再**写**。
260
521
  func (sw *sessionWriter) appendLine(ev sessionEvent) {
261
522
  ev.Seq = sw.seq.Add(1)
262
523
  if ev.At == "" {
@@ -265,6 +526,8 @@ func (sw *sessionWriter) appendLine(ev sessionEvent) {
265
526
  if ev.SessionID == "" {
266
527
  ev.SessionID = sw.currentSessionID()
267
528
  }
529
+ // 见函数头:累积快照不落盘(只留增量 text + item 收敛三元组)。
530
+ ev.Snapshot = ""
268
531
  b, err := json.Marshal(ev)
269
532
  if err != nil {
270
533
  fmt.Fprintf(os.Stderr, "magic-agent: 会话事件序列化失败:%v(流继续)\n", err)
@@ -275,8 +538,8 @@ func (sw *sessionWriter) appendLine(ev sessionEvent) {
275
538
  fmt.Fprintf(os.Stderr, "magic-agent: 写会话失败:%v(流继续)\n", err)
276
539
  return
277
540
  }
278
- // 不每行都 Flush:bufio 攒一批,close 时一次性刷。代价:断电最坏丢一批;
279
- // 流路径完全不受影响。disk 也减少 90%+ 的 fsync。
541
+ // 不每行都 Flush:bufio 攒一批,另有 1s 的定时 flush(见 flush)与收尾的整批刷。
542
+ // 代价:断电最坏丢一小批;流路径完全不受影响。disk 也减少 90%+ 的 fsync。
280
543
  }
281
544
 
282
545
  // sessionEventFromJSON 把 jsonl 的一行解析成 sessionEvent。
@@ -290,6 +553,23 @@ func sessionEventFromJSON(line []byte) (sessionEvent, error) {
290
553
  return ev, nil
291
554
  }
292
555
 
556
+ // stripSnapshots 清掉输出里的 snapshot(--session-log-lean)。
557
+ //
558
+ // 为什么可以清:snapshot 是**该 item 到此刻的累积整段**,每条增量事件都带一份,
559
+ // 于是长会话的存档是二次方膨胀(实测一条会话 9.4 万条 thinking 事件 = 283MB 快照,
560
+ // 整份日志 338MB,读一次要 3.7s、序列化出来 354MB)。
561
+ // 而回放侧只用增量 text 自己拼(client-ui/shared/conv/09-stream-apply.js),
562
+ // 全仓没有任何消费方读事件级 snapshot —— 裁掉它不影响画面。
563
+ //
564
+ // ⚠️ 2026-10-20 起**写盘就不写 snapshot 了**(见 appendLine)—— 本函数仍要留着:
565
+ // 磁盘上还有 2026-10-20 之前写的旧档(带累积快照),读它们时得裁,
566
+ // 否则一次 --session-log 就能把几百 MB 灌进内存。
567
+ func stripSnapshots(evs []sessionEvent) {
568
+ for i := range evs {
569
+ evs[i].Snapshot = ""
570
+ }
571
+ }
572
+
293
573
  // ReadSessionLog 读整个 jsonl 到 []sessionEvent,调用方拿去做 UI 渲染或 diff。
294
574
  //
295
575
  // 顺序:seq 升序;空文件返回 nil。损坏行跳过(warn 一行)—— 历史不能 100% 保证,
@@ -321,5 +601,72 @@ func ReadSessionLogAfter(dir, sessionID string, after uint64) ([]sessionEvent, u
321
601
  func(e sessionEvent) uint64 { return e.Seq }, sessionEventFromJSON)
322
602
  }
323
603
 
604
+ // ReadSessionLogLean / ReadSessionLogAfterLean 是上面两条的 lean 版:在**解码前**
605
+ // 摘掉 snapshot(见 session.ReadSessionLogLean)。
606
+ //
607
+ // 为什么 CLI 也走这条:`--session-log-lean` 原本是「先全量解析再清空字段」,
608
+ // 283MB 的累积串照样逐条分配 + 反转义(实测读一次 3.7s)。既然输出里本来就不要,
609
+ // 就不该先把它造出来。输出与「先读后删」逐字段一致。
610
+ func ReadSessionLogLean(dir, sessionID string) ([]sessionEvent, error) {
611
+ if dir == "" {
612
+ var err error
613
+ dir, err = sessionLogDir()
614
+ if err != nil {
615
+ return nil, err
616
+ }
617
+ }
618
+ return session.ReadSessionLogLean(dir, sessionID, sessionEventFromJSON)
619
+ }
620
+
621
+ func ReadSessionLogAfterLean(dir, sessionID string, after uint64) ([]sessionEvent, uint64, error) {
622
+ if dir == "" {
623
+ var err error
624
+ dir, err = sessionLogDir()
625
+ if err != nil {
626
+ return nil, 0, err
627
+ }
628
+ }
629
+ return session.ReadSessionLogAfterLean(dir, sessionID, after,
630
+ func(e sessionEvent) uint64 { return e.Seq }, sessionEventFromJSON)
631
+ }
632
+
633
+ // ReadSessionLogBefore / ReadSessionLogBeforeLean 反向分页读「从文件尾跳过 skip 条后的
634
+ // 一页」(向上翻历史用)。返回 (事件, 下一页游标, 是否还有更早)。
635
+ //
636
+ // ⚠️ 游标是**跳过条数**而非 seq:同一会话每次续跑都是新进程,旧版 writer 的 seq 每轮
637
+ // 从 1 重来,拿 seq 当全局游标跨不过 run 边界(见 session.ReadSessionLogBefore)。
638
+ // 下一页游标由 core 算(含坏行与被页头对齐裁掉的条数),调用方不要自己加。
639
+ //
640
+ // 页头对齐到轮次起点 user:客户端按轮渲染,页头落在轮次中间会让思考/正文各半截。
641
+ //
642
+ // dir 传空 = 用默认历史目录(与 ReadSessionLog 同一口径)。
643
+ func ReadSessionLogBefore(dir, sessionID string, skip uint64, limit int) ([]sessionEvent, uint64, bool, error) {
644
+ if dir == "" {
645
+ var err error
646
+ dir, err = sessionLogDir()
647
+ if err != nil {
648
+ return nil, 0, false, err
649
+ }
650
+ }
651
+ return session.ReadSessionLogBefore(dir, sessionID, skip, limit, isTurnStart, sessionEventFromJSON)
652
+ }
653
+
654
+ func ReadSessionLogBeforeLean(dir, sessionID string, skip uint64, limit int) ([]sessionEvent, uint64, bool, error) {
655
+ if dir == "" {
656
+ var err error
657
+ dir, err = sessionLogDir()
658
+ if err != nil {
659
+ return nil, 0, false, err
660
+ }
661
+ }
662
+ return session.ReadSessionLogBeforeLean(dir, sessionID, skip, limit, isTurnStart, sessionEventFromJSON)
663
+ }
664
+
665
+ // isTurnStart 判一条事件是不是「一轮对话的起点」。历史里只有用户提问是这个角色
666
+ // (引擎事件流里没有 user,由 core 在 runStreamAsk 里补写,见 sessionEvent 的注释)。
667
+ func isTurnStart(ev sessionEvent) bool {
668
+ return ev.Kind == "user"
669
+ }
670
+
324
671
  // errClosed 表示 writer 已关。push 收到这个就吞掉 —— 不该发生,但保险。
325
672
  var errClosed = errors.New("session writer closed")