cc-viewer 1.6.293 → 1.6.295

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 (110) hide show
  1. package/cli.js +7 -2
  2. package/dist/assets/App-Br-u2TKk.js +2 -0
  3. package/dist/assets/App-eFrjLzF_.css +1 -0
  4. package/dist/assets/{MdxEditorPanel-Cf01KF6Z.js → MdxEditorPanel-Cy4egsQx.js} +1 -1
  5. package/dist/assets/{Mobile-BJlGkvAP.js → Mobile-ZHF74GQs.js} +1 -1
  6. package/dist/assets/{_baseUniq-CPUnJ5bQ.js → _baseUniq-r3p3rodd.js} +1 -1
  7. package/dist/assets/{arc-WhuJ-oY5.js → arc-CjTV5gxc.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-CWx77Yhd.js → architectureDiagram-Q4EWVU46-BqzjXpCq.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-D7AQLCoj.js → blockDiagram-DXYQGD6D-CLyFfeHh.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-BoPHNqCF.js → c4Diagram-AHTNJAMY-BaO-0tuc.js} +1 -1
  11. package/dist/assets/{channel-B9Ja6Xkc.js → channel-yOyhvOLV.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-B-b0RYab.js → chunk-4BX2VUAB-CMTnvZkS.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-BK_V34yf.js → chunk-4TB4RGXK-QI41m9WP.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-D-kMbu-2.js → chunk-55IACEB6-C4ZO8bM3.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-CEtSkZzd.js → chunk-EDXVE4YY-Bo8P4o65.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-BXa_7Pn3.js → chunk-FMBD7UC4-CTHLGcHh.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-tvM_OApS.js → chunk-OYMX7WX6-D0OHxKGd.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-DrEmcVHf.js → chunk-QZHKN3VN-CoYnjUpS.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-D2M9T_R5.js → chunk-YZCP3GAM-BY71mTXM.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-C9o5ip5q.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-C9o5ip5q.js +1 -0
  22. package/dist/assets/clone-GDqN3kwT.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-H7bkwu5F.js → cose-bilkent-S5V4N54A-DUNsA_MT.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-DKXEGN18.js → dagre-KV5264BT-BzlT2Exr.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DZFhwpI3.js → diagram-5BDNPKRD-CiqQK3Ci.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-Crg9GlIk.js → diagram-G4DWMVQ6-BciK18tQ.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-B8Qn1fKP.js → diagram-MMDJMWI5-C1WH1vfU.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-BHE1LjtY.js → diagram-TYMM5635-CR5RzJ6u.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-BaEqFWLd.js → erDiagram-SMLLAGMA-NJQKXu51.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-b2ukTawV.js → flowDiagram-DWJPFMVM-Cjx5t_1H.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-D5quyFgK.js → ganttDiagram-T4ZO3ILL-YFTDBBiU.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BE1H5_fN.js → gitGraphDiagram-UUTBAWPF-C2muKahz.js} +1 -1
  33. package/dist/assets/{graph-D_JLoOax.js → graph-I1olozIg.js} +1 -1
  34. package/dist/assets/{index-Cx8bk0Tp.js → index-7vxIrUNA.js} +1 -1
  35. package/dist/assets/{index-BDUs32pN.css → index-Be9T-kDq.css} +1 -1
  36. package/dist/assets/{index-CtrY6gFZ.js → index-C1RNAzAB.js} +1 -1
  37. package/dist/assets/{index-CQrdpZQb.js → index-Cf4FBg-V.js} +1 -1
  38. package/dist/assets/{index-B8UmlA4F.js → index-D-HPuqxB.js} +1 -1
  39. package/dist/assets/{index-k0AH8cvI.js → index-D2QUxu18.js} +1 -1
  40. package/dist/assets/index-DMuCrfTo.js +2 -0
  41. package/dist/assets/{index-DiZ9CErG.js → index-DhzoJ5wE.js} +1 -1
  42. package/dist/assets/{index-CWjqMDrs.js → index-fhI0i2p3.js} +1 -1
  43. package/dist/assets/{infoDiagram-42DDH7IO-DQKlrVkw.js → infoDiagram-42DDH7IO-C9bza97c.js} +1 -1
  44. package/dist/assets/{ishikawaDiagram-UXIWVN3A-BchFlpPc.js → ishikawaDiagram-UXIWVN3A-BtZGipfW.js} +1 -1
  45. package/dist/assets/{journeyDiagram-VCZTEJTY-Dg1mt4df.js → journeyDiagram-VCZTEJTY-CKTp590c.js} +1 -1
  46. package/dist/assets/{jszip.min-LIb2SFoK.js → jszip.min-DDU-_oA-.js} +1 -1
  47. package/dist/assets/{kanban-definition-6JOO6SKY-226va2PS.js → kanban-definition-6JOO6SKY-BHLNWfr5.js} +1 -1
  48. package/dist/assets/{layout-rSa8rcPi.js → layout-DBmqcl9N.js} +1 -1
  49. package/dist/assets/{linear-BeARi8nH.js → linear-Br9n7mCI.js} +1 -1
  50. package/dist/assets/{mermaid.core-CDgdx9l7.js → mermaid.core-BV3ugHFm.js} +2 -2
  51. package/dist/assets/{min-B9yebCuj.js → min-D-YA3MGY.js} +1 -1
  52. package/dist/assets/{mindmap-definition-QFDTVHPH-C3apVbdg.js → mindmap-definition-QFDTVHPH-CzrYj3cB.js} +1 -1
  53. package/dist/assets/{pieDiagram-DEJITSTG-xjOQoQeL.js → pieDiagram-DEJITSTG-BAvtfiT3.js} +1 -1
  54. package/dist/assets/{quadrantDiagram-34T5L4WZ-Dq8x_VN2.js → quadrantDiagram-34T5L4WZ-i4zhnBJq.js} +1 -1
  55. package/dist/assets/{requirementDiagram-MS252O5E-CLmO1Gai.js → requirementDiagram-MS252O5E-Cb2wX9Sk.js} +1 -1
  56. package/dist/assets/{sankeyDiagram-XADWPNL6-BuUP1Eqq.js → sankeyDiagram-XADWPNL6-CcpbP6z5.js} +1 -1
  57. package/dist/assets/seqResourceLoaders-C7X23dCJ.js +2 -0
  58. package/dist/assets/{seqResourceLoaders-DWKAvGtj.css → seqResourceLoaders-De_-fYhE.css} +2 -2
  59. package/dist/assets/{sequenceDiagram-FGHM5R23-B18koU20.js → sequenceDiagram-FGHM5R23-BcbUxMmI.js} +1 -1
  60. package/dist/assets/{stateDiagram-FHFEXIEX-Cj57OCcO.js → stateDiagram-FHFEXIEX-CpIa1qoO.js} +1 -1
  61. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-C01a2p--.js → stateDiagram-v2-QKLJ7IA2-d3GoyW9S.js} +1 -1
  62. package/dist/assets/{timeline-definition-GMOUNBTQ-cOlsEN_F.js → timeline-definition-GMOUNBTQ-BfQPSOuT.js} +1 -1
  63. package/dist/assets/{vendor-antd-DqFS7Zj9.js → vendor-antd-Bur5ZxWE.js} +1 -1
  64. package/dist/assets/{vendor-codemirror-B_pF4DrA.js → vendor-codemirror-Si44UqBp.js} +1 -1
  65. package/dist/assets/{vendor-mdxeditor-B_IrHcWH.js → vendor-mdxeditor-Cco3AQJS.js} +2 -2
  66. package/dist/assets/{vendor-qrcode-C4PneAS5.js → vendor-qrcode-Dn3GYC4l.js} +1 -1
  67. package/dist/assets/{vendor-virtuoso-CEGeJyDP.js → vendor-virtuoso-CW9EqKMt.js} +1 -1
  68. package/dist/assets/{vennDiagram-DHZGUBPP-BCjdwiDk.js → vennDiagram-DHZGUBPP-hTgiYDQL.js} +1 -1
  69. package/dist/assets/{wardley-RL74JXVD-CRmLlBwn.js → wardley-RL74JXVD-ByDpAPp1.js} +1 -1
  70. package/dist/assets/{wardleyDiagram-NUSXRM2D-BJYVDJ4F.js → wardleyDiagram-NUSXRM2D-D7LJTuWq.js} +1 -1
  71. package/dist/assets/{xychartDiagram-5P7HB3ND-el5C4S1Z.js → xychartDiagram-5P7HB3ND-MW_KOomO.js} +1 -1
  72. package/dist/index.html +5 -5
  73. package/findcc.js +3 -3
  74. package/package.json +1 -1
  75. package/server/i18n.js +224 -8
  76. package/server/interceptor.js +21 -18
  77. package/server/lib/adapters/dingtalk-adapter.js +69 -0
  78. package/server/lib/adapters/discord-adapter.js +44 -1
  79. package/server/lib/adapters/feishu-adapter.js +56 -0
  80. package/server/lib/adapters/wecom-adapter.js +4 -0
  81. package/server/lib/ask-store.js +19 -90
  82. package/server/lib/async-file-lock.js +123 -0
  83. package/server/lib/async-write-queue.js +131 -0
  84. package/server/lib/git-diff.js +4 -1
  85. package/server/lib/im-bridge-core.js +178 -21
  86. package/server/lib/im-claude-md.js +37 -1
  87. package/server/lib/im-config.js +11 -6
  88. package/server/lib/im-process-manager.js +1 -1
  89. package/server/lib/im-senders.js +73 -0
  90. package/server/lib/jsonl-archive.js +0 -1
  91. package/server/lib/log-watcher.js +224 -177
  92. package/server/lib/plugin-manager.js +1 -1
  93. package/server/lib/updater.js +4 -2
  94. package/server/pty-manager.js +1 -1
  95. package/server/routes/ask-perm.js +2 -2
  96. package/server/routes/dingtalk.js +2 -0
  97. package/server/routes/files-fs.js +4 -4
  98. package/server/routes/im.js +117 -3
  99. package/server/routes/project-meta.js +18 -1
  100. package/server/routes/skills.js +180 -165
  101. package/server/routes/workspaces.js +7 -10
  102. package/server/server.js +23 -20
  103. package/server/workspace-registry.js +9 -53
  104. package/dist/assets/App-DRvRd96X.css +0 -1
  105. package/dist/assets/App-OM2oqZRW.js +0 -1
  106. package/dist/assets/classDiagram-6PBFFD2Q-CCwGJXEA.js +0 -1
  107. package/dist/assets/classDiagram-v2-HSJHXN6E-CCwGJXEA.js +0 -1
  108. package/dist/assets/clone-BuQbTPQO.js +0 -1
  109. package/dist/assets/index-CnWSVlWW.js +0 -2
  110. package/dist/assets/seqResourceLoaders-BZ6M3Jb-.js +0 -2
