evolclaw 3.1.4 → 3.1.6

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 (99) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/agents/claude-runner.js +398 -161
  3. package/dist/agents/kit-renderer.js +191 -25
  4. package/dist/aun/aid/agentmd.js +75 -103
  5. package/dist/aun/aid/client.js +1 -29
  6. package/dist/aun/aid/identity.js +105 -64
  7. package/dist/aun/aid/index.js +2 -1
  8. package/dist/aun/aid/store.js +74 -0
  9. package/dist/aun/msg/group.js +2 -2
  10. package/dist/aun/msg/p2p.js +26 -2
  11. package/dist/aun/rpc/connection.js +23 -30
  12. package/dist/channels/aun.js +174 -99
  13. package/dist/channels/dingtalk.js +2 -1
  14. package/dist/channels/feishu.js +301 -199
  15. package/dist/channels/qqbot.js +2 -1
  16. package/dist/channels/wechat.js +2 -1
  17. package/dist/channels/wecom.js +2 -1
  18. package/dist/cli/agent.js +21 -16
  19. package/dist/cli/bench.js +41 -28
  20. package/dist/cli/help.js +8 -0
  21. package/dist/cli/index.js +176 -87
  22. package/dist/cli/init-channel.js +5 -1
  23. package/dist/cli/init.js +37 -21
  24. package/dist/cli/link-rules.js +1 -7
  25. package/dist/cli/model.js +549 -0
  26. package/dist/cli/net-check.js +133 -50
  27. package/dist/cli/watch-msg.js +7 -7
  28. package/dist/cli/watch-web/debug-log.js +18 -0
  29. package/dist/cli/watch-web/server.js +306 -0
  30. package/dist/cli/watch-web/sources/aid.js +63 -0
  31. package/dist/cli/watch-web/sources/msg.js +70 -0
  32. package/dist/cli/watch-web/sources/session.js +638 -0
  33. package/dist/cli/watch-web/sources/types.js +10 -0
  34. package/dist/cli/watch-web/static/app.js +546 -0
  35. package/dist/cli/watch-web/static/index.html +54 -0
  36. package/dist/cli/watch-web/static/style.css +247 -0
  37. package/dist/config-store.js +1 -22
  38. package/dist/core/channel-loader.js +7 -4
  39. package/dist/core/command-handler.js +261 -133
  40. package/dist/core/evolagent-registry.js +1 -1
  41. package/dist/core/evolagent.js +4 -22
  42. package/dist/core/interaction-router.js +59 -0
  43. package/dist/core/message/im-renderer.js +9 -20
  44. package/dist/core/message/message-bridge.js +13 -9
  45. package/dist/core/message/message-log.js +2 -2
  46. package/dist/core/message/message-processor.js +211 -123
  47. package/dist/core/message/stream-idle-monitor.js +21 -0
  48. package/dist/core/model/model-catalog.js +215 -0
  49. package/dist/core/model/model-scope.js +250 -0
  50. package/dist/core/relation/peer-identity.js +58 -55
  51. package/dist/core/relation/peer-key.js +16 -0
  52. package/dist/core/session/session-fs-store.js +34 -55
  53. package/dist/core/session/session-key.js +24 -0
  54. package/dist/core/session/session-manager.js +308 -251
  55. package/dist/core/session/session-mapper.js +9 -4
  56. package/dist/core/trigger/manager.js +3 -3
  57. package/dist/core/trigger/parser.js +4 -4
  58. package/dist/core/trigger/scheduler.js +22 -7
  59. package/dist/index.js +61 -7
  60. package/dist/ipc.js +23 -1
  61. package/dist/utils/error-utils.js +6 -0
  62. package/dist/utils/process-introspect.js +7 -5
  63. package/kits/docs/GUIDE.md +2 -2
  64. package/kits/docs/INDEX.md +8 -8
  65. package/kits/docs/channels/aun.md +56 -17
  66. package/kits/docs/channels/feishu.md +41 -12
  67. package/kits/docs/context-assembly.md +182 -0
  68. package/kits/docs/evolclaw/INDEX.md +43 -0
  69. package/kits/docs/evolclaw/agent.md +49 -0
  70. package/kits/docs/evolclaw/aid.md +49 -0
  71. package/kits/docs/evolclaw/ctl.md +46 -0
  72. package/kits/docs/evolclaw/group.md +89 -0
  73. package/kits/docs/evolclaw/model.md +51 -0
  74. package/kits/docs/evolclaw/msg.md +91 -0
  75. package/kits/docs/evolclaw/rpc.md +35 -0
  76. package/kits/docs/evolclaw/storage.md +49 -0
  77. package/kits/docs/venues/aun-group.md +10 -0
  78. package/kits/docs/venues/aun-private.md +10 -0
  79. package/kits/docs/venues/client-desktop.md +10 -0
  80. package/kits/docs/venues/client-mobile.md +10 -0
  81. package/kits/docs/venues/feishu-group.md +13 -0
  82. package/kits/docs/venues/feishu-private.md +9 -0
  83. package/kits/docs/venues/group.md +23 -0
  84. package/kits/docs/venues/private.md +10 -0
  85. package/kits/eck_manifest.json +81 -36
  86. package/kits/rules/01-overview.md +20 -10
  87. package/kits/rules/06-channel.md +34 -27
  88. package/kits/templates/system-fragments/baseagent.md +7 -1
  89. package/kits/templates/system-fragments/channel.md +7 -5
  90. package/kits/templates/system-fragments/commands.md +19 -0
  91. package/kits/templates/system-fragments/session.md +19 -3
  92. package/kits/templates/system-fragments/venue.md +24 -0
  93. package/package.json +10 -5
  94. package/dist/aun/aid/lifecycle-log.js +0 -33
  95. package/dist/utils/aid-lifecycle-log.js +0 -33
  96. package/kits/docs/evolclaw/AGENT_CMD.md +0 -31
  97. package/kits/docs/evolclaw/MSG_GROUP.md +0 -30
  98. package/kits/docs/evolclaw/MSG_PRIVATE.md +0 -72
  99. package/kits/docs/evolclaw/tools.md +0 -25
@@ -4,6 +4,7 @@ import { logger } from '../../utils/logger.js';
4
4
  import { encodePath } from '../../utils/cross-platform.js';
5
5
  import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, appendJsonl, readJsonFile, readLastJsonlLine, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
6
6
  import { sessionToFile, fileToSession } from './session-mapper.js';
7
+ import { formatSessionKey, DEFAULT_THREAD_ID } from './session-key.js';
7
8
  import path from 'path';
8
9
  import fs from 'fs';
9
10
  import os from 'os';
@@ -21,6 +22,7 @@ export class SessionManager {
21
22
  this.eventBus = eventBus;
22
23
  this.ownerResolver = ownerResolver;
23
24
  this.adminResolver = adminResolver;
25
+ this.migrateChannelKeyFormat();
24
26
  }
