zen-gitsync 2.16.43 → 2.16.44

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 (98) hide show
  1. package/package.json +1 -1
  2. package/src/ui/public/assets/AgentView-BsN9y4Kg.css +1 -0
  3. package/src/ui/public/assets/AgentView-Cv9VaSKN.js +4 -0
  4. package/src/ui/public/assets/AppVersionBadge-BdqGOtBK.js +2 -0
  5. package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +1 -0
  6. package/src/ui/public/assets/BranchSelector-BXJDzZ3f.js +1 -0
  7. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +1 -0
  8. package/src/ui/public/assets/CommitForm-CUvl0VcF.css +1 -0
  9. package/src/ui/public/assets/CommitForm-DSddCPcu.js +6 -0
  10. package/src/ui/public/assets/EditorView-BgA-_8WV.css +1 -0
  11. package/src/ui/public/assets/EditorView-et7aqhjC.js +1 -0
  12. package/src/ui/public/assets/FlowExecutionViewer-AWaR_biA.js +1 -0
  13. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +1 -0
  14. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +1 -0
  15. package/src/ui/public/assets/FlowOrchestrationWorkspace-RC5DaY3c.js +21 -0
  16. package/src/ui/public/assets/LogList-puKBzBb8.js +7 -0
  17. package/src/ui/public/assets/LogList-r8sgFBhZ.css +1 -0
  18. package/src/ui/public/assets/MindmapView-BcLn10w2.js +1 -0
  19. package/src/ui/public/assets/MindmapView-CS2t30Ko.css +1 -0
  20. package/src/ui/public/assets/MonitorView-ChZk7T1l.js +1 -0
  21. package/src/ui/public/assets/MonitorView-DxFogQIt.css +1 -0
  22. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +1 -0
  23. package/src/ui/public/assets/ProjectStartupButton-BFXo6CRz.js +1 -0
  24. package/src/ui/public/assets/RemoteRepoCard-DyCD9GBp.js +1 -0
  25. package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +1 -0
  26. package/src/ui/public/assets/SourceMapView-DEgfJ4jb.js +3 -0
  27. package/src/ui/public/assets/SourceMapView-DG_a_076.css +1 -0
  28. package/src/ui/public/assets/UserInputNode-D5yO5I4U.js +1 -0
  29. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +1 -0
  30. package/src/ui/public/assets/WorkbenchView-B1yeYHWg.js +12 -0
  31. package/src/ui/public/assets/{WorkbenchView-Bp2YaSGS.css → WorkbenchView-CcYljxjT.css} +1 -1
  32. package/src/ui/public/assets/element-plus-CXfbWXaJ.css +1 -0
  33. package/src/ui/public/assets/element-plus-utMlO-GC.js +27 -0
  34. package/src/ui/public/assets/{flow-mindmap-Bi227PSm.js → flow-mindmap-CatiJi_w.js} +12 -12
  35. package/src/ui/public/assets/index-B15iIgdD.css +1 -0
  36. package/src/ui/public/assets/index-C2HMPXsP.js +62 -0
  37. package/src/ui/public/assets/{monaco-43aputPh.js → monaco-DPQ3OaiL.js} +2 -2
  38. package/src/ui/public/assets/office-docx-CUC3NjrQ.js +31 -0
  39. package/src/ui/public/assets/{office-excel-DxreApmb.js → office-excel-BGq5miA9.js} +2 -2
  40. package/src/ui/public/assets/office-pptx-CNefnIUm.js +60 -0
  41. package/src/ui/public/assets/socket-io-ObqgZMoP.js +1 -0
  42. package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
  43. package/src/ui/public/assets/{vendor-I3yQCqJK.js → vendor-C_cx2RSh.js} +39 -46
  44. package/src/ui/public/assets/vue-flow-CBbapQM_.js +39 -0
  45. package/src/ui/public/index.html +9 -16
  46. package/src/ui/server/index.js +1 -0
  47. package/src/ui/server/routes/fs.js +4 -0
  48. package/src/ui/server/routes/gitOps.js +20 -23
  49. package/src/ui/server/routes/gitRepoFlagSync.test.js +129 -0
  50. package/src/ui/server/routes/workbench/agentSessionStore.js +173 -173
  51. package/src/ui/server/utils/instanceRegistry.js +45 -7
  52. package/src/ui/server/utils/instanceRegistry.test.js +58 -0
  53. package/src/utils/index.js +14 -47
  54. package/src/ui/public/assets/AgentView-8-xsOmE6.css +0 -1
  55. package/src/ui/public/assets/AgentView-CVfH_WTL.js +0 -4
  56. package/src/ui/public/assets/AppVersionBadge-BKQZrA46.js +0 -2
  57. package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +0 -1
  58. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  59. package/src/ui/public/assets/BranchSelector-BrmzYPb2.js +0 -1
  60. package/src/ui/public/assets/CommitForm-CSAwdqxy.js +0 -6
  61. package/src/ui/public/assets/CommitForm-DdK8dymc.css +0 -1
  62. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +0 -1
  63. package/src/ui/public/assets/CommonDialog-Clc3mo5h.js +0 -1
  64. package/src/ui/public/assets/EditorView-B05M4l8X.js +0 -1
  65. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  66. package/src/ui/public/assets/FlowExecutionViewer-Du0JPzV8.js +0 -1
  67. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +0 -1
  68. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  69. package/src/ui/public/assets/FlowOrchestrationWorkspace-Kg7HaggD.js +0 -21
  70. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  71. package/src/ui/public/assets/LogList-DSl8gjqq.js +0 -7
  72. package/src/ui/public/assets/MindmapView-BQTqx842.css +0 -1
  73. package/src/ui/public/assets/MindmapView-KeS9uPEF.js +0 -2
  74. package/src/ui/public/assets/MonitorView-BYqPPmpL.js +0 -1
  75. package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +0 -1
  76. package/src/ui/public/assets/ProjectStartupButton-COEXIVyh.js +0 -1
  77. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  78. package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +0 -1
  79. package/src/ui/public/assets/RemoteRepoCard-Cqyksiy4.js +0 -5
  80. package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +0 -1
  81. package/src/ui/public/assets/SourceMapView-DhqMQTQD.js +0 -3
  82. package/src/ui/public/assets/SvgIcon-CDNsk4-Q.css +0 -1
  83. package/src/ui/public/assets/SvgIcon-DLr4V32f.js +0 -1
  84. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  85. package/src/ui/public/assets/UserInputNode-Do__vhj9.js +0 -1
  86. package/src/ui/public/assets/WorkbenchView-Dk5OlqZz.js +0 -28
  87. package/src/ui/public/assets/_plugin-vue_export-helper-CMAHJXhc.js +0 -17
  88. package/src/ui/public/assets/configStore-D666qKi0.js +0 -1
  89. package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
  90. package/src/ui/public/assets/element-plus-DXuL5yWp.js +0 -39
  91. package/src/ui/public/assets/index-BT90zGWp.js +0 -89
  92. package/src/ui/public/assets/index-RKGE_YSG.css +0 -1
  93. package/src/ui/public/assets/office-docx-ZOPof2Jf.js +0 -32
  94. package/src/ui/public/assets/office-pptx-CR0bf9zY.js +0 -74
  95. package/src/ui/public/assets/socket-io-BZIzo1US.js +0 -1
  96. package/src/ui/public/assets/toolsStore-DsvD8S_8.js +0 -1
  97. package/src/ui/public/assets/vue-flow-DV5FYL9P.js +0 -39
  98. /package/src/ui/public/assets/{dagre-97WoLjFt.js → dagre-BCXBo6I9.js} +0 -0