@@ -99,6 +99,25 @@ const feishuAdapter = {
99
99
  // Feishu long-connection has no app-level inbound ACK (the SDK handles transport acking).
100
100
  ack() { /* no-op */ },
101
101
 
102
+ // 解析发送者姓名 + 头像(供「对话记录」展示)。事件只带 open_id,姓名/头像需查通讯录:
103
+ // 复用已建好的 sendClient(自带 tenant_access_token 缓存)调 contact.v3.user.get。
104
+ // 需应用具备「读取通讯录」相关 scope;无权限/外部用户/失败 → null,由 bridge 静默降级。
105
+ async resolveSender(cfg, senderId, ctx) {
106
+ if (!senderId) return null;
107
+ const client = ctx.store.sendClient;
108
+ if (!client?.contact?.v3?.user?.get) return null;
109
+ try {
110
+ const r = await client.contact.v3.user.get({
111
+ path: { user_id: senderId },
112
+ params: { user_id_type: 'open_id' },
113
+ });
114
+ if (r && typeof r.code === 'number' && r.code !== 0) return null;
115
+ const u = r?.data?.user || {};
116
+ const avatar = u.avatar?.avatar_240 || u.avatar?.avatar_72 || u.avatar?.avatar_origin || null;
117
+ return { name: u.name || null, avatar };
118
+ } catch { return null; }
119
+ },
120
+
102
121
  async sendOne(cfg, target, content, ctx) {
103
122
  const client = ctx.store.sendClient;
104
123
  if (!client) throw new Error('feishu send client not initialized');
@@ -111,6 +130,43 @@ const feishuAdapter = {
111
130
  }
112
131
  },
113
132
 
133
+ async sendAckCard(cfg, target, statusText, ctx) {
134
+ const client = ctx.store.sendClient;
135
+ if (!client) throw new Error('feishu send client not initialized');
136
+ const card = {
137
+ config: { wide_screen_mode: true },
138
+ header: { title: { tag: 'plain_text', content: 'Claude' }, template: 'blue' },
139
+ elements: [{ tag: 'div', text: { tag: 'lark_md', content: statusText } }],
140
+ };
141
+ const r = await client.im.v1.message.create({
142
+ params: { receive_id_type: target.receiveIdType },
143
+ data: { receive_id: target.receiveId, msg_type: 'interactive', content: JSON.stringify(card) },
144
+ });
145
+ if (r && typeof r.code === 'number' && r.code !== 0) {
146
+ throw new Error(`sendAckCard ${r.code}: ${r.msg || 'failed'}`);
147
+ }
148
+ return { messageId: r?.data?.message_id };
149
+ },
150
+
151
+ async updateAckCard(cfg, target, handle, content, status, ctx) {
152
+ try {
153
+ const client = ctx.store.sendClient;
154
+ if (!client) return false;
155
+ const templateMap = { done: 'green', interrupted: 'orange', error: 'red' };
156
+ const card = {
157
+ config: { wide_screen_mode: true },
158
+ header: { title: { tag: 'plain_text', content: 'Claude' }, template: templateMap[status] || 'blue' },
159
+ elements: [{ tag: 'div', text: { tag: 'lark_md', content } }],
160
+ };
161
+ const r = await client.im.v1.message.patch({
162
+ path: { message_id: handle.messageId },
163
+ data: { content: JSON.stringify(card) },
164
+ });
165
+ if (r && typeof r.code === 'number' && r.code !== 0) return false;
166
+ return true;
167
+ } catch { return false; }
168
+ },
169
+
114
170
  async testConnection(cfg, ctx) {
115
171
  try {
116
172
  const r = await ctx.fetch(tokenHost(cfg.region) + TOKEN_PATH, {
@@ -61,6 +61,10 @@ const wecomAdapter = {
61
61
  hasCreds(cfg) { return !!(cfg.botId && cfg.secret); },
62
62
  statusFields(cfg) { return { botIdTail: cfg?.botId?.slice(-4) || '' }; },
63
63
 
64
+ // 发送者姓名/头像:v1 不实现 resolveSender —— 智能机器人长连接凭证(botId+secret)拿不到
65
+ // 企业通讯录的 corp_access_token,`/cgi-bin/user/get` 不可达。故 WeCom 发送者在「对话记录」里
66
+ // 降级为默认头像 + senderId(不报错、不阻断)。后续如引入管理员级凭证再补 resolveSender。
67
+
64
68
  async connect(cfg, hooks, ctx) {
65
69
  const mod = await loadSdk();
66
70
  const WSClient = resolveWSClient(mod);
@@ -9,10 +9,11 @@
9
9
  // - 启动时 hydrate 出的 entry 的 res 字段为 null(旧连接已死),
10
10
  // 等待新的 ask-bridge 重新 POST 同 toolUseId 时复用(已在 server.js:2727 实现)
11
11
  // 或浏览器通过 /api/pending-asks 拉取展示。
12
- import { readFileSync, writeFileSync, writeSync, existsSync, mkdirSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs';
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { randomBytes } from 'node:crypto';
15
15
  import { renameSyncWithRetry } from './file-api.js';
16
+ import { withFileLockAsync } from './async-file-lock.js';
16
17
  import { LOG_DIR } from '../../findcc.js';
17
18
 
18
19
  const SCHEMA_VERSION = 1;
@@ -24,80 +25,8 @@ let _loggedPersistError = false;
24
25
  function getStoreFile() { return join(LOG_DIR, 'ask-store.json'); }
25
26
  function getLockFile() { return join(LOG_DIR, 'ask-store.lock'); }
26
27
 
27
- function sleep(ms) {
28
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
29
- }
30
-
31
- // Lock body 含 owner pid,让其它进程精确判断 owner 是否仍活着 —— 避免 mtime-only stale
32
- // 判定在 Electron 多 Tab + 慢盘场景下误删活跃锁(持锁方 fn 跑 >5s 时旧实现会被偷锁)。
33
- // Body 不可读(老格式 / openSync 与 writeSync 之间的瞬间 race)时退回 mtime 阈值兜底。
34
- function readLockOwnerPid(path) {
35
- try {
36
- const raw = readFileSync(path, 'utf-8');
37
- if (!raw) return null;
38
- const obj = JSON.parse(raw);
39
- if (obj && Number.isInteger(obj.pid)) return obj.pid;
40
- } catch {}
41
- return null;
42
- }
43
-
44
- function isPidAlive(pid) {
45
- if (!Number.isInteger(pid) || pid <= 0) return false;
46
- try {
47
- process.kill(pid, 0);
48
- return true;
49
- } catch (err) {
50
- // ESRCH = 进程不存在;EPERM = 存在但不同 user(仍算活)
51
- return err && err.code === 'EPERM';
52
- }
53
- }
54
-
55
- function isLockStale(path, mtimeFallbackMs) {
56
- const pid = readLockOwnerPid(path);
57
- if (pid !== null) {
58
- // 自己 pid 见到 = 必然 stale(理论不可能:try/finally 保证 unlink;
59
- // 若发生 → 视作可回收,避免 2 秒死锁)
60
- if (pid === process.pid) return true;
61
- return !isPidAlive(pid);
62
- }
63
- try {
64
- const stats = statSync(path);
65
- return Date.now() - stats.mtimeMs > mtimeFallbackMs;
66
- } catch {
67
- return false;
68
- }
69
- }
70
-
71
28
  function withLock(fn) {
72
- mkdirSync(LOG_DIR, { recursive: true });
73
- const deadline = Date.now() + 2000;
74
- const STALE_THRESHOLD = 5000;
75
- while (true) {
76
- try {
77
- const fd = openSync(getLockFile(), 'wx');
78
- try {
79
- writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
80
- } finally {
81
- closeSync(fd);
82
- }
83
- break;
84
- } catch (err) {
85
- if (err?.code === 'EEXIST') {
86
- if (Date.now() < deadline) {
87
- if (isLockStale(getLockFile(), STALE_THRESHOLD)) {
88
- try { unlinkSync(getLockFile()); } catch {}
89
- continue;
90
- }
91
- sleep(25);
92
- continue;
93
- }
94
- }
95
- throw err;
96
- }
97
- }
98
- try { return fn(); } finally {
99
- try { unlinkSync(getLockFile()); } catch {}
100
- }
29
+ return withFileLockAsync(getLockFile(), fn, { ensureDir: LOG_DIR });
101
30
  }
102
31
 
103
32
  /**
@@ -187,11 +116,11 @@ export function saveAskStore(entries) {
187
116
  * last-write-wins 覆盖错乱(A 选 X 落盘 → B 选 Y 又落 → ask-bridge 最终拿 Y 而 A UI 显 X)。
188
117
  * Returns true if this call wrote, false if noop'd by existing terminal state.
189
118
  */
190
- export function markAnswered(id, answers) {
119
+ export async function markAnswered(id, answers) {
191
120
  if (!id || typeof id !== 'string') return false;
192
121
  if (!answers || typeof answers !== 'object') return false;
193
122
  try {
194
- return withLock(() => {
123
+ return await withLock(() => {
195
124
  const all = loadAskStore();
196
125
  const existing = all[id];
197
126
  if (existing && (existing.status === 'answered' || existing.status === 'cancelled')) {
@@ -211,10 +140,10 @@ export function markAnswered(id, answers) {
211
140
  } catch { return false; }
212
141
  }
213
142
 
214
- export function markCancelled(id, reason) {
143
+ export async function markCancelled(id, reason) {
215
144
  if (!id || typeof id !== 'string') return false;
216
145
  try {
217
- return withLock(() => {
146
+ return await withLock(() => {
218
147
  const all = loadAskStore();
219
148
  const existing = all[id];
220
149
  if (existing && (existing.status === 'answered' || existing.status === 'cancelled')) {
@@ -242,10 +171,10 @@ export function markCancelled(id, reason) {
242
171
  * 替代旧的 "无条件 consume + setEntry 写回" 双 withLock 模式 —— 那种模式的两次锁
243
172
  * 中间窗口会被 markAnswered 命中 → setEntry 把答案覆盖回 pending。
244
173
  */
245
- export function consumeIfFinal(id) {
174
+ export async function consumeIfFinal(id) {
246
175
  if (!id || typeof id !== 'string') return null;
247
176
  try {
248
- return withLock(() => {
177
+ return await withLock(() => {
249
178
  const all = loadAskStore();
250
179
  const entry = all[id];
251
180
  if (!entry) return null;
@@ -265,10 +194,10 @@ export function consumeIfFinal(id) {
265
194
  * truly want "read + delete regardless of status"). New short-poll GET handler
266
195
  * uses consumeIfFinal instead — see server.js GET /api/ask-hook/:id/result.
267
196
  */
268
- export function consume(id) {
197
+ export async function consume(id) {
269
198
  if (!id || typeof id !== 'string') return null;
270
199
  try {
271
- return withLock(() => {
200
+ return await withLock(() => {
272
201
  const all = loadAskStore();
273
202
  const entry = all[id];
274
203
  if (!entry) return null;
@@ -289,11 +218,11 @@ export function consume(id) {
289
218
  * setEntry 不能把 status 倒回 pending —— 否则 setImmediate 排队的延迟 _persistAskEntry
290
219
  * 或重 POST 的 placeholder 会覆盖真实终态,导致 ask-bridge 短轮询永远拿不到答案。
291
220
  */
292
- export function setEntry(id, fields) {
221
+ export async function setEntry(id, fields) {
293
222
  if (!id || typeof id !== 'string') return;
294
223
  if (!fields || !Array.isArray(fields.questions)) return;
295
224
  try {
296
- withLock(() => {
225
+ await withLock(() => {
297
226
  const all = loadAskStore();
298
227
  const existing = all[id];
299
228
  if (existing && (existing.status === 'answered' || existing.status === 'cancelled')) {
@@ -312,10 +241,10 @@ export function setEntry(id, fields) {
312
241
  }
313
242
  }
314
243
 
315
- export function deleteEntry(id) {
244
+ export async function deleteEntry(id) {
316
245
  if (!id || typeof id !== 'string') return;
317
246
  try {
318
- withLock(() => {
247
+ await withLock(() => {
319
248
  const all = loadAskStore();
320
249
  if (!(id in all)) return;
321
250
  delete all[id];
@@ -328,9 +257,9 @@ export function deleteEntry(id) {
328
257
  * Replace the entire store atomically. Used on server startup to drop entries
329
258
  * older than maxAgeMs (24h-class staleness sweep) without N round-trips.
330
259
  */
331
- export function replaceAll(entries) {
260
+ export async function replaceAll(entries) {
332
261
  try {
333
- withLock(() => saveAskStore(entries));
262
+ await withLock(() => saveAskStore(entries));
334
263
  } catch {}
335
264
  }
336
265
 
@@ -341,9 +270,9 @@ export function replaceAll(entries) {
341
270
  * Stale 判定用 max(createdAt, answeredAt):刚 answered 的老 entry(createdAt 旧但
342
271
  * answeredAt 新)必须保留,否则 ask-bridge 短轮询拿不到答案。
343
272
  */
344
- export function pruneStale(maxAgeMs = 24 * 60 * 60 * 1000) {
273
+ export async function pruneStale(maxAgeMs = 24 * 60 * 60 * 1000) {
345
274
  try {
346
- return withLock(() => {
275
+ return await withLock(() => {
347
276
  const all = loadAskStore();
348
277
  const cutoff = Date.now() - maxAgeMs;
349
278
  const survivors = {};
@@ -0,0 +1,123 @@
1
+ // Async file lock — 替代 Atomics.wait 阻塞锁
2
+ // 使用 fs.promises.open('wx') 原子创建 + setTimeout 异步重试
3
+ // 同进程内通过 Promise 链串行化,跨进程通过文件锁互斥
4
+
5
+ import { open, stat, unlink, readFile } from 'node:fs/promises';
6
+ import { mkdirSync } from 'node:fs';
7
+
8
+ // 同进程内的 Promise 互斥锁(按 lockPath 分组)
9
+ const _inProcessLocks = new Map();
10
+
11
+ function isPidAlive(pid) {
12
+ if (!Number.isInteger(pid) || pid <= 0) return false;
13
+ try {
14
+ process.kill(pid, 0);
15
+ return true;
16
+ } catch (err) {
17
+ return err && err.code === 'EPERM';
18
+ }
19
+ }
20
+
21
+ async function readLockOwnerPid(path) {
22
+ try {
23
+ const raw = await readFile(path, 'utf-8');
24
+ if (!raw) return null;
25
+ const obj = JSON.parse(raw);
26
+ if (obj && Number.isInteger(obj.pid)) return obj.pid;
27
+ } catch {}
28
+ return null;
29
+ }
30
+
31
+ async function isLockStale(path, mtimeFallbackMs) {
32
+ const pid = await readLockOwnerPid(path);
33
+ if (pid !== null) {
34
+ // 同进程的锁由 _inProcessLocks 管理,这里看到 own pid 说明是上次崩溃残留
35
+ if (pid === process.pid) return true;
36
+ return !isPidAlive(pid);
37
+ }
38
+ try {
39
+ const stats = await stat(path);
40
+ return Date.now() - stats.mtimeMs > mtimeFallbackMs;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function sleep(ms) {
47
+ return new Promise(resolve => setTimeout(resolve, ms));
48
+ }
49
+
50
+ async function _acquireFileLock(lockPath, opts) {
51
+ const deadline = Date.now() + (opts.deadline ?? 2000);
52
+ const retryMs = opts.retryMs ?? 25;
53
+ const staleThresholdMs = opts.staleThresholdMs ?? 5000;
54
+ const writePid = opts.writePid !== false;
55
+
56
+ while (true) {
57
+ try {
58
+ const fh = await open(lockPath, 'wx');
59
+ if (writePid) {
60
+ await fh.writeFile(JSON.stringify({ pid: process.pid, ts: Date.now() }));
61
+ }
62
+ await fh.close();
63
+ return;
64
+ } catch (err) {
65
+ if (err?.code === 'EEXIST') {
66
+ if (Date.now() < deadline) {
67
+ if (await isLockStale(lockPath, staleThresholdMs)) {
68
+ try { await unlink(lockPath); } catch {}
69
+ continue;
70
+ }
71
+ await sleep(retryMs);
72
+ continue;
73
+ }
74
+ // deadline exceeded — only break if lock is stale (dead PID or old mtime)
75
+ if (await isLockStale(lockPath, staleThresholdMs)) {
76
+ try { await unlink(lockPath); } catch {}
77
+ continue;
78
+ }
79
+ throw new Error(`Lock acquisition timeout: ${lockPath} (held by live process)`);
80
+
81
+ }
82
+ throw err;
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @param {string} lockPath - 锁文件路径
89
+ * @param {() => any | Promise<any>} fn - 持锁期间执行的函数
90
+ * @param {object} [opts]
91
+ * @param {number} [opts.deadline] - 最长等待时间 (ms),默认 2000
92
+ * @param {number} [opts.retryMs] - 重试间隔 (ms),默认 25
93
+ * @param {number} [opts.staleThresholdMs] - mtime 过期阈值 (ms),默认 5000
94
+ * @param {boolean} [opts.writePid] - 锁文件中写入 PID 信息,默认 true
95
+ * @param {string} [opts.ensureDir] - 若提供,在获取锁前确保此目录存在
96
+ */
97
+ export async function withFileLockAsync(lockPath, fn, opts = {}) {
98
+ if (opts.ensureDir) {
99
+ try { mkdirSync(opts.ensureDir, { recursive: true }); } catch {}
100
+ }
101
+
102
+ // 同进程串行化:排队等待前一个持锁操作完成
103
+ const prev = _inProcessLocks.get(lockPath) || Promise.resolve();
104
+ let resolve;
105
+ const next = new Promise(r => { resolve = r; });
106
+ _inProcessLocks.set(lockPath, next);
107
+
108
+ await prev;
109
+
110
+ try {
111
+ await _acquireFileLock(lockPath, opts);
112
+ try {
113
+ return await fn();
114
+ } finally {
115
+ try { await unlink(lockPath); } catch {}
116
+ }
117
+ } finally {
118
+ resolve();
119
+ if (_inProcessLocks.get(lockPath) === next) {
120
+ _inProcessLocks.delete(lockPath);
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,131 @@
1
+ // Async Write Queue — 非阻塞追加写入,替代 appendFileSync
2
+ // 单写者模式保证写入顺序,进程退出时回退同步写入保证不丢数据
3
+
4
+ import { appendFile } from 'node:fs/promises';
5
+ import { appendFileSync } from 'node:fs';
6
+
7
+ const HIGH_WATER_MARK = 50 * 1024 * 1024; // 50MB — 超过此值降级为同步写入
8
+
9
+ export class AsyncWriteQueue {
10
+ /**
11
+ * @param {string|(() => string)} pathOrGetter - 文件路径或返回路径的函数(支持动态路径)
12
+ * @param {object} [opts]
13
+ * @param {boolean} [opts.syncMode] - 强制同步模式
14
+ */
15
+ constructor(pathOrGetter, opts = {}) {
16
+ this._pathOrGetter = pathOrGetter;
17
+ this._queue = []; // { path: string, data: string, onDone?: Function }[]
18
+ this._pendingBytes = 0;
19
+ this._draining = false;
20
+ this._drainPromise = null;
21
+ this._closed = false;
22
+ this._flushResolvers = []; // resolve() callbacks waiting for flush()
23
+ this._syncMode = opts.syncMode || !!process.env.CCV_SYNC_WRITES;
24
+ }
25
+
26
+ _getPath() {
27
+ return typeof this._pathOrGetter === 'function' ? this._pathOrGetter() : this._pathOrGetter;
28
+ }
29
+
30
+ get filePath() { return this._getPath(); }
31
+ get pendingBytes() { return this._pendingBytes; }
32
+
33
+ append(data, onDone) {
34
+ if (this._closed) return;
35
+ const path = this._getPath();
36
+ if (!path) {
37
+ if (onDone) try { onDone(); } catch {}
38
+ return;
39
+ }
40
+
41
+ if (this._syncMode || this._pendingBytes >= HIGH_WATER_MARK) {
42
+ try { appendFileSync(path, data); } catch {}
43
+ if (onDone) try { onDone(); } catch {}
44
+ return;
45
+ }
46
+
47
+ const byteLen = Buffer.byteLength(data);
48
+ this._queue.push({ path, data, onDone });
49
+ this._pendingBytes += byteLen;
50
+ this._scheduleDrain();
51
+ }
52
+
53
+ async flush() {
54
+ if (this._queue.length === 0 && !this._draining) return;
55
+ return new Promise(resolve => {
56
+ this._flushResolvers.push(resolve);
57
+ this._scheduleDrain();
58
+ });
59
+ }
60
+
61
+ async close() {
62
+ this._closed = true;
63
+ // 等待 in-flight 异步 drain 完成,防止退出时丢失正在写入的数据
64
+ if (this._drainPromise) {
65
+ try { await this._drainPromise; } catch {}
66
+ }
67
+ // 同步兜底:排空 drain 完成后可能新入队的剩余项
68
+ this._drainSync();
69
+ }
70
+
71
+ // Synchronous drain for process exit — guarantees no data loss
72
+ _drainSync() {
73
+ while (this._queue.length > 0) {
74
+ const item = this._queue.shift();
75
+ try {
76
+ appendFileSync(item.path, item.data);
77
+ if (item.onDone) item.onDone();
78
+ } catch {}
79
+ }
80
+ this._pendingBytes = 0;
81
+ for (const resolve of this._flushResolvers) resolve();
82
+ this._flushResolvers.length = 0;
83
+ }
84
+
85
+ _scheduleDrain() {
86
+ if (this._draining) return;
87
+ this._draining = true;
88
+ // queueMicrotask 批量收集同 tick 的 append 调用
89
+ queueMicrotask(() => { this._drainPromise = this._drain(); });
90
+ }
91
+
92
+ async _drain() {
93
+ while (this._queue.length > 0) {
94
+ // Group by path — entries for the same file are batched together
95
+ const batch = this._queue.splice(0);
96
+ const byPath = new Map();
97
+ for (const item of batch) {
98
+ if (!byPath.has(item.path)) byPath.set(item.path, []);
99
+ byPath.get(item.path).push(item);
100
+ }
101
+
102
+ for (const [path, items] of byPath) {
103
+ const callbacks = [];
104
+ let combined = '';
105
+ for (const item of items) {
106
+ combined += item.data;
107
+ if (item.onDone) callbacks.push(item.onDone);
108
+ }
109
+
110
+ try {
111
+ await appendFile(path, combined);
112
+ } catch {}
113
+ for (const cb of callbacks) {
114
+ try { cb(); } catch {}
115
+ }
116
+ }
117
+
118
+ let totalBytes = 0;
119
+ for (const [, items] of byPath) {
120
+ for (const item of items) totalBytes += Buffer.byteLength(item.data);
121
+ }
122
+ this._pendingBytes -= totalBytes;
123
+ if (this._pendingBytes < 0) this._pendingBytes = 0;
124
+ }
125
+
126
+ this._draining = false;
127
+
128
+ for (const resolve of this._flushResolvers) resolve();
129
+ this._flushResolvers.length = 0;
130
+ }
131
+ }
@@ -3,7 +3,10 @@ import { join, sep } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
 
6
- const execFileAsync = promisify(execFile);
6
+ // Windows:从无控制台的 worker node.exe 启动 git.exe 会弹可见控制台窗口,
7
+ // 包装层统一默认 windowsHide(POSIX no-op),覆盖本文件全部 git 调用。
8
+ const _execFileAsyncRaw = promisify(execFile);
9
+ const execFileAsync = (cmd, args, opts) => _execFileAsyncRaw(cmd, args, { windowsHide: true, ...opts });
7
10
 
8
11
  const UNTRACKED_MAX_BYTES = 5 * 1024 * 1024;
9
12
  const BINARY_PROBE_BYTES = 8192;