25
27
  setOwnerResolver(resolver) {
26
28
  this.ownerResolver = resolver;
@@ -87,83 +89,39 @@ export class SessionManager {
87
89
  // ─── File I/O helpers ───
88
90
  /**
89
91
  * 解析 chat 目录路径。
90
- * 1. 先扫描所有 chat 目录,按 channelId 查找匹配项(同时 channelType==channel 或缺失时直接 channelId 匹配)
91
- * 2. 找不到则按 fallback:channelType=channel(实例名),selfId=null
92
- *
93
- * 这样保持兼容:不知道 channelType 的 caller 仍可以用 (channel, channelId) 调用。
92
+ * 需要明确的 channelType + selfAID 才能确定路径。
94
93
  */
95
- resolveChatDir(channel, channelId, channelType, selfId) {
96
- // 必须有明确 channelType 才能确定路径
97
- if (!channelType) {
98
- throw new Error(`[SessionManager] resolveChatDir requires channelType. Got channel="${channel}" channelId="${channelId}". Caller must pass channelType (e.g. 'aun', 'feishu').`);
99
- }
100
- return chatDirPath(this.sessionsDir, channelType, channelId, selfId);
94
+ resolveChatDir(channel, channelId, channelType, selfAID) {
95
+ return chatDirPath(this.sessionsDir, channelType, channelId, selfAID);
101
96
  }
102
97
  /**
103
- * 给定明确的 channelType + selfId 时直接计算路径(不扫描)。
98
+ * 给定明确的 channelType + selfAID 时直接计算路径(不扫描)。
104
99
  * 用于 caller 已经知道完整路由信息的场景(如 message-bridge 透传)。
105
100
  */
106
- resolveChatDirExact(channel, channelId, channelType, selfId) {
107
- if (channelType) {
108
- return chatDirPath(this.sessionsDir, channelType, channelId, selfId);
109
- }
110
- return this.resolveChatDirSafe(channel, channelId);
101
+ resolveChatDirExact(channel, channelId, channelType, selfAID) {
102
+ return chatDirPath(this.sessionsDir, channelType, channelId, selfAID);
111
103
  }
112
104
  resolveChatDirFromSession(session) {
113
- const channelType = session.channelType || session.channel;
114
- return chatDirPath(this.sessionsDir, channelType, session.channelId, session.selfId);
105
+ if (!session.channelType) {
106
+ throw new Error(`[SessionManager] missing channelType for session ${session.id}`);
107
+ }
108
+ return chatDirPath(this.sessionsDir, session.channelType, session.channelId, session.selfAID);
115
109
  }
116
110
  /** Public accessor: get the chat directory path for a session (for message log etc.) */
117
111
  getChatDir(session) {
118
112
  return this.resolveChatDirFromSession(session);
119
113
  }
120
114
  /** Like resolveChatDir but also ensures the dir + _threads + _trash exist. */
121
- ensureResolvedChatDir(channel, channelId, channelType, selfId) {
122
- const dir = this.resolveChatDir(channel, channelId, channelType, selfId);
115
+ ensureResolvedChatDir(channel, channelId, channelType, selfAID) {
116
+ const dir = this.resolveChatDir(channel, channelId, channelType, selfAID);
123
117
  fs.mkdirSync(dir, { recursive: true });
124
118
  fs.mkdirSync(path.join(dir, '_threads'), { recursive: true });
125
119
  fs.mkdirSync(path.join(dir, '_trash'), { recursive: true });
126
120
  return dir;
127
121
  }
128
- /** 推断给定 chat 的 channelType(优先取 active.json)。无活跃时回落到 channel 实例名。 */
129
- inferChannelType(channel, channelId, chatDir) {
130
- if (chatDir) {
131
- const active = readJsonFile(path.join(chatDir, 'active.json'));
132
- if (active?.channelType)
133
- return active.channelType;
134
- }
135
- // 扫描已有目录
136
- const dirs = scanChatDirs(this.sessionsDir);
137
- for (const d of dirs) {
138
- if (d.channelId !== channelId)
139
- continue;
140
- const active = readJsonFile(path.join(d.dirPath, 'active.json'));
141
- if (active && active.channel === channel && active.channelType)
142
- return active.channelType;
143
- }
144
- throw new Error(`[SessionManager] Cannot infer channelType for channel="${channel}" channelId="${channelId}". No existing session found.`);
145
- }
146
- /** 从 active 推断 selfId(已有 session 的复用) */
147
- inferSelfId(channel, channelId, chatDir) {
148
- if (chatDir) {
149
- const active = readJsonFile(path.join(chatDir, 'active.json'));
150
- if (active?.selfId)
151
- return active.selfId;
152
- }
153
- // 扫描已有目录
154
- const dirs = scanChatDirs(this.sessionsDir);
155
- for (const d of dirs) {
156
- if (d.channelId !== channelId)
157
- continue;
158
- const active = readJsonFile(path.join(d.dirPath, 'active.json'));
159
- if (active && active.channel === channel)
160
- return active.selfId || undefined;
161
- }
162
- return undefined;
163
- }
164
122
  /**
165
123
  * 扫描已有 chat 目录,找到匹配 channel+channelId 的目录并返回其 chatDir 路径。
166
- * 用于不知道 channelType/selfId 的 caller 在调用 resolveChatDir 前定位已有目录。
124
+ * 用于不知道 channelType/selfAID 的 caller 在调用 resolveChatDir 前定位已有目录。
167
125
  */
168
126
  findExistingChatDir(channel, channelId) {
169
127
  const dirs = scanChatDirs(this.sessionsDir);
@@ -194,26 +152,26 @@ export class SessionManager {
194
152
  return undefined;
195
153
  }
196
154
  /**
197
- * 安全版 resolveChatDir:先尝试用提供的 channelType/selfId
155
+ * 安全版 resolveChatDir:先尝试用提供的 channelType/selfAID
198
156
  * 如果没有则扫描已有目录推断。用于操作已有 session 的公共方法。
199
157
  */
200
- resolveChatDirSafe(channel, channelId, channelType, selfId) {
201
- if (channelType) {
202
- return this.resolveChatDir(channel, channelId, channelType, selfId);
158
+ resolveChatDirSafe(channel, channelId, channelType, selfAID) {
159
+ if (channelType && (selfAID || channelType !== 'aun')) {
160
+ return this.resolveChatDir(channel, channelId, channelType, selfAID);
203
161
  }
204
162
  // 尝试从已有目录推断
205
163
  const existingDir = this.findExistingChatDir(channel, channelId);
206
164
  if (existingDir)
207
165
  return existingDir;
208
- throw new Error(`[SessionManager] Cannot resolve chat dir for channel="${channel}" channelId="${channelId}". No channelType provided and no existing session found.`);
166
+ throw new Error(`[SessionManager] Cannot resolve chat dir for channel="${channel}" channelId="${channelId}". No channelType/selfAID provided and no existing session found.`);
209
167
  }
210
168
  /**
211
- * 安全版 ensureResolvedChatDir:先尝试用提供的 channelType/selfId
169
+ * 安全版 ensureResolvedChatDir:先尝试用提供的 channelType/selfAID
212
170
  * 如果没有则扫描已有目录推断。确保目录存在。
213
171
  */
214
- ensureResolvedChatDirSafe(channel, channelId, channelType, selfId) {
215
- if (channelType) {
216
- return this.ensureResolvedChatDir(channel, channelId, channelType, selfId);
172
+ ensureResolvedChatDirSafe(channel, channelId, channelType, selfAID) {
173
+ if (channelType && (selfAID || channelType !== 'aun')) {
174
+ return this.ensureResolvedChatDir(channel, channelId, channelType, selfAID);
217
175
  }
218
176
  // 尝试从已有目录推断
219
177
  const existingDir = this.findExistingChatDir(channel, channelId);
@@ -224,18 +182,14 @@ export class SessionManager {
224
182
  fs.mkdirSync(path.join(existingDir, '_trash'), { recursive: true });
225
183
  return existingDir;
226
184
  }
227
- // 回退:推断 channelType selfId
228
- const inferredType = this.inferChannelType(channel, channelId);
229
- const inferredSelfId = this.inferSelfId(channel, channelId);
230
- return this.ensureResolvedChatDir(channel, channelId, inferredType, inferredSelfId);
185
+ throw new Error(`[SessionManager] Cannot resolve chat dir for channel="${channel}" channelId="${channelId}". No channelType/selfAID provided and no existing session found.`);
231
186
  }
232
- readActive(channel, channelId, channelType, selfId) {
187
+ readActive(channel, channelId, channelType, selfAID) {
233
188
  let dir;
234
- try {
235
- dir = this.resolveChatDir(channel, channelId, channelType, selfId);
189
+ if (channelType && selfAID) {
190
+ dir = this.resolveChatDir(channel, channelId, channelType, selfAID);
236
191
  }
237
- catch {
238
- // channelType not provided — try to find existing dir
192
+ else {
239
193
  const existingDir = this.findExistingChatDir(channel, channelId);
240
194
  if (!existingDir)
241
195
  return undefined;
@@ -251,12 +205,12 @@ export class SessionManager {
251
205
  const file = sessionToFile(session);
252
206
  atomicWriteJson(path.join(dir, 'active.json'), file);
253
207
  }
254
- clearActive(channel, channelId, channelType, selfId) {
208
+ clearActive(channel, channelId, channelType, selfAID) {
255
209
  let dir;
256
- try {
257
- dir = this.resolveChatDir(channel, channelId, channelType, selfId);
210
+ if (channelType && selfAID) {
211
+ dir = this.resolveChatDir(channel, channelId, channelType, selfAID);
258
212
  }
259
- catch {
213
+ else {
260
214
  const existingDir = this.findExistingChatDir(channel, channelId);
261
215
  if (!existingDir)
262
216
  return;
@@ -273,51 +227,72 @@ export class SessionManager {
273
227
  }
274
228
  ensureChatDirForSession(session) {
275
229
  const channelType = session.channelType || session.channel;
276
- return ensureChatDir(this.sessionsDir, channelType, session.channelId, session.selfId);
230
+ return ensureChatDir(this.sessionsDir, channelType, session.channelId, session.selfAID);
277
231
  }
278
232
  metaFilePath(chatDir, sessionId) {
279
233
  return path.join(chatDir, `${sessionId}.jsonl`);
280
234
  }
281
- appendMeta(channel, channelId, session) {
235
+ /** session 自身的 channelType/channelId/selfId/threadId 直接定位其 .jsonl 路径(不扫描目录)。 */
236
+ metaPathForSession(session) {
282
237
  const dir = this.ensureChatDirForSession(session);
283
- const isThread = !!session.threadId;
284
- const targetDir = isThread ? path.join(dir, '_threads') : dir;
285
- const file = sessionToFile(session);
286
- const metaPath = this.metaFilePath(targetDir, session.id);
287
- appendJsonl(metaPath, file);
238
+ const targetDir = session.threadId ? path.join(dir, '_threads') : dir;
239
+ return this.metaFilePath(targetDir, session.id);
288
240
  }
289
- /**
290
- * 比较两个 SessionFile 是否在内容上相等(忽略 updatedAt / updatedAtStr)。
291
- * 用于跳过"没真变化"的写入,避免 jsonl 写放大。
292
- */
293
- sessionFilesEqual(a, b) {
294
- const stripVolatile = ({ updatedAt, updatedAtStr, ...rest }) => rest;
295
- return JSON.stringify(stripVolatile(a)) === JSON.stringify(stripVolatile(b));
241
+ appendMeta(channel, channelId, session) {
242
+ const file = sessionToFile(session);
243
+ appendJsonl(this.metaPathForSession(session), file);
296
244
  }
297
245
  /**
298
- * Append meta + write active.json,但只在 session 内容(除 updatedAt 外)真正变化时才写。
299
- * prev 是修改前的快照(用于 diff),next 是修改后的 session
300
- * 返回是否发生了写入。
246
+ * Session 持久化的唯一事务原语。所有 session 写操作都应经此,业务层不直接调
247
+ * appendMeta / writeActive
248
+ *
249
+ * 契约:.jsonl 是权威源,active.json 是热缓存。
250
+ * 1. 内建 diff 去重:与该 session 的 .jsonl 末行比较(忽略 updatedAt),无变化则跳过
251
+ * 2. appendMeta(.jsonl) —— 始终先落历史档案(权威源)
252
+ * 3. 按 intent 决定 active.json 行为:
253
+ * - 'set' : 无条件让该 session 成为 active(创建/切换主会话)
254
+ * - 'sync' : 仅当该 session 已是当前 active 时同步缓存(更新自身字段)
255
+ * - 'none' : 不碰 active.json(thread 会话 / 后台 autonomous 会话)
256
+ *
257
+ * @param session 要持久化的 session(其 updatedAt 会被刷新)
258
+ * @param intent active.json 行为意图
259
+ * @param opts.forceWrite 强制写入一条新记录,跳过去重。用于 markProcessing/clearProcessing
260
+ * 这类"状态转换事件必须留痕"的场景。
261
+ * @returns 是否真的写入了 .jsonl(去重命中时返回 false)
301
262
  */
302
- writeSessionIfChanged(channel, channelId, prev, next) {
303
- if (prev) {
304
- const prevFile = sessionToFile(prev);
305
- const nextFile = sessionToFile(next);
306
- if (this.sessionFilesEqual(prevFile, nextFile))
263
+ persistSession(session, intent, opts) {
264
+ if (!opts?.forceWrite) {
265
+ const lastMeta = readLastJsonlLine(this.metaPathForSession(session));
266
+ if (lastMeta && this.sessionFilesEqual(lastMeta, sessionToFile(session))) {
267
+ // .jsonl 末行已与目标一致:仍可能需要把缓存对齐('set' 语义)
268
+ if (intent === 'set') {
269
+ session.updatedAt = Date.now();
270
+ this.writeActive(session.channel, session.channelId, session);
271
+ }
307
272
  return false;
273
+ }
308
274
  }
309
- next.updatedAt = Date.now();
310
- this.appendMeta(channel, channelId, next);
311
- const active = this.readActive(channel, channelId);
312
- if (active && active.id === next.id) {
313
- // 保留 active.json 中已有的 activeTask(markProcessing 写入的处理状态)
314
- if (active.processingState && !next.processingState) {
315
- next.processingState = active.processingState;
275
+ session.updatedAt = Date.now();
276
+ this.appendMeta(session.channel, session.channelId, session);
277
+ if (intent === 'set') {
278
+ this.writeActive(session.channel, session.channelId, session);
279
+ }
280
+ else if (intent === 'sync') {
281
+ const active = this.readActive(session.channel, session.channelId);
282
+ if (active && active.id === session.id) {
283
+ this.writeActive(session.channel, session.channelId, session);
316
284
  }
317
- this.writeActive(channel, channelId, next);
318
285
  }
319
286
  return true;
320
287
  }
288
+ /**
289
+ * 比较两个 SessionFile 是否在内容上相等(忽略 updatedAt / updatedAtStr)。
290
+ * 用于跳过"没真变化"的写入,避免 jsonl 写放大。
291
+ */
292
+ sessionFilesEqual(a, b) {
293
+ const stripVolatile = ({ updatedAt, updatedAtStr, ...rest }) => rest;
294
+ return JSON.stringify(stripVolatile(a)) === JSON.stringify(stripVolatile(b));
295
+ }
321
296
  readMetaLatest(metaFilePath) {
322
297
  const file = readLastJsonlLine(metaFilePath);
323
298
  if (!file)
@@ -335,9 +310,7 @@ export class SessionManager {
335
310
  * 2. 优先读 active.json(如果 active.id === sessionId)—— 当前状态
336
311
  * 3. 否则 fallback 到 .jsonl 末行 —— 非活跃 session 的更新(如多 session 并存时改非 active 那个)
337
312
  *
338
- * 返回 { current, prev }:
339
- * - current 用于 caller 修改后写回
340
- * - prev 是 current 的初始快照(用于 writeSessionIfChanged 的 diff 检查)
313
+ * 返回 { current }:caller 修改后交给 persistSession 写回(去重由 persistSession 内建)。
341
314
  */
342
315
  loadSessionForUpdate(sessionId) {
343
316
  const found = this.findSessionFileById(sessionId);
@@ -350,10 +323,8 @@ export class SessionManager {
350
323
  // 优先用 active.json 的当前状态(如果它就是这个 sessionId)
351
324
  const active = this.readActive(fromJsonl.channel, fromJsonl.channelId);
352
325
  const base = (active && active.id === sessionId) ? active : fromJsonl;
353
- // 深拷贝避免 caller 改 current 时污染 prev
354
326
  const current = JSON.parse(JSON.stringify(base));
355
- const prev = JSON.parse(JSON.stringify(base));
356
- return { current, prev };
327
+ return { current };
357
328
  }
358
329
  validateSessionFile(session) {
359
330
  const agentSessionId = session.agentSessionId;
@@ -366,9 +337,8 @@ export class SessionManager {
366
337
  if (adapter.checkExists(session.projectPath, agentSessionId))
367
338
  return agentSessionId;
368
339
  logger.warn(`Session file not found for ${agentId}: ${agentSessionId}, clearing session ID`);
369
- const prev = JSON.parse(JSON.stringify(session));
370
340
  session.agentSessionId = undefined;
371
- this.writeSessionIfChanged(session.channel, session.channelId, prev, session);
341
+ this.persistSession(session, 'sync');
372
342
  return undefined;
373
343
  }
374
344
  getActiveChatType(channel, channelId) {
@@ -433,17 +403,14 @@ export class SessionManager {
433
403
  markProcessing(sessionId, taskId) {
434
404
  const now = Date.now();
435
405
  const state = taskId ? `${now}:${taskId}` : String(now);
436
- const chatDirs = scanChatDirs(this.sessionsDir);
437
- for (const { dirPath } of chatDirs) {
438
- const active = readJsonFile(path.join(dirPath, 'active.json'));
439
- if (active && active.id === sessionId) {
440
- active.activeTask = state;
441
- active.updatedAt = now;
442
- active.updatedAtStr = formatTimestamp(now);
443
- atomicWriteJson(path.join(dirPath, 'active.json'), active);
444
- return;
445
- }
446
- }
406
+ const found = this.findSessionFileById(sessionId);
407
+ if (!found)
408
+ return;
409
+ const session = this.readMetaLatest(found.metaPath);
410
+ if (!session)
411
+ return;
412
+ session.processingState = state;
413
+ this.persistSession(session, 'sync', { forceWrite: true });
447
414
  }
448
415
  getActiveTaskId(sessionId) {
449
416
  const chatDirs = scanChatDirs(this.sessionsDir);
@@ -459,17 +426,20 @@ export class SessionManager {
459
426
  return undefined;
460
427
  }
461
428
  clearProcessing(sessionId) {
462
- const now = Date.now();
463
- const chatDirs = scanChatDirs(this.sessionsDir);
464
- for (const { dirPath } of chatDirs) {
465
- const active = readJsonFile(path.join(dirPath, 'active.json'));
466
- if (active && active.id === sessionId) {
467
- active.activeTask = null;
468
- active.updatedAt = now;
469
- active.updatedAtStr = formatTimestamp(now);
470
- atomicWriteJson(path.join(dirPath, 'active.json'), active);
471
- break;
472
- }
429
+ const found = this.findSessionFileById(sessionId);
430
+ if (!found) {
431
+ this.sessionEncryptState.delete(sessionId);
432
+ return;
433
+ }
434
+ const session = this.readMetaLatest(found.metaPath);
435
+ if (!session) {
436
+ this.sessionEncryptState.delete(sessionId);
437
+ return;
438
+ }
439
+ // 仅当 .jsonl 末行确实有 activeTask 时才写(避免写放大)
440
+ if (session.processingState) {
441
+ session.processingState = undefined;
442
+ this.persistSession(session, 'sync', { forceWrite: true });
473
443
  }
474
444
  this.sessionEncryptState.delete(sessionId);
475
445
  }
@@ -484,36 +454,41 @@ export class SessionManager {
484
454
  const result = [];
485
455
  const chatDirs = scanChatDirs(this.sessionsDir);
486
456
  for (const { dirPath } of chatDirs) {
487
- const active = readJsonFile(path.join(dirPath, 'active.json'));
488
- if (!active || !active.activeTask)
489
- continue;
490
- const colonIdx = active.activeTask.indexOf(':');
491
- const ts = parseInt(colonIdx > 0 ? active.activeTask.slice(0, colonIdx) : active.activeTask, 10);
492
- if (!isNaN(ts) && (now - ts) < maxAgeMs) {
493
- result.push(fileToSession(active));
494
- }
495
- else {
496
- active.activeTask = null;
497
- active.updatedAt = now;
498
- active.updatedAtStr = formatTimestamp(now);
499
- atomicWriteJson(path.join(dirPath, 'active.json'), active);
457
+ for (const metaFile of scanMetaFiles(dirPath)) {
458
+ const session = this.readMetaLatest(path.join(dirPath, metaFile));
459
+ if (!session?.processingState)
460
+ continue;
461
+ const colonIdx = session.processingState.indexOf(':');
462
+ const ts = parseInt(colonIdx > 0 ? session.processingState.slice(0, colonIdx) : session.processingState, 10);
463
+ if (!isNaN(ts) && (now - ts) < maxAgeMs) {
464
+ result.push(session);
465
+ }
466
+ else {
467
+ this.clearProcessing(session.id);
468
+ }
500
469
  }
501
470
  }
502
471
  return result;
503
472
  }
504
473
  // ─── Session lifecycle ───
505
- async getOrCreateSession(channel, channelId, defaultProjectPath, threadId, metadata, name, userId, chatType, agentId, selfId, channelType, peerType) {
474
+ async getOrCreateSession(channel, channelId, defaultProjectPath, threadId, metadata, name, userId, chatType, agentId, selfAID, channelType, peerType) {
475
+ if (!selfAID && channelType === 'aun') {
476
+ throw new Error(`[SessionManager] getOrCreateSession requires selfAID for aun channel. channelId="${channelId}"`);
477
+ }
478
+ if (!channelType) {
479
+ throw new Error(`[SessionManager] getOrCreateSession requires channelType. channel="${channel}" channelId="${channelId}"`);
480
+ }
506
481
  if (threadId) {
507
- const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfId, channelType, peerType);
482
+ const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfAID, channelType, peerType);
508
483
  session.identity = this.resolveIdentity(channel, userId);
509
484
  if (session.metadata && !session.metadata.permissionMode) {
510
485
  session.metadata.permissionMode = DEFAULT_PERMISSION_MODE;
511
- this.appendMeta(channel, channelId, session);
486
+ this.persistSession(session, 'none');
512
487
  }
513
488
  return session;
514
489
  }
515
490
  // 使用精确路径解析(caller 提供了 channelType 时直接定位,避免扫描回落)
516
- const exactDir = this.resolveChatDirExact(channel, channelId, channelType, selfId);
491
+ const exactDir = this.resolveChatDirExact(channel, channelId, channelType, selfAID);
517
492
  const activeFile = readJsonFile(path.join(exactDir, 'active.json'));
518
493
  const active = activeFile ? fileToSession(activeFile) : undefined;
519
494
  if (active && !active.threadId) {
@@ -526,8 +501,8 @@ export class SessionManager {
526
501
  session.chatType = chatType;
527
502
  mutated = true;
528
503
  }
529
- if (selfId && session.selfId !== selfId) {
530
- session.selfId = selfId;
504
+ if (session.selfAID !== selfAID) {
505
+ session.selfAID = selfAID;
531
506
  mutated = true;
532
507
  }
533
508
  if (chatType === 'private' && userId) {
@@ -541,41 +516,38 @@ export class SessionManager {
541
516
  session.metadata.peerName = metadata.peerName;
542
517
  mutated = true;
543
518
  }
544
- if (metadata?.channelName && session.metadata.channelName !== metadata.channelName) {
545
- session.metadata.channelName = metadata.channelName;
519
+ if (metadata?.channelKey && session.metadata.channelKey !== metadata.channelKey) {
520
+ session.metadata.channelKey = metadata.channelKey;
546
521
  mutated = true;
547
522
  }
548
523
  }
549
- if (metadata?.channelName && chatType !== 'private') {
524
+ if (metadata?.channelKey && chatType !== 'private') {
550
525
  if (!session.metadata)
551
526
  session.metadata = {};
552
- if (session.metadata.channelName !== metadata.channelName) {
553
- session.metadata.channelName = metadata.channelName;
527
+ if (session.metadata.channelKey !== metadata.channelKey) {
528
+ session.metadata.channelKey = metadata.channelKey;
554
529
  mutated = true;
555
530
  }
556
531
  }
557
532
  if (mutated) {
558
- session.updatedAt = Date.now();
559
- this.appendMeta(channel, channelId, session);
560
- this.writeActive(channel, channelId, session);
533
+ this.persistSession(session, 'sync');
561
534
  }
562
535
  return session;
563
536
  }
564
537
  // Find existing session for default project path
565
- const chatDir = this.resolveChatDir(channel, channelId, channelType, selfId);
538
+ const chatDir = this.resolveChatDir(channel, channelId, channelType, selfAID);
566
539
  const allSessions = this.findAllSessionsInChat(chatDir, false);
567
540
  const existing = allSessions
568
541
  .filter(s => s.projectPath === defaultProjectPath && !s.threadId)
569
542
  .sort((a, b) => b.updatedAt - a.updatedAt)[0];
570
543
  if (existing) {
571
544
  const validSessionId = this.validateSessionFile(existing);
572
- const prev = JSON.parse(JSON.stringify({ ...existing, agentSessionId: validSessionId }));
573
545
  const session = { ...existing, agentSessionId: validSessionId };
574
546
  session.identity = this.resolveIdentity(channel, userId);
575
547
  if (!session.metadata)
576
548
  session.metadata = {};
577
- if (selfId && session.selfId !== selfId) {
578
- session.selfId = selfId;
549
+ if (session.selfAID !== selfAID) {
550
+ session.selfAID = selfAID;
579
551
  }
580
552
  if (chatType && session.chatType !== chatType) {
581
553
  logger.info(`[SessionManager] Updating chatType for session ${session.id}: ${session.chatType} -> ${chatType}`);
@@ -587,7 +559,7 @@ export class SessionManager {
587
559
  if (chatType === 'private' && metadata?.peerName && !session.metadata.peerName) {
588
560
  session.metadata.peerName = metadata.peerName;
589
561
  }
590
- this.writeSessionIfChanged(channel, channelId, prev, session);
562
+ this.persistSession(session, 'sync');
591
563
  return session;
592
564
  }
593
565
  // Create new session
@@ -597,12 +569,13 @@ export class SessionManager {
597
569
  const session = {
598
570
  id: generateSessionId(),
599
571
  channel,
600
- channelType: channelType || channel,
572
+ channelType,
601
573
  channelId,
602
- selfId,
574
+ selfAID,
603
575
  projectPath: defaultProjectPath,
604
576
  threadId: '',
605
577
  agentId: agentId || 'claude',
578
+ sessionKey: formatSessionKey(channelType, channelId, DEFAULT_THREAD_ID),
606
579
  chatType: chatType || 'private',
607
580
  sessionMode: this.resolveDefaultSessionMode(channel, chatType || 'private', peerType),
608
581
  metadata: sessionMetadata,
@@ -611,8 +584,7 @@ export class SessionManager {
611
584
  updatedAt: Date.now(),
612
585
  };
613
586
  session.identity = this.resolveIdentity(channel, userId);
614
- this.appendMeta(channel, channelId, session);
615
- this.writeActive(channel, channelId, session);
587
+ this.persistSession(session, 'set');
616
588
  this.eventBus.publish({
617
589
  type: 'session:created',
618
590
  sessionId: session.id,
@@ -629,7 +601,7 @@ export class SessionManager {
629
601
  const loaded = this.loadSessionForUpdate(sessionId);
630
602
  if (!loaded)
631
603
  return;
632
- const { current, prev } = loaded;
604
+ const { current } = loaded;
633
605
  if (updates.chatType !== undefined)
634
606
  current.chatType = updates.chatType;
635
607
  if (updates.name !== undefined)
@@ -640,41 +612,43 @@ export class SessionManager {
640
612
  current.metadata = updates.metadata;
641
613
  if ('agentSessionId' in updates)
642
614
  current.agentSessionId = updates.agentSessionId ?? undefined;
643
- this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
615
+ this.persistSession(current, 'sync');
644
616
  }
645
- getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfId, channelType, peerType) {
646
- // 优先使用精确路径(channelType + selfId),避免 fallback 到错误目录
647
- const chatDir = (channelType && selfId)
648
- ? (() => { const d = chatDirPath(this.sessionsDir, channelType, channelId, selfId); fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(path.join(d, '_threads'), { recursive: true }); return d; })()
649
- : this.ensureResolvedChatDirSafe(channel, channelId, channelType);
617
+ getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfAID, channelType, peerType) {
618
+ // 使用精确路径(channelType + selfAID)
619
+ const chatDir = (channelType && selfAID)
620
+ ? (() => { const d = chatDirPath(this.sessionsDir, channelType, channelId, selfAID); fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(path.join(d, '_threads'), { recursive: true }); return d; })()
621
+ : this.ensureResolvedChatDirSafe(channel, channelId, channelType, selfAID);
650
622
  const threadIndex = readThreadIndex(chatDir);
651
- const existingMetaId = threadIndex[threadId];
652
- if (existingMetaId) {
653
- const metaPath = path.join(chatDir, '_threads', `${existingMetaId}.jsonl`);
623
+ const existingEntry = threadIndex[threadId];
624
+ if (existingEntry) {
625
+ const metaPath = path.join(chatDir, '_threads', `${existingEntry.sessionId}.jsonl`);
654
626
  const existing = this.readMetaLatest(metaPath);
655
627
  if (existing) {
656
628
  const validSessionId = this.validateSessionFile(existing);
657
629
  if (metadata) {
658
630
  existing.metadata = { ...(existing.metadata || {}), ...metadata };
659
- existing.updatedAt = Date.now();
660
- this.appendMeta(channel, channelId, existing);
631
+ this.persistSession(existing, 'none');
661
632
  }
662
633
  return { ...existing, agentSessionId: validSessionId };
663
634
  }
664
635
  }
665
636
  // Inherit project path & chatType from active main session
666
- const activeMain = this.readActive(channel, channelId, channelType, selfId);
637
+ const activeMain = this.readActive(channel, channelId, channelType, selfAID);
667
638
  const projectPath = (activeMain && !activeMain.threadId ? activeMain.projectPath : undefined) || defaultProjectPath;
668
639
  const inheritedChatType = (activeMain && !activeMain.threadId ? activeMain.chatType : undefined) || 'private';
640
+ const effectiveChannelType = channelType || channel;
641
+ const sessionKey = formatSessionKey(effectiveChannelType, channelId, threadId);
669
642
  const session = {
670
643
  id: generateSessionId(),
671
644
  channel,
672
- channelType: channelType || channel,
645
+ channelType: effectiveChannelType,
673
646
  channelId,
674
- selfId,
647
+ selfAID: selfAID || '',
675
648
  projectPath,
676
649
  threadId,
677
650
  agentId: agentId || 'claude',
651
+ sessionKey,
678
652
  chatType: inheritedChatType,
679
653
  sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType, peerType),
680
654
  metadata,
@@ -682,8 +656,12 @@ export class SessionManager {
682
656
  createdAt: Date.now(),
683
657
  updatedAt: Date.now(),
684
658
  };
685
- this.appendMeta(channel, channelId, session);
686
- threadIndex[threadId] = session.id;
659
+ this.persistSession(session, 'none');
660
+ threadIndex[threadId] = {
661
+ sessionId: session.id,
662
+ sessionKey,
663
+ metaFile: `${session.id}.jsonl`,
664
+ };
687
665
  writeThreadIndex(chatDir, threadIndex);
688
666
  this.eventBus.publish({
689
667
  type: 'session:created',
@@ -708,20 +686,23 @@ export class SessionManager {
708
686
  if (target) {
709
687
  const validSessionId = this.validateSessionFile(target);
710
688
  target.agentSessionId = validSessionId;
711
- target.updatedAt = Date.now();
712
- this.appendMeta(channel, channelId, target);
713
- this.writeActive(channel, channelId, target);
689
+ this.persistSession(target, 'set');
714
690
  return target;
715
691
  }
692
+ // Derive selfAID and channelType from existing sessions in this chatDir
693
+ const existingAny = allSessions[0];
694
+ const selfAID = existingAny?.selfAID || '';
695
+ const channelType = existingAny?.channelType || channel;
716
696
  const session = {
717
697
  id: generateSessionId(),
718
698
  channel,
719
- channelType: this.inferChannelType(channel, channelId),
699
+ channelType,
720
700
  channelId,
721
- selfId: this.inferSelfId(channel, channelId),
701
+ selfAID,
722
702
  projectPath: newProjectPath,
723
703
  threadId: '',
724
704
  agentId,
705
+ sessionKey: formatSessionKey(channelType, channelId, DEFAULT_THREAD_ID),
725
706
  chatType: inheritedChatType,
726
707
  sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
727
708
  metadata: {},
@@ -729,8 +710,7 @@ export class SessionManager {
729
710
  createdAt: Date.now(),
730
711
  updatedAt: Date.now(),
731
712
  };
732
- this.appendMeta(channel, channelId, session);
733
- this.writeActive(channel, channelId, session);
713
+ this.persistSession(session, 'set');
734
714
  this.eventBus.publish({
735
715
  type: 'session:created',
736
716
  sessionId: session.id,
@@ -746,24 +726,29 @@ export class SessionManager {
746
726
  const active = this.readActive(channel, channelId);
747
727
  if (!active)
748
728
  return;
749
- const prev = JSON.parse(JSON.stringify(active));
750
729
  active.agentSessionId = agentSessionId;
751
- this.writeSessionIfChanged(channel, channelId, prev, active);
730
+ this.persistSession(active, 'sync');
752
731
  }
753
732
  async updateAgentSessionIdBySessionId(sessionId, agentSessionId) {
754
733
  const loaded = this.loadSessionForUpdate(sessionId);
755
734
  if (!loaded)
756
735
  return;
757
- const { current, prev } = loaded;
736
+ const { current } = loaded;
758
737
  current.agentSessionId = agentSessionId;
759
- const wrote = this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
738
+ const wrote = this.persistSession(current, 'sync');
760
739
  if (wrote) {
761
740
  logger.info(`[SessionManager] Updating agent_session_id: sessionId=${sessionId}, agentSessionId=${agentSessionId}`);
762
741
  }
763
742
  }
764
743
  async switchAgent(channel, channelId, projectPath, newAgentId) {
765
744
  const inheritedChatType = this.getActiveChatType(channel, channelId);
766
- const chatDir = this.ensureResolvedChatDirSafe(channel, channelId);
745
+ // Derive channelType/selfAID from existing sessions; fall back to channel name
746
+ const probeChatDir = this.resolveChatDir(channel, channelId, channel, '');
747
+ const probeSessions = fs.existsSync(probeChatDir) ? this.findAllSessionsInChat(probeChatDir, false) : [];
748
+ const existingAny = probeSessions[0];
749
+ const channelType = existingAny?.channelType || channel;
750
+ const selfAID = existingAny?.selfAID || '';
751
+ const chatDir = this.ensureResolvedChatDir(channel, channelId, channelType, selfAID);
767
752
  const allSessions = this.findAllSessionsInChat(chatDir, false);
768
753
  const target = allSessions
769
754
  .filter(s => s.projectPath === projectPath && (s.agentId || 'claude') === newAgentId && !s.threadId)
@@ -771,20 +756,19 @@ export class SessionManager {
771
756
  if (target) {
772
757
  const validSessionId = this.validateSessionFile(target);
773
758
  target.agentSessionId = validSessionId;
774
- target.updatedAt = Date.now();
775
- this.appendMeta(channel, channelId, target);
776
- this.writeActive(channel, channelId, target);
759
+ this.persistSession(target, 'set');
777
760
  return target;
778
761
  }
779
762
  const session = {
780
763
  id: generateSessionId(),
781
764
  channel,
782
- channelType: this.inferChannelType(channel, channelId),
765
+ channelType,
783
766
  channelId,
784
- selfId: this.inferSelfId(channel, channelId),
767
+ selfAID,
785
768
  projectPath,
786
769
  threadId: '',
787
770
  agentId: newAgentId,
771
+ sessionKey: formatSessionKey(channelType, channelId, DEFAULT_THREAD_ID),
788
772
  chatType: inheritedChatType,
789
773
  sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
790
774
  metadata: {},
@@ -792,8 +776,7 @@ export class SessionManager {
792
776
  createdAt: Date.now(),
793
777
  updatedAt: Date.now(),
794
778
  };
795
- this.appendMeta(channel, channelId, session);
796
- this.writeActive(channel, channelId, session);
779
+ this.persistSession(session, 'set');
797
780
  this.eventBus.publish({
798
781
  type: 'session:created',
799
782
  sessionId: session.id,
@@ -809,9 +792,8 @@ export class SessionManager {
809
792
  const active = this.readActive(channel, channelId);
810
793
  if (!active)
811
794
  return;
812
- const prev = JSON.parse(JSON.stringify(active));
813
795
  active.agentSessionId = undefined;
814
- this.writeSessionIfChanged(channel, channelId, prev, active);
796
+ this.persistSession(active, 'sync');
815
797
  }
816
798
  getOwnerChatId(targetChannel, ownerPeerId) {
817
799
  const chatDirs = scanChatDirs(this.sessionsDir);
@@ -851,8 +833,8 @@ export class SessionManager {
851
833
  return this.readActive(channel, channelId);
852
834
  }
853
835
  /** 同步版 getActiveSession,支持精确路径定位(避免扫描) */
854
- getActiveSessionSync(channel, channelId, channelType, selfId) {
855
- return this.readActive(channel, channelId, channelType, selfId);
836
+ getActiveSessionSync(channel, channelId, channelType, selfAID) {
837
+ return this.readActive(channel, channelId, channelType, selfAID);
856
838
  }
857
839
  async getThreadSession(channel, channelId, threadId) {
858
840
  let chatDir;
@@ -863,10 +845,10 @@ export class SessionManager {
863
845
  return undefined;
864
846
  }
865
847
  const threadIndex = readThreadIndex(chatDir);
866
- const metaId = threadIndex[threadId];
867
- if (!metaId)
848
+ const entry = threadIndex[threadId];
849
+ if (!entry)
868
850
  return undefined;
869
- const metaPath = path.join(chatDir, '_threads', `${metaId}.jsonl`);
851
+ const metaPath = path.join(chatDir, '_threads', `${entry.sessionId}.jsonl`);
870
852
  const session = this.readMetaLatest(metaPath);
871
853
  if (!session)
872
854
  return undefined;
@@ -928,26 +910,24 @@ export class SessionManager {
928
910
  const target = sessions.find(s => s.id === targetSessionId);
929
911
  if (!target)
930
912
  return null;
931
- target.updatedAt = Date.now();
932
- this.appendMeta(channel, channelId, target);
933
- this.writeActive(channel, channelId, target);
913
+ this.persistSession(target, 'set');
934
914
  return target;
935
915
  }
936
916
  updateMetadata(sessionId, metadata) {
937
917
  const loaded = this.loadSessionForUpdate(sessionId);
938
918
  if (!loaded)
939
919
  return;
940
- const { current, prev } = loaded;
920
+ const { current } = loaded;
941
921
  current.metadata = metadata;
942
- this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
922
+ this.persistSession(current, 'sync');
943
923
  }
944
924
  async renameSession(sessionId, newName) {
945
925
  const loaded = this.loadSessionForUpdate(sessionId);
946
926
  if (!loaded)
947
927
  return false;
948
- const { current, prev } = loaded;
928
+ const { current } = loaded;
949
929
  current.name = newName;
950
- this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
930
+ this.persistSession(current, 'sync');
951
931
  return true;
952
932
  }
953
933
  async unbindSession(sessionId) {
@@ -967,8 +947,8 @@ export class SessionManager {
967
947
  // If thread session, remove from thread-index
968
948
  if (found.isThread) {
969
949
  const threadIndex = readThreadIndex(found.chatDir);
970
- for (const [tid, mid] of Object.entries(threadIndex)) {
971
- if (mid === sessionId) {
950
+ for (const [tid, entry] of Object.entries(threadIndex)) {
951
+ if (entry.sessionId === sessionId) {
972
952
  delete threadIndex[tid];
973
953
  break;
974
954
  }
@@ -1040,34 +1020,35 @@ export class SessionManager {
1040
1020
  }
1041
1021
  async createNewSession(channel, channelId, projectPath, name, agentId) {
1042
1022
  const inheritedChatType = this.getActiveChatType(channel, channelId);
1043
- let inferredType;
1044
- let inferredSelfId;
1045
- try {
1046
- inferredType = this.inferChannelType(channel, channelId);
1047
- inferredSelfId = this.inferSelfId(channel, channelId);
1048
- }
1049
- catch {
1050
- inferredType = channel;
1051
- inferredSelfId = undefined;
1023
+ // Derive selfAID and channelType from existing sessions
1024
+ const existingDir = this.findExistingChatDir(channel, channelId);
1025
+ let channelType = channel;
1026
+ let selfAID = '';
1027
+ if (existingDir) {
1028
+ const active = readJsonFile(path.join(existingDir, 'active.json'));
1029
+ if (active) {
1030
+ channelType = active.channelType || channel;
1031
+ selfAID = active.selfAID || '';
1032
+ }
1052
1033
  }
1053
1034
  const session = {
1054
1035
  id: generateSessionId(),
1055
1036
  channel,
1056
- channelType: inferredType,
1037
+ channelType,
1057
1038
  channelId,
1058
- selfId: inferredSelfId,
1039
+ selfAID,
1059
1040
  projectPath,
1060
1041
  threadId: '',
1061
1042
  agentId: agentId || 'claude',
1043
+ sessionKey: formatSessionKey(channelType, channelId, DEFAULT_THREAD_ID),
1062
1044
  chatType: inheritedChatType,
1063
1045
  sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
1064
- metadata: {},
1046
+ metadata: { permissionMode: DEFAULT_PERMISSION_MODE },
1065
1047
  name: name || '默认会话',
1066
1048
  createdAt: Date.now(),
1067
1049
  updatedAt: Date.now(),
1068
1050
  };
1069
- this.appendMeta(channel, channelId, session);
1070
- this.writeActive(channel, channelId, session);
1051
+ this.persistSession(session, 'set');
1071
1052
  this.eventBus.publish({
1072
1053
  type: 'session:created',
1073
1054
  sessionId: session.id,
@@ -1080,25 +1061,27 @@ export class SessionManager {
1080
1061
  return session;
1081
1062
  }
1082
1063
  async createForkedSession(sourceSession, forkedAgentSessionId, name) {
1064
+ const channelType = sourceSession.channelType || sourceSession.channel;
1065
+ const threadId = sourceSession.threadId || '';
1083
1066
  const session = {
1084
1067
  id: generateSessionId(),
1085
1068
  channel: sourceSession.channel,
1086
- channelType: sourceSession.channelType || sourceSession.channel,
1069
+ channelType,
1087
1070
  channelId: sourceSession.channelId,
1088
- selfId: sourceSession.selfId,
1071
+ selfAID: sourceSession.selfAID,
1089
1072
  projectPath: sourceSession.projectPath,
1090
- threadId: sourceSession.threadId || '',
1073
+ threadId,
1091
1074
  agentId: sourceSession.agentId || 'claude',
1075
+ sessionKey: formatSessionKey(channelType, sourceSession.channelId, threadId || DEFAULT_THREAD_ID),
1092
1076
  chatType: sourceSession.chatType || 'private',
1093
1077
  sessionMode: sourceSession.sessionMode || 'interactive',
1094
1078
  agentSessionId: forkedAgentSessionId,
1095
- metadata: {},
1079
+ metadata: { permissionMode: sourceSession.metadata?.permissionMode || DEFAULT_PERMISSION_MODE },
1096
1080
  name: name || `${sourceSession.name || '会话'}-分支`,
1097
1081
  createdAt: Date.now(),
1098
1082
  updatedAt: Date.now(),
1099
1083
  };
1100
- this.appendMeta(sourceSession.channel, sourceSession.channelId, session);
1101
- this.writeActive(sourceSession.channel, sourceSession.channelId, session);
1084
+ this.persistSession(session, 'set');
1102
1085
  this.eventBus.publish({
1103
1086
  type: 'session:created',
1104
1087
  sessionId: session.id,
@@ -1161,15 +1144,27 @@ export class SessionManager {
1161
1144
  const inheritedChatType = this.getActiveChatType(channel, channelId);
1162
1145
  const fileInfo = this.getSessionFileInfo(projectPath, agentSessionId, agentId);
1163
1146
  const name = fileInfo.title || `CLI会话-${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`;
1147
+ // Derive selfAID and channelType from existing sessions
1148
+ const existingDir = this.findExistingChatDir(channel, channelId);
1149
+ let channelType = channel;
1150
+ let selfAID = '';
1151
+ if (existingDir) {
1152
+ const active = readJsonFile(path.join(existingDir, 'active.json'));
1153
+ if (active) {
1154
+ channelType = active.channelType || channel;
1155
+ selfAID = active.selfAID || '';
1156
+ }
1157
+ }
1164
1158
  const session = {
1165
1159
  id: generateSessionId(),
1166
1160
  channel,
1167
- channelType: this.inferChannelType(channel, channelId),
1161
+ channelType,
1168
1162
  channelId,
1169
- selfId: this.inferSelfId(channel, channelId),
1163
+ selfAID,
1170
1164
  projectPath,
1171
1165
  threadId: '',
1172
1166
  agentId,
1167
+ sessionKey: formatSessionKey(channelType, channelId, DEFAULT_THREAD_ID),
1173
1168
  chatType: inheritedChatType,
1174
1169
  sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
1175
1170
  agentSessionId,
@@ -1178,8 +1173,7 @@ export class SessionManager {
1178
1173
  createdAt: Date.now(),
1179
1174
  updatedAt: Date.now(),
1180
1175
  };
1181
- this.appendMeta(channel, channelId, session);
1182
- this.writeActive(channel, channelId, session);
1176
+ this.persistSession(session, 'set');
1183
1177
  this.eventBus.publish({
1184
1178
  type: 'session:created',
1185
1179
  sessionId: session.id,
@@ -1300,4 +1294,67 @@ export class SessionManager {
1300
1294
  for (const adapter of this.fileAdapters.values())
1301
1295
  adapter.close?.();
1302
1296
  }
1297
+ /**
1298
+ * Migrate session channel key from old format "{selfPeerId}#{type}#{name}"
1299
+ * to new format "{type}#{selfPeerId}#{name}".
1300
+ * Runs once at startup, rewrites active.json and meta_*.jsonl in-place.
1301
+ */
1302
+ migrateChannelKeyFormat() {
1303
+ const chatDirs = scanChatDirs(this.sessionsDir);
1304
+ let migrated = 0;
1305
+ for (const { dirPath } of chatDirs) {
1306
+ const activePath = path.join(dirPath, 'active.json');
1307
+ const active = readJsonFile(activePath);
1308
+ if (!active?.channel)
1309
+ continue;
1310
+ const newKey = this.convertOldChannelKey(active.channel);
1311
+ if (!newKey)
1312
+ continue;
1313
+ // Rewrite active.json
1314
+ active.channel = newKey;
1315
+ atomicWriteJson(activePath, active);
1316
+ // Rewrite meta_*.jsonl files
1317
+ const metaFiles = scanMetaFiles(dirPath);
1318
+ for (const metaFile of metaFiles) {
1319
+ const metaPath = path.join(dirPath, metaFile);
1320
+ const lines = readAllJsonlLines(metaPath);
1321
+ if (!lines.length)
1322
+ continue;
1323
+ let changed = false;
1324
+ for (const line of lines) {
1325
+ if (line.channel && this.convertOldChannelKey(line.channel)) {
1326
+ line.channel = this.convertOldChannelKey(line.channel);
1327
+ changed = true;
1328
+ }
1329
+ }
1330
+ if (changed) {
1331
+ const content = lines.map(l => JSON.stringify(l)).join('\n') + '\n';
1332
+ fs.writeFileSync(metaPath, content, 'utf-8');
1333
+ }
1334
+ }
1335
+ migrated++;
1336
+ }
1337
+ if (migrated > 0) {
1338
+ logger.info(`[SessionManager] Migrated ${migrated} session(s) to new channelKey format`);
1339
+ }
1340
+ }
1341
+ /**
1342
+ * Detect old format "selfPeerId#type#name" and convert to "type#selfPeerId#name".
1343
+ * Returns null if already in new format or not a valid key.
1344
+ */
1345
+ convertOldChannelKey(key) {
1346
+ const parts = key.split('#');
1347
+ if (parts.length !== 3)
1348
+ return null;
1349
+ const [first, second, third] = parts;
1350
+ // New format: type#selfPeerId#name — type is short (aun, feishu, wechat, etc.)
1351
+ // Old format: selfPeerId#type#name — selfPeerId contains '.' (e.g., evolai.agentid.pub)
1352
+ const knownTypes = ['aun', 'feishu', 'wechat', 'dingtalk', 'qqbot', 'wecom'];
1353
+ if (knownTypes.includes(first))
1354
+ return null; // already new format
1355
+ if (knownTypes.includes(second) && first.includes('.')) {
1356
+ return `${second}#${first}#${third}`;
1357
+ }
1358
+ return null;
1359
+ }
1303
1360
  }