@@ -97,7 +97,11 @@ export function registerFsRoutes({
97
97
  // 检查当前目录是否是Git仓库
98
98
  try {
99
99
  await execGitCommand(['rev-parse', '--is-inside-work-tree']);
100
+ // 用实时检测结果回写服务端 isGitRepo 标志,自愈启动后目录状态
101
+ // 发生变化(如外部终端里 git init)导致的 stale false。
102
+ setIsGitRepo(true);
100
103
  } catch (error) {
104
+ setIsGitRepo(false);
101
105
  return res.status(400).json({
102
106
  error: '当前目录不是一个Git仓库',
103
107
  directory,
@@ -42,6 +42,7 @@ export function registerGitOpsRoutes({
42
42
  clearCommandHistory,
43
43
  checkAndClearGitLock,
44
44
  getIsGitRepo,
45
+ setIsGitRepo,
45
46
  setRecentPushStatus
46
47
  }) {
47
48
  // 提交更改
@@ -948,9 +949,8 @@ export function registerGitOpsRoutes({
948
949
  // 拼成 origin/<branch> 后进 git 参数位,同样要过 ref 校验
949
950
  const safeBranch = assertGitRef(branch, '分支名');
950
951
 
951
- // 尝试清理 Git 锁文件(破坏性入口:force=true 绕过 mtime 判断,
952
- // 仍走 PID liveness 校验——用户主动触发的 reset --hard 不该被
953
- // 一个刚崩出来的新孤儿锁挡住)
952
+ // 仅清理能确认持有进程已退出的孤儿锁。原生 Git 的空锁无法确认
953
+ // 持有者状态时必须保留,让 Git 自己返回并发锁错误。
954
954
  await checkAndClearGitLock({ force: true });
955
955
 
956
956
  // 执行 git reset --hard origin/branch 命令
@@ -968,9 +968,8 @@ export function registerGitOpsRoutes({
968
968
  // 清除本地所有更改,包括未跟踪文件 (git reset --hard && git clean -fd)
969
969
  app.post('/api/discard-all-changes', async (req, res) => {
970
970
  try {
971
- // 尝试清理 Git 锁文件(破坏性入口:force=true 绕过 mtime 判断,
972
- // 仍走 PID liveness 校验——用户主动触发的 discard-all 不该被
973
- // 一个刚崩出来的新孤儿锁挡住)
971
+ // 仅清理能确认持有进程已退出的孤儿锁。原生 Git 的空锁无法确认
972
+ // 持有者状态时必须保留,让 Git 自己返回并发锁错误。
974
973
  await checkAndClearGitLock({ force: true });
975
974
 
976
975
  // 1. 执行 git reset --hard 丢弃已跟踪文件的更改
@@ -1286,23 +1285,15 @@ export function registerGitOpsRoutes({
1286
1285
  // 添加清理Git锁定文件的接口
1287
1286
  app.post('/api/remove-lock', async (req, res) => {
1288
1287
  try {
1289
- const gitDir = path.join(process.cwd(), '.git')
1290
- const indexLockFile = path.join(gitDir, 'index.lock')
1291
-
1292
- // 检查文件是否存在
1293
- try {
1294
- await fs.access(indexLockFile)
1295
- // 如果文件存在,尝试删除它
1296
- await fs.unlink(indexLockFile)
1297
- res.json({ success: true, message: '已清理锁定文件' })
1298
- } catch (error) {
1299
- // 如果文件不存在,也返回成功
1300
- if (error.code === 'ENOENT') {
1301
- res.json({ success: true, message: '没有发现锁定文件' })
1302
- } else {
1303
- throw error
1304
- }
1305
- }
1288
+ // 统一走带并发保护的锁检查。原生 Git 的 index.lock 通常为空,
1289
+ // 无法确认是否仍被其他工具使用时绝不能直接 unlink。
1290
+ const cleaned = await checkAndClearGitLock({ force: false });
1291
+ res.json({
1292
+ success: cleaned,
1293
+ message: cleaned
1294
+ ? '已清理确认失效的锁定文件'
1295
+ : '未自动删除锁定文件:无法确认没有其他 Git 操作正在运行'
1296
+ });
1306
1297
  } catch (error) {
1307
1298
  logger.error('清理锁定文件失败:', error)
1308
1299
  res.status(500).json({
@@ -1371,6 +1362,12 @@ export function registerGitOpsRoutes({
1371
1362
  app.post('/api/git-init', async (req, res) => {
1372
1363
  try {
1373
1364
  const { stdout } = await execGitCommand(['init']);
1365
+ // git init 成功后立即同步服务端 isGitRepo 标志。
1366
+ // 否则该标志仍是启动时检测的 false,后续 /api/add-remote 等以
1367
+ // getIsGitRepo() 做门禁的接口会误报"当前目录不是Git仓库"。
1368
+ if (typeof setIsGitRepo === 'function') {
1369
+ setIsGitRepo(true);
1370
+ }
1374
1371
  res.json({ success: true, output: stdout.trim() });
1375
1372
  } catch (error) {
1376
1373
  logger.error('git init 失败:', error);
@@ -0,0 +1,129 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // src/ui/server/routes/gitOps.js isGitRepo 标志同步回归测试。
16
+ //
17
+ // 背景 bug:服务端 isGitRepo 是启动时一次性检测的变量。用户在非仓库目录
18
+ // 点"初始化Git仓库"(或"初始化并添加远程")后,/api/git-init 真实创建了 .git,
19
+ // 但标志没有同步,/api/add-remote 用 stale 的 getIsGitRepo() 做门禁,
20
+ // 必然报"当前目录不是Git仓库"。
21
+ //
22
+ // 修复:git-init 成功后 setIsGitRepo(true)。
23
+ // 本文件验证:
24
+ // 1. git-init 成功 → setIsGitRepo(true) 被调用
25
+ // 2. 同一实例内 init 后 add-remote 不再被误拦(完整回归场景)
26
+ // 3. 未 init 的目录 add-remote 仍被正确拦截(门禁本身不回退)
27
+ // 4. add-remote 成功路径正确执行 git remote add
28
+ //
29
+ // 测试不真起 Express,直接调 registerGitOpsRoutes 注入 mock app,
30
+ // 触发最后一个注册的 handler(跳过 express.json() 等中间件)。
31
+ import { test } from 'node:test'
32
+ import assert from 'node:assert/strict'
33
+ import { registerGitOpsRoutes } from './gitOps.js'
34
+
35
+ /** 最小 express app mock:post(path, ...fns) 只保留最后一个 handler */
36
+ function makeApp() {
37
+ const handlers = new Map() // key = "METHOD path" → handler
38
+ return {
39
+ get(p, ...fns) { handlers.set(`GET ${p}`, fns[fns.length - 1]) },
40
+ post(p, ...fns) { handlers.set(`POST ${p}`, fns[fns.length - 1]) },
41
+ invoke(method, p, req = { body: {} }, res = makeRes()) {
42
+ const key = `${method} ${p}`
43
+ const handler = handlers.get(key)
44
+ assert.ok(handler, `no handler for ${key}`)
45
+ return handler(req, res, () => {})
46
+ }
47
+ }
48
+ }
49
+ function makeRes() {
50
+ return {
51
+ statusCode: 200,
52
+ payload: null,
53
+ status(c) { this.statusCode = c; return this },
54
+ json(p) { this.payload = p; return this }
55
+ }
56
+ }
57
+
58
+ /** 组装依赖:isGitRepo 是可变闭包变量,模拟服务端标志 */
59
+ function setup({ initialRepo = false } = {}) {
60
+ const app = makeApp()
61
+ let isGitRepo = initialRepo
62
+ const gitCalls = []
63
+ registerGitOpsRoutes({
64
+ app,
65
+ execGitCommand: async (args) => {
66
+ gitCalls.push(args)
67
+ if (args[0] === 'init') isGitRepo = true // git init 真实生效
68
+ return { stdout: '' }
69
+ },
70
+ configManager: { getLockedFiles: async () => [] },
71
+ execGitAddWithLockFilter: async () => ({}),
72
+ addCommandToHistory: () => {},
73
+ clearCommandHistory: () => {},
74
+ checkAndClearGitLock: async () => true,
75
+ getIsGitRepo: () => isGitRepo,
76
+ setIsGitRepo: (v) => { isGitRepo = v },
77
+ setRecentPushStatus: () => {}
78
+ })
79
+ return { app, gitCalls, getFlag: () => isGitRepo }
80
+ }
81
+
82
+ // ========== 回归:初始化后立即添加远程 ==========
83
+
84
+ test('gitRepoFlagSync: git-init 成功后同步 setIsGitRepo(true)', async () => {
85
+ const { app, getFlag } = setup({ initialRepo: false })
86
+ const res = makeRes()
87
+ await app.invoke('POST', '/api/git-init', { body: {} }, res)
88
+ assert.equal(res.payload.success, true)
89
+ assert.equal(getFlag(), true, 'git-init 成功后服务端标志应立即翻为 true')
90
+ })
91
+
92
+ test('gitRepoFlagSync: 初始化后立即添加远程不再报"当前目录不是Git仓库"', async () => {
93
+ const { app, gitCalls } = setup({ initialRepo: false })
94
+ // 第一步:初始化(模拟"初始化并添加远程"按钮的第一步)
95
+ const initRes = makeRes()
96
+ await app.invoke('POST', '/api/git-init', { body: {} }, initRes)
97
+ assert.equal(initRes.payload.success, true)
98
+ // 第二步:添加远程(修复前此处被 stale 标志拦下)
99
+ const addRes = makeRes()
100
+ await app.invoke('POST', '/api/add-remote', {
101
+ body: { name: 'origin', url: 'git@gitee.com:flowdash/home2026.git' }
102
+ }, addRes)
103
+ assert.equal(addRes.payload.success, true,
104
+ `add-remote 应成功,实际: ${JSON.stringify(addRes.payload)}`)
105
+ assert.deepEqual(gitCalls[gitCalls.length - 1],
106
+ ['remote', 'add', 'origin', 'git@gitee.com:flowdash/home2026.git'])
107
+ })
108
+
109
+ test('gitRepoFlagSync: 未初始化时 add-remote 仍被门禁拦截', async () => {
110
+ const { app, gitCalls } = setup({ initialRepo: false })
111
+ const res = makeRes()
112
+ await app.invoke('POST', '/api/add-remote', {
113
+ body: { name: 'origin', url: 'git@gitee.com:flowdash/home2026.git' }
114
+ }, res)
115
+ assert.equal(res.payload.success, false)
116
+ assert.equal(res.payload.error, '当前目录不是Git仓库')
117
+ assert.equal(gitCalls.length, 0, '非 git 仓库不应执行任何 git 子命令')
118
+ })
119
+
120
+ test('gitRepoFlagSync: add-remote 正常路径执行 git remote add', async () => {
121
+ const { app, gitCalls } = setup({ initialRepo: true })
122
+ const res = makeRes()
123
+ await app.invoke('POST', '/api/add-remote', {
124
+ body: { name: 'origin', url: 'https://gitee.com/x/y.git' }
125
+ }, res)
126
+ assert.equal(res.payload.success, true)
127
+ assert.deepEqual(gitCalls[gitCalls.length - 1],
128
+ ['remote', 'add', 'origin', 'https://gitee.com/x/y.git'])
129
+ })
@@ -1,173 +1,173 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- // 智能体对话 session 持久化层。
16
- //
17
- // 与 ai-split-sessions 不同,这里存储的是完整 agent 对话(含工具调用)。
18
- // 消息格式兼容 OpenAI chat completions:
19
- // - { role: 'system', content: string }
20
- // - { role: 'user', content: string | content_parts[] }
21
- // - { role: 'assistant', content: string|null, tool_calls?: [{id,type,function:{name,arguments}}] }
22
- // - { role: 'tool', tool_call_id, name, content: string }
23
- //
24
- // 文件命名: {sessionId}.json,原子写(tmp + rename)。
25
- // 数据目录: ~/.zen-gitsync/agent-sessions/
26
-
27
- import fsp from 'fs/promises';
28
- import path from 'path';
29
- import { logger } from './shared.js';
30
-
31
- // 数据目录:与 ai-split-sessions 平级
32
- export const AGENT_SESSIONS_DIR = path.join(
33
- path.dirname(path.dirname(path.dirname(path.dirname(new URL(import.meta.url).pathname.replace(/^\//, ''))))),
34
- '.zen-gitsync',
35
- 'agent-sessions'
36
- );
37
- // 上面路径在 Windows 下不可靠,改用 os.homedir
38
- import os from 'os';
39
- export const AGENT_SESSIONS_DIR_FIXED = path.join(os.homedir(), '.zen-gitsync', 'agent-sessions');
40
-
41
- export const MAX_AGENT_SESSIONS = 200;
42
- export const AGENT_SESSIONS_KEEP = 100;
43
-
44
- // 16 字符: 时间戳后 8 位 + 随机 8 位 base36
45
- export function genAgentSessionId() {
46
- return `ag-${Date.now().toString(36).slice(-8)}-${Math.random().toString(36).slice(2, 10)}`;
47
- }
48
-
49
- // 从第一条 user 消息自动生成标题
50
- export function autoTitle(messages) {
51
- for (const m of messages) {
52
- if (m.role === 'user') {
53
- let text = '';
54
- if (typeof m.content === 'string') {
55
- text = m.content;
56
- } else if (Array.isArray(m.content)) {
57
- text = m.content.filter(p => p?.type === 'text').map(p => p.text).join(' ');
58
- }
59
- text = text.trim();
60
- if (text) {
61
- // 取第一行,截断到 40 字符
62
- const firstLine = text.split('\n')[0].trim();
63
- return firstLine.length > 40 ? firstLine.slice(0, 40) + '…' : firstLine;
64
- }
65
- }
66
- }
67
- return '(新会话)';
68
- }
69
-
70
- async function readAgentSessionFile(sessionId) {
71
- if (!/^ag-[a-z0-9-]{4,32}$/i.test(sessionId)) {
72
- const err = new Error('非法 sessionId'); err.statusCode = 400; throw err;
73
- }
74
- const file = path.join(AGENT_SESSIONS_DIR_FIXED, `${sessionId}.json`);
75
- try {
76
- return JSON.parse(await fsp.readFile(file, 'utf-8'));
77
- } catch (err) {
78
- if (err.code === 'ENOENT') {
79
- const e = new Error('会话不存在'); e.statusCode = 404; throw e;
80
- }
81
- throw err;
82
- }
83
- }
84
-
85
- async function writeAgentSessionFile(sessionId, data) {
86
- await fsp.mkdir(AGENT_SESSIONS_DIR_FIXED, { recursive: true });
87
- const file = path.join(AGENT_SESSIONS_DIR_FIXED, `${sessionId}.json`);
88
- const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
89
- await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
90
- await fsp.rename(tmp, file);
91
- }
92
-
93
- async function deleteAgentSessionFile(sessionId) {
94
- if (!/^ag-[a-z0-9-]{4,32}$/i.test(sessionId)) {
95
- const err = new Error('非法 sessionId'); err.statusCode = 400; throw err;
96
- }
97
- const file = path.join(AGENT_SESSIONS_DIR_FIXED, `${sessionId}.json`);
98
- try {
99
- await fsp.unlink(file);
100
- } catch (err) {
101
- if (err.code === 'ENOENT') {
102
- const e = new Error('会话不存在'); e.statusCode = 404; throw e;
103
- }
104
- throw err;
105
- }
106
- }
107
-
108
- // 列表只读 metadata
109
- async function listAgentSessionsMeta() {
110
- await fsp.mkdir(AGENT_SESSIONS_DIR_FIXED, { recursive: true });
111
- const files = await fsp.readdir(AGENT_SESSIONS_DIR_FIXED);
112
- const list = [];
113
- for (const f of files) {
114
- if (!f.endsWith('.json')) continue;
115
- const sid = f.slice(0, -5);
116
- try {
117
- const file = path.join(AGENT_SESSIONS_DIR_FIXED, f);
118
- const stat = await fsp.stat(file);
119
- const data = JSON.parse(await fsp.readFile(file, 'utf-8'));
120
- list.push({
121
- sessionId: data.sessionId || sid,
122
- title: data.title || '(无标题)',
123
- source: data.source || 'web', // 'web' | 'cli'
124
- cwd: data.cwd || '',
125
- model: data.model || '',
126
- createdAt: data.createdAt || '',
127
- updatedAt: data.updatedAt || '',
128
- messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
129
- size: stat.size
130
- });
131
- } catch { /* 跳过坏文件 */ }
132
- }
133
- list.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
134
- return list;
135
- }
136
-
137
- async function enforceAgentSessionsRetention() {
138
- const files = await fsp.readdir(AGENT_SESSIONS_DIR_FIXED).catch(() => []);
139
- const jsonFiles = files.filter(f => f.endsWith('.json'));
140
- if (jsonFiles.length < MAX_AGENT_SESSIONS) return;
141
- const stats = await Promise.all(jsonFiles.map(async f => {
142
- const full = path.join(AGENT_SESSIONS_DIR_FIXED, f);
143
- const s = await fsp.stat(full);
144
- return { file: full, mtime: s.mtimeMs };
145
- }));
146
- stats.sort((a, b) => b.mtime - a.mtime);
147
- const toDelete = stats.slice(AGENT_SESSIONS_KEEP);
148
- await Promise.all(toDelete.map(s => fsp.unlink(s.file).catch(() => {})));
149
- if (toDelete.length > 0) {
150
- logger.info(`[agent] sessions: cleaned up ${toDelete.length} old files`);
151
- }
152
- }
153
-
154
- // 更新会话标题
155
- async function renameAgentSession(sessionId, title) {
156
- const data = await readAgentSessionFile(sessionId);
157
- data.title = String(title || '').trim() || '(无标题)';
158
- data.updatedAt = new Date().toISOString();
159
- await writeAgentSessionFile(sessionId, data);
160
- return data;
161
- }
162
-
163
- export const agentSessionStore = {
164
- genSessionId: genAgentSessionId,
165
- autoTitle,
166
- read: readAgentSessionFile,
167
- write: writeAgentSessionFile,
168
- delete: deleteAgentSessionFile,
169
- listMeta: listAgentSessionsMeta,
170
- enforceRetention: enforceAgentSessionsRetention,
171
- rename: renameAgentSession,
172
- DIR: AGENT_SESSIONS_DIR_FIXED,
173
- };
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 智能体对话 session 持久化层。
16
+ //
17
+ // 与 ai-split-sessions 不同,这里存储的是完整 agent 对话(含工具调用)。
18
+ // 消息格式兼容 OpenAI chat completions:
19
+ // - { role: 'system', content: string }
20
+ // - { role: 'user', content: string | content_parts[] }
21
+ // - { role: 'assistant', content: string|null, tool_calls?: [{id,type,function:{name,arguments}}] }
22
+ // - { role: 'tool', tool_call_id, name, content: string }
23
+ //
24
+ // 文件命名: {sessionId}.json,原子写(tmp + rename)。
25
+ // 数据目录: ~/.zen-gitsync/agent-sessions/
26
+
27
+ import fsp from 'fs/promises';
28
+ import path from 'path';
29
+ import { logger } from './shared.js';
30
+
31
+ // 数据目录:与 ai-split-sessions 平级
32
+ export const AGENT_SESSIONS_DIR = path.join(
33
+ path.dirname(path.dirname(path.dirname(path.dirname(new URL(import.meta.url).pathname.replace(/^\//, ''))))),
34
+ '.zen-gitsync',
35
+ 'agent-sessions'
36
+ );
37
+ // 上面路径在 Windows 下不可靠,改用 os.homedir
38
+ import os from 'os';
39
+ export const AGENT_SESSIONS_DIR_FIXED = path.join(os.homedir(), '.zen-gitsync', 'agent-sessions');
40
+
41
+ export const MAX_AGENT_SESSIONS = 200;
42
+ export const AGENT_SESSIONS_KEEP = 100;
43
+
44
+ // 16 字符: 时间戳后 8 位 + 随机 8 位 base36
45
+ export function genAgentSessionId() {
46
+ return `ag-${Date.now().toString(36).slice(-8)}-${Math.random().toString(36).slice(2, 10)}`;
47
+ }
48
+
49
+ // 从第一条 user 消息自动生成标题
50
+ export function autoTitle(messages) {
51
+ for (const m of messages) {
52
+ if (m.role === 'user') {
53
+ let text = '';
54
+ if (typeof m.content === 'string') {
55
+ text = m.content;
56
+ } else if (Array.isArray(m.content)) {
57
+ text = m.content.filter(p => p?.type === 'text').map(p => p.text).join(' ');
58
+ }
59
+ text = text.trim();
60
+ if (text) {
61
+ // 取第一行,截断到 40 字符
62
+ const firstLine = text.split('\n')[0].trim();
63
+ return firstLine.length > 40 ? firstLine.slice(0, 40) + '…' : firstLine;
64
+ }
65
+ }
66
+ }
67
+ return '(新会话)';
68
+ }
69
+
70
+ async function readAgentSessionFile(sessionId) {
71
+ if (!/^ag-[a-z0-9-]{4,32}$/i.test(sessionId)) {
72
+ const err = new Error('非法 sessionId'); err.statusCode = 400; throw err;
73
+ }
74
+ const file = path.join(AGENT_SESSIONS_DIR_FIXED, `${sessionId}.json`);
75
+ try {
76
+ return JSON.parse(await fsp.readFile(file, 'utf-8'));
77
+ } catch (err) {
78
+ if (err.code === 'ENOENT') {
79
+ const e = new Error('会话不存在'); e.statusCode = 404; throw e;
80
+ }
81
+ throw err;
82
+ }
83
+ }
84
+
85
+ async function writeAgentSessionFile(sessionId, data) {
86
+ await fsp.mkdir(AGENT_SESSIONS_DIR_FIXED, { recursive: true });
87
+ const file = path.join(AGENT_SESSIONS_DIR_FIXED, `${sessionId}.json`);
88
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
89
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
90
+ await fsp.rename(tmp, file);
91
+ }
92
+
93
+ async function deleteAgentSessionFile(sessionId) {
94
+ if (!/^ag-[a-z0-9-]{4,32}$/i.test(sessionId)) {
95
+ const err = new Error('非法 sessionId'); err.statusCode = 400; throw err;
96
+ }
97
+ const file = path.join(AGENT_SESSIONS_DIR_FIXED, `${sessionId}.json`);
98
+ try {
99
+ await fsp.unlink(file);
100
+ } catch (err) {
101
+ if (err.code === 'ENOENT') {
102
+ const e = new Error('会话不存在'); e.statusCode = 404; throw e;
103
+ }
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ // 列表只读 metadata
109
+ async function listAgentSessionsMeta() {
110
+ await fsp.mkdir(AGENT_SESSIONS_DIR_FIXED, { recursive: true });
111
+ const files = await fsp.readdir(AGENT_SESSIONS_DIR_FIXED);
112
+ const list = [];
113
+ for (const f of files) {
114
+ if (!f.endsWith('.json')) continue;
115
+ const sid = f.slice(0, -5);
116
+ try {
117
+ const file = path.join(AGENT_SESSIONS_DIR_FIXED, f);
118
+ const stat = await fsp.stat(file);
119
+ const data = JSON.parse(await fsp.readFile(file, 'utf-8'));
120
+ list.push({
121
+ sessionId: data.sessionId || sid,
122
+ title: data.title || '(无标题)',
123
+ source: data.source || 'web', // 'web' | 'cli'
124
+ cwd: data.cwd || '',
125
+ model: data.model || '',
126
+ createdAt: data.createdAt || '',
127
+ updatedAt: data.updatedAt || '',
128
+ messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
129
+ size: stat.size
130
+ });
131
+ } catch { /* 跳过坏文件 */ }
132
+ }
133
+ list.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
134
+ return list;
135
+ }
136
+
137
+ async function enforceAgentSessionsRetention() {
138
+ const files = await fsp.readdir(AGENT_SESSIONS_DIR_FIXED).catch(() => []);
139
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
140
+ if (jsonFiles.length < MAX_AGENT_SESSIONS) return;
141
+ const stats = await Promise.all(jsonFiles.map(async f => {
142
+ const full = path.join(AGENT_SESSIONS_DIR_FIXED, f);
143
+ const s = await fsp.stat(full);
144
+ return { file: full, mtime: s.mtimeMs };
145
+ }));
146
+ stats.sort((a, b) => b.mtime - a.mtime);
147
+ const toDelete = stats.slice(AGENT_SESSIONS_KEEP);
148
+ await Promise.all(toDelete.map(s => fsp.unlink(s.file).catch(() => {})));
149
+ if (toDelete.length > 0) {
150
+ logger.info(`[agent] sessions: cleaned up ${toDelete.length} old files`);
151
+ }
152
+ }
153
+
154
+ // 更新会话标题
155
+ async function renameAgentSession(sessionId, title) {
156
+ const data = await readAgentSessionFile(sessionId);
157
+ data.title = String(title || '').trim() || '(无标题)';
158
+ data.updatedAt = new Date().toISOString();
159
+ await writeAgentSessionFile(sessionId, data);
160
+ return data;
161
+ }
162
+
163
+ export const agentSessionStore = {
164
+ genSessionId: genAgentSessionId,
165
+ autoTitle,
166
+ read: readAgentSessionFile,
167
+ write: writeAgentSessionFile,
168
+ delete: deleteAgentSessionFile,
169
+ listMeta: listAgentSessionsMeta,
170
+ enforceRetention: enforceAgentSessionsRetention,
171
+ rename: renameAgentSession,
172
+ DIR: AGENT_SESSIONS_DIR_FIXED,
173
+ };
@@ -139,19 +139,46 @@ export function createInstanceRegistry({
139
139
  }
140
140
  }
141
141
 
142
- // 读单个 entry 文件。失败/不存在时返回 null。
142
+ // 读单个 entry 文件。返回值三种:
143
+ // { entry } → 解析成功且为对象
144
+ // { corrupt: true }→ 文件存在但内容损坏(非合法 JSON / 非对象)
145
+ // null → 文件不存在或读取失败(IO 错误,不动文件)
146
+ //
147
+ // 损坏来源(Windows 实测):进程被强杀 / 断电时文件长度已扩展但数据未落盘,
148
+ // 留下全 \x00 的文件。这类文件永远解析失败,若不清理会永久堆积,
149
+ // 且每次 list()(监控轮询约 5 秒一次)都打一条 warn 刷屏。
143
150
  async function readEntry(pid) {
144
151
  const fp = entryFilePath(pid);
152
+ let raw;
145
153
  try {
146
- const raw = await fsMod.readFile(fp, 'utf-8');
147
- const parsed = JSON.parse(raw);
148
- if (parsed && typeof parsed === 'object') return parsed;
149
- return null;
154
+ raw = await fsMod.readFile(fp, 'utf-8');
150
155
  } catch (err) {
151
156
  if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
152
157
  logger.warn(`[instanceRegistry] 读 entry pid=${pid} 失败: ${err?.message || err}`);
153
158
  return null;
154
159
  }
160
+ try {
161
+ const parsed = JSON.parse(raw);
162
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
163
+ return { entry: parsed };
164
+ }
165
+ return { corrupt: true };
166
+ } catch (err) {
167
+ logger.warn(`[instanceRegistry] entry pid=${pid} 内容损坏(非合法 JSON),将被清理: ${err?.message || err}`);
168
+ return { corrupt: true };
169
+ }
170
+ }
171
+
172
+ // 尽力删除损坏的 entry 文件(失败不抛,只留 warn,下轮再试)
173
+ async function removeCorruptEntry(pid) {
174
+ try {
175
+ await fsMod.unlink(entryFilePath(pid));
176
+ logger.info(`[instanceRegistry] 已清理损坏的 entry 文件 pid=${pid}`);
177
+ } catch (err) {
178
+ if (err && err.code !== 'ENOENT') {
179
+ logger.warn(`[instanceRegistry] 清理损坏 entry pid=${pid} 失败: ${err?.message || err}`);
180
+ }
181
+ }
155
182
  }
156
183
 
157
184
  // 原子写单个 entry 文件:tmp + rename
@@ -189,7 +216,13 @@ export function createInstanceRegistry({
189
216
  for (const name of names) {
190
217
  if (!isEntryFileName(name)) continue;
191
218
  const pid = pidFromFileName(name);
192
- const entry = await readEntry(pid);
219
+ const res = await readEntry(pid);
220
+ if (res && res.corrupt) {
221
+ // 损坏文件直接删掉,自愈,避免永久堆积 + 每 5 秒刷 warn
222
+ await removeCorruptEntry(pid);
223
+ continue;
224
+ }
225
+ const entry = res?.entry;
193
226
  if (entry && Number.isInteger(entry.pid)) {
194
227
  instances[String(entry.pid)] = entry;
195
228
  }
@@ -320,7 +353,12 @@ export function createInstanceRegistry({
320
353
  await ensureDir();
321
354
  await migrateLegacy();
322
355
 
323
- const existing = await readEntry(pid);
356
+ const res = await readEntry(pid);
357
+ if (res && res.corrupt) {
358
+ // 损坏文件先清掉,按"条目丢失"走自愈重建
359
+ await removeCorruptEntry(pid);
360
+ }
361
+ const existing = res?.entry || null;
324
362
  let entry;
325
363
  if (!existing) {
326
364
  const canRebuild =