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
@@ -23,13 +23,16 @@ import { existsSync, readFileSync, appendFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { LOG_DIR } from '../../findcc.js';
25
25
  import { t } from '../i18n.js';
26
+ import { upsertSender } from './im-senders.js';
26
27
 
27
28
  // ─── tunables (shared across platforms; per-platform rate caps come from adapter.rateLimit) ───
28
29
  const SEEN_MAX = 500;
29
30
  const RATE_WINDOW_MS = 60_000;
30
31
  const MAX_CHUNKS_PER_TURN = 5;
31
32
  const MAX_QUEUE = 50; // cap inbound backlog so an authorized sender can't grow it unbounded
32
- const PENDING_TIMEOUT_MS = 10 * 60_000; // release a stuck injection so the queue can't wedge forever
33
+ const PENDING_TIMEOUT_MS = process.env.CCV_IM_PLATFORM ? 2 * 60_000 : 10 * 60_000;
34
+ const IDLE_POLL_INTERVAL_MS = 5_000; // check every 5s if streaming stopped
35
+ const IDLE_POLL_THRESHOLD = 3; // 3 consecutive idle ticks (15s) → synthetic turn_end
33
36
  const CONNECT_TIMEOUT_MS = 15_000; // bound adapter.connect() so a hung start can't block others
34
37
  const STOP_WORDS = new Set(['/stop', 'stop', '停止', 'esc', '/esc']);
35
38
 
@@ -37,6 +40,8 @@ const STOP_WORDS = new Set(['/stop', 'stop', '停止', 'esc', '/esc']);
37
40
  const instances = new Map(); // platformId → instance
38
41
  let activeInjection = null; // { platformId, since, target } — the one in-flight turn
39
42
  let activeInjectionTimer = null; // self-heal timer if a turn_end never arrives
43
+ let idlePollTimer = null; // secondary idle detection (IM worker only)
44
+ let idlePollCount = 0; // consecutive ticks where isStreaming() is false
40
45
  let fetchImpl = null; // shared test seam
41
46
 
42
47
  // ─── test seams ───
@@ -58,6 +63,7 @@ function newInstance(adapter) {
58
63
  queue: [],
59
64
  sendTimes: [],
60
65
  store: {}, // adapter scratch (token cache, send client)
66
+ ackCardPromise: null, // Promise<handle|null> for the in-flight ack card
61
67
  };
62
68
  }
63
69
 
@@ -93,26 +99,112 @@ function ctxFor(inst) {
93
99
  return { fetch: coreFetch, store: inst.store };
94
100
  }
95
101
 
102
+ // 发送者身份解析的缓存有效期:同一 senderId 在此窗口内只解析一次(避免每条消息打 contact API)。
103
+ const SENDER_RESOLVE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
104
+ // 负缓存有效期:解析「没拿到任何身份」(外部用户 / 无 scope / API 失败)也要短期记一笔,否则像飞书这种
105
+ // 无免费字段、靠 contact API 的平台会对每条消息重复打 API(触发租户限流)。10min 后再重试,兼顾「拿到瞬时失败可恢复」。
106
+ const SENDER_NEG_TTL_MS = 10 * 60 * 1000;
107
+
108
+ /**
109
+ * 后台解析发送者 {name, avatar} 并持久化到 IM_<id>/im-senders.json(供 /senders 路由 + 对话记录展示)。
110
+ * 优先用 normalizeInbound 免费带出的 senderName/senderAvatar;缺失再调可选的 adapter.resolveSender
111
+ * (需打 contact API 的平台)。整段静默:任何失败都不得影响消息注入。**调用方必须 void(不 await)。**
112
+ */
113
+ async function resolveAndPersistSender(inst, senderId, normalized) {
114
+ if (!senderId) return;
115
+ const store = inst.store || (inst.store = {});
116
+ const cache = store.senderCache || (store.senderCache = {});
117
+ const now = Date.now();
118
+ // 缓存项形如 { ts, ok }:解析成功用 7d TTL,负缓存(没拿到)用 10min TTL。
119
+ const c = cache[senderId];
120
+ if (c && (now - c.ts) < (c.ok ? SENDER_RESOLVE_TTL_MS : SENDER_NEG_TTL_MS)) return;
121
+ try {
122
+ let name = normalized.senderName != null ? String(normalized.senderName) : null;
123
+ let avatar = normalized.senderAvatar != null ? String(normalized.senderAvatar) : null;
124
+ // 免费字段不全且适配器支持 → 调一次 contact API 补齐(仅 feishu;dingtalk/wecom 无 resolveSender)。
125
+ let attemptedResolve = false;
126
+ if ((!name || !avatar) && typeof inst.adapter.resolveSender === 'function') {
127
+ attemptedResolve = true;
128
+ const r = await inst.adapter.resolveSender(inst.bridgeDeps.getConfig(), senderId, ctxFor(inst));
129
+ if (r) {
130
+ if (!name && r.name != null) name = String(r.name);
131
+ if (!avatar && r.avatar != null) avatar = String(r.avatar);
132
+ }
133
+ }
134
+ if (name || avatar) {
135
+ upsertSender(inst.adapter.id, senderId, { name, avatar });
136
+ cache[senderId] = { ts: now, ok: true };
137
+ } else if (attemptedResolve) {
138
+ // 调过 contact API 但什么都没拿到 → 负缓存,避免对该发送者每条消息反复打 API。
139
+ cache[senderId] = { ts: now, ok: false };
140
+ }
141
+ } catch (e) {
142
+ cache[senderId] = { ts: now, ok: false }; // 失败也负缓存(短 TTL,10min 后自动重试)
143
+ audit(inst, 'resolve-sender-error', { senderId, error: String(e?.message || e) });
144
+ }
145
+ }
146
+
96
147
  function queueCap(inst) {
97
148
  return inst.maxQueueOverride ?? MAX_QUEUE;
98
149
  }
99
150
 
151
+ /**
152
+ * Await the in-flight ack card promise, update it with terminal text/status, and null
153
+ * the promise on `inst`. Returns true if the card was successfully updated. Best-effort:
154
+ * never throws, never blocks the slot release.
155
+ */
156
+ async function finalizeAckCard(inst, target, text, status) {
157
+ try {
158
+ const handle = await inst.ackCardPromise?.catch(() => null);
159
+ inst.ackCardPromise = null;
160
+ if (handle && typeof inst.adapter.updateAckCard === 'function') {
161
+ const cfg = inst.bridgeDeps?.getConfig();
162
+ if (cfg) return !!(await inst.adapter.updateAckCard(cfg, target, handle, text, status, ctxFor(inst)).catch(() => false));
163
+ }
164
+ } catch { /* best-effort */ }
165
+ inst.ackCardPromise = null;
166
+ return false;
167
+ }
168
+
100
169
  // ─── activeInjection lifecycle ───
101
170
  function clearActiveInjection() {
102
171
  activeInjection = null;
103
172
  if (activeInjectionTimer) { clearTimeout(activeInjectionTimer); activeInjectionTimer = null; }
173
+ if (idlePollTimer) { clearInterval(idlePollTimer); idlePollTimer = null; }
174
+ idlePollCount = 0;
104
175
  }
105
176
  function armActiveInjection(inst, target, since) {
106
- activeInjection = { platformId: inst.adapter.id, since, target };
177
+ activeInjection = { platformId: inst.adapter.id, since, target, transcriptPath: null };
107
178
  if (activeInjectionTimer) clearTimeout(activeInjectionTimer);
108
- activeInjectionTimer = setTimeout(() => {
179
+ activeInjectionTimer = setTimeout(async () => {
109
180
  // Only fire if THIS injection still owns the slot (symmetry with the inject-failure guard).
110
181
  if (!activeInjection || activeInjection.since !== since) return;
111
182
  audit(inst, 'reply-timeout', { conversationId: target?.conversationId });
112
- clearActiveInjection(); // turn_end never came release the slot globally…
183
+ const cardUpdated = await finalizeAckCard(inst, target, tr(inst, 'ackTimeout'), 'error');
184
+ if (!activeInjection || activeInjection.since !== since) return;
185
+ clearActiveInjection();
186
+ if (!cardUpdated) void sendReply(inst, target, tr(inst, 'ackTimeout'));
113
187
  drainAll(); // …and let any platform's queue proceed
114
188
  }, PENDING_TIMEOUT_MS);
115
189
  if (typeof activeInjectionTimer.unref === 'function') activeInjectionTimer.unref();
190
+ // Secondary idle detection: poll isStreaming() to catch missed Stop hook events.
191
+ if (idlePollTimer) clearInterval(idlePollTimer);
192
+ idlePollCount = 0;
193
+ let sawStreaming = false;
194
+ idlePollTimer = setInterval(() => {
195
+ if (!activeInjection || activeInjection.since !== since) { clearInterval(idlePollTimer); idlePollTimer = null; return; }
196
+ const d = inst.bridgeDeps;
197
+ if (!d) return;
198
+ if (d.isStreaming()) { sawStreaming = true; idlePollCount = 0; return; }
199
+ if (!sawStreaming) return; // haven't seen streaming start yet — don't count idle
200
+ idlePollCount++;
201
+ if (idlePollCount >= IDLE_POLL_THRESHOLD) {
202
+ clearInterval(idlePollTimer); idlePollTimer = null;
203
+ audit(inst, 'idle-turn-end', { conversationId: target?.conversationId, idleSeconds: idlePollCount * IDLE_POLL_INTERVAL_MS / 1000 });
204
+ notifyTurnEnd(null, since, activeInjection?.transcriptPath || null);
205
+ }
206
+ }, IDLE_POLL_INTERVAL_MS);
207
+ if (typeof idlePollTimer.unref === 'function') idlePollTimer.unref();
116
208
  }
117
209
 
118
210
  // ─── small helpers ───
@@ -129,12 +221,15 @@ function bracketPasteSubmit(text) {
129
221
  return ['\x1b[200~' + text + '\x1b[201~', '\r'];
130
222
  }
131
223
 
132
- /** Prepend the IM-origin marker `⟦im:<id>⟧`, EXCEPT for slash commands (a marker prefix would
133
- * stop the CLI from recognizing `/clear` etc.). trim() guards leading whitespace / full-width
134
- * spaces. KEEP IN SYNC with IM_ORIGIN_RE in src/utils/imOrigin.js. */
135
- function markOrigin(id, content) {
224
+ /** Prepend the IM-origin marker `⟦im:<id>⟧` or `⟦im:<id>:<senderId>⟧`, EXCEPT for slash commands
225
+ * (a marker prefix would stop the CLI from recognizing `/clear` etc.). trim() guards leading
226
+ * whitespace / full-width spaces. senderId is embedded only when it's "safe" (no space / `:` / `⟧`)
227
+ * so the marker stays unambiguous; otherwise it degrades to the platform-only form.
228
+ * KEEP IN SYNC with IM_ORIGIN_RE in src/utils/imOrigin.js. */
229
+ function markOrigin(id, senderId, content) {
136
230
  if (content.trim().startsWith('/')) return content;
137
- return `⟦im:${id}⟧` + content;
231
+ const safe = (typeof senderId === 'string' && /^[^\s:⟧]+$/.test(senderId)) ? `:${senderId}` : '';
232
+ return `⟦im:${id}${safe}⟧` + content;
138
233
  }
139
234
 
140
235
  /**
@@ -163,8 +258,8 @@ function isStopCommand(text) {
163
258
  return STOP_WORDS.has(text.trim().toLowerCase());
164
259
  }
165
260
 
166
- function tr(inst, key) {
167
- return t(`${inst.adapter.i18nNs}.${key}`);
261
+ function tr(inst, key, params) {
262
+ return t(`${inst.adapter.i18nNs}.${key}`, params);
168
263
  }
169
264
 
170
265
  // ─── transcript extraction (the safe outbound text source) ───
@@ -312,11 +407,19 @@ function handleInboundInner(inst, normalized) {
312
407
  }
313
408
 
314
409
  audit(inst, 'in', { msgId, senderId, conversationId, len: text.length });
410
+ // 后台解析并持久化发送者身份(姓名/头像)供「对话记录」展示。fire-and-forget:
411
+ // 绝不 await(解析可能要打 contact API),不阻塞/不影响下面的消息注入。
412
+ void resolveAndPersistSender(inst, senderId, normalized);
315
413
  if (!text) return; // non-text messages (image/voice/file) are ignored in v1
316
414
 
317
415
  if (isStopCommand(text)) {
318
416
  inst.bridgeDeps.writeToPty('\x1b'); // ESC interrupts the current turn (NOT killPty)
319
417
  audit(inst, 'stop', { conversationId });
418
+ const stoppedInst = activeInjection ? instances.get(activeInjection.platformId) : null;
419
+ const stoppedTarget = activeInjection?.target;
420
+ if (stoppedInst && stoppedTarget) {
421
+ void finalizeAckCard(stoppedInst, stoppedTarget, tr(stoppedInst, 'interrupted'), 'interrupted');
422
+ }
320
423
  // ESC interrupts whatever turn is live on the shared PTY (possibly another platform's), which
321
424
  // may mean its turn_end never fires. Release the global slot and resume all queues so /stop
322
425
  // can never wedge the bridge.
@@ -328,12 +431,13 @@ function handleInboundInner(inst, normalized) {
328
431
 
329
432
  if (inst.queue.length >= queueCap(inst)) {
330
433
  audit(inst, 'queue-full', { conversationId, queued: inst.queue.length });
331
- void sendReply(inst, target, tr(inst, 'queueFull'));
434
+ void sendReply(inst, target, tr(inst, 'queueFull', { max: String(queueCap(inst)) }));
332
435
  return;
333
436
  }
334
- inst.queue.push({ ...target, content: text });
437
+ inst.queue.push({ ...target, senderId, content: text });
335
438
  if (activeInjection || inst.bridgeDeps.isStreaming()) {
336
- void sendReply(inst, target, tr(inst, 'busyQueued'));
439
+ const ahead = inst.queue.length - 1;
440
+ void sendReply(inst, target, tr(inst, 'busyQueued', { ahead: String(ahead), max: String(queueCap(inst)) }));
337
441
  }
338
442
  drainQueue(inst);
339
443
  }
@@ -367,20 +471,36 @@ function drainQueue(inst) {
367
471
  }
368
472
  const since = Date.now();
369
473
  armActiveInjection(inst, item, since);
370
- if (!isImWorker && skipPerm) {
474
+ // Instant ack: fire-and-forget so writeToPtySequential is never delayed.
475
+ if (cfg.ackCard !== false && typeof inst.adapter.sendAckCard === 'function') {
476
+ const ackTarget = item;
477
+ inst.ackCardPromise = inst.adapter.sendAckCard(cfg, item, tr(inst, 'ackProcessing'), ctxFor(inst))
478
+ .then((handle) => { if (!handle) void sendReply(inst, ackTarget, tr(inst, 'ackProcessing')); return handle; })
479
+ .catch((e) => { audit(inst, 'ack-card-error', { error: String(e?.message || e) }); void sendReply(inst, ackTarget, tr(inst, 'ackProcessing')); return null; });
480
+ } else if (cfg.ackCard !== false) {
481
+ void sendReply(inst, item, tr(inst, 'ackProcessing'));
482
+ inst.ackCardPromise = null;
483
+ } else {
484
+ inst.ackCardPromise = null;
485
+ }
486
+ if (!isImWorker && skipPerm && cfg.ackCard === false) {
371
487
  audit(inst, 'skip-perm-warning', { conversationId: item.conversationId });
372
488
  void sendReply(inst, item, tr(inst, 'skipPermWarning'));
373
489
  }
374
490
  // React to a failed injection (PTY gone/died mid-write → onComplete(false)). Without this the
375
491
  // prompt never submits, no turn_end ever comes, and the slot wedges until the timeout. Only
376
492
  // act if THIS injection still owns the slot (a /stop or timeout may have released it).
377
- d.writeToPtySequential(bracketPasteSubmit(markOrigin(inst.adapter.id, item.content)), (ok) => {
493
+ d.writeToPtySequential(bracketPasteSubmit(markOrigin(inst.adapter.id, item.senderId, item.content)), (ok) => {
378
494
  if (ok) return;
379
495
  if (!activeInjection || activeInjection.platformId !== inst.adapter.id || activeInjection.since !== since) return;
380
496
  audit(inst, 'inject-failed', { conversationId: item.conversationId });
381
- clearActiveInjection();
382
- void sendReply(inst, item, tr(inst, 'injectFailed'));
383
- drainAll();
497
+ void (async () => {
498
+ const cardUpdated = await finalizeAckCard(inst, item, tr(inst, 'injectFailed'), 'error');
499
+ if (!activeInjection || activeInjection.platformId !== inst.adapter.id || activeInjection.since !== since) return;
500
+ clearActiveInjection();
501
+ if (!cardUpdated) void sendReply(inst, item, tr(inst, 'injectFailed'));
502
+ drainAll();
503
+ })().catch(() => {});
384
504
  }, { settleMs: 250 });
385
505
  return; // one at a time; resume on the next turn_end
386
506
  }
@@ -395,6 +515,7 @@ function drainAll() {
395
515
 
396
516
  // ─── outbound trigger (called from server.js _emitTurnEnd) ───
397
517
  export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
518
+ if (transcriptPath && activeInjection) activeInjection.transcriptPath = transcriptPath;
398
519
  if (!activeInjection) { drainAll(); return; } // only reply to turns a bridge initiated
399
520
  const inst = instances.get(activeInjection.platformId);
400
521
  if (!inst) { clearActiveInjection(); drainAll(); return; }
@@ -403,6 +524,9 @@ export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
403
524
  // correlation is a v2 item.)
404
525
  if (ts && activeInjection.since && ts < activeInjection.since) { drainAll(); return; }
405
526
  const target = activeInjection.target;
527
+ // Grab the ack card promise before clearing state.
528
+ const ackP = inst.ackCardPromise;
529
+ inst.ackCardPromise = null;
406
530
  clearActiveInjection();
407
531
  // Idempotency for a doubled turn_end of the SAME turn (a re-broadcast carries the same ts).
408
532
  // Keyed on ts, NOT reply text.
@@ -414,8 +538,36 @@ export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
414
538
  drainAll();
415
539
  let text = extractLastAssistantText(transcriptPath);
416
540
  if (!text) text = tr(inst, 'noTextReply');
417
- try { await sendReply(inst, target, text); }
418
- catch (e) { inst.lastError = String(e?.message || e); audit(inst, 'send-error', { error: inst.lastError }); }
541
+
542
+ // Try to update the ack card in-place with the reply. Fall back to sendReply on failure.
543
+ const handle = await ackP?.catch(() => null);
544
+ if (handle && typeof inst.adapter.updateAckCard === 'function') {
545
+ const cfg = inst.bridgeDeps.getConfig();
546
+ let chunks = chunkText(text, cfg.maxChunkChars);
547
+ if (chunks.length > MAX_CHUNKS_PER_TURN) {
548
+ chunks = chunks.slice(0, MAX_CHUNKS_PER_TURN);
549
+ chunks[MAX_CHUNKS_PER_TURN - 1] += '\n\n' + tr(inst, 'truncated');
550
+ }
551
+ try {
552
+ const updated = await inst.adapter.updateAckCard(cfg, target, handle, chunks[0] || text, 'done', ctxFor(inst));
553
+ if (updated && chunks.length > 1) {
554
+ for (let i = 1; i < chunks.length; i++) {
555
+ try { await rateLimitGate(inst); await inst.adapter.sendOne(cfg, target, chunks[i], ctxFor(inst)); }
556
+ catch (e) { inst.lastError = String(e?.message || e); audit(inst, 'send-error', { error: inst.lastError }); break; }
557
+ }
558
+ } else if (!updated) {
559
+ await sendReply(inst, target, text);
560
+ }
561
+ audit(inst, 'out', { conversationId: target.conversationId, chunks: chunks.length, cardUpdated: !!updated });
562
+ } catch (e) {
563
+ inst.lastError = String(e?.message || e);
564
+ audit(inst, 'card-update-error', { error: inst.lastError });
565
+ try { await sendReply(inst, target, text); } catch { /* already logged in sendReply */ }
566
+ }
567
+ } else {
568
+ try { await sendReply(inst, target, text); }
569
+ catch (e) { inst.lastError = String(e?.message || e); audit(inst, 'send-error', { error: inst.lastError }); }
570
+ }
419
571
  }
420
572
 
421
573
  // ─── per-platform lifecycle ───
@@ -452,6 +604,11 @@ export async function startBridge(id, deps) {
452
604
  export async function stopBridge(id) {
453
605
  const inst = instances.get(id);
454
606
  if (!inst) return;
607
+ if (inst.ackCardPromise && activeInjection?.platformId === id) {
608
+ await finalizeAckCard(inst, activeInjection.target, tr(inst, 'noSession'), 'error');
609
+ } else {
610
+ inst.ackCardPromise = null;
611
+ }
455
612
  try { await inst.adapter.disconnect?.(inst.client, ctxFor(inst)); } catch { /* best-effort */ }
456
613
  inst.client = null;
457
614
  inst.running = false;
@@ -3,9 +3,14 @@
3
3
  // + 强制 allowlist(见 plan §安全)。但它仍是抑制交互式工具、控制回复风格的主要手段。
4
4
  //
5
5
  // 用 openSync(path,'wx') 原子创建:从不覆盖用户已编辑的文件(避免 existsSync→write 的 TOCTOU)。
6
- import { openSync, writeFileSync, closeSync, mkdirSync } from 'node:fs';
6
+ import { openSync, writeFileSync, closeSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ import { randomBytes } from 'node:crypto';
8
9
  import { imDir } from './im-lock.js';
10
+ import { renameSyncWithRetry } from './file-api.js';
11
+
12
+ // CLAUDE.md 写入上限(字符数,按 String.length / UTF-16 码元计):远超任何合理人格定义,纯防失控大 body。
13
+ export const MAX_CLAUDE_MD_CHARS = 256 * 1024;
9
14
 
10
15
  // id → 双语展示名(CLAUDE.md 为内联内容,不走 i18n.js)。
11
16
  const PLATFORM_LABELS = {
@@ -80,3 +85,34 @@ export function ensureImClaudeMd(id, dir = imDir(id)) {
80
85
  }
81
86
  return true;
82
87
  }
88
+
89
+ /**
90
+ * 读取 IM_<id>/CLAUDE.md 当前内容(供「模型性格定义」编辑器加载)。
91
+ * 文件不存在 → 返回预置文本(尚未落盘),让编辑器展示默认人格供用户定制;保存时才写盘。
92
+ */
93
+ export function readImClaudeMd(id) {
94
+ try {
95
+ return readFileSync(join(imDir(id), 'CLAUDE.md'), 'utf-8');
96
+ } catch (e) {
97
+ if (e && e.code === 'ENOENT') return buildImClaudeMdPreset(id);
98
+ throw e;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 覆盖写 IM_<id>/CLAUDE.md(原子:temp + rename,mode 0600)。下次该 IM worker 重启时生效
104
+ * (CLAUDE.md 仅在 worker 启动时读取一次)。
105
+ */
106
+ export function writeImClaudeMd(id, content) {
107
+ const text = String(content ?? '');
108
+ const dir = imDir(id);
109
+ mkdirSync(dir, { recursive: true });
110
+ const tmp = join(dir, `CLAUDE.md.tmp-${process.pid}-${randomBytes(4).toString('hex')}`);
111
+ try {
112
+ writeFileSync(tmp, text, { mode: 0o600 });
113
+ renameSyncWithRetry(tmp, join(dir, 'CLAUDE.md'));
114
+ } catch (err) {
115
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
116
+ throw err;
117
+ }
118
+ }
@@ -34,7 +34,7 @@ const DESCRIPTORS = {
34
34
  allowListField: 'allowStaffIds',
35
35
  defaults: {
36
36
  enabled: false, appKey: '', appSecret: '', allowStaffIds: [],
37
- maxChunkChars: 3800, blockOnSkipPermissions: false,
37
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true, cardTemplateId: '',
38
38
  },
39
39
  fields: [
40
40
  { key: 'enabled', type: 'bool' },
@@ -43,6 +43,8 @@ const DESCRIPTORS = {
43
43
  { key: 'allowStaffIds', type: 'idlist' },
44
44
  { key: 'maxChunkChars', type: 'chunk' },
45
45
  { key: 'blockOnSkipPermissions', type: 'bool' },
46
+ { key: 'ackCard', type: 'bool', default: true },
47
+ { key: 'cardTemplateId', type: 'string' },
46
48
  ],
47
49
  },
48
50
  feishu: {
@@ -50,7 +52,7 @@ const DESCRIPTORS = {
50
52
  allowListField: 'allowUserIds',
51
53
  defaults: {
52
54
  enabled: false, appId: '', appSecret: '', region: 'feishu', allowUserIds: [],
53
- maxChunkChars: 3800, blockOnSkipPermissions: false,
55
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true,
54
56
  },
55
57
  fields: [
56
58
  { key: 'enabled', type: 'bool' },
@@ -60,6 +62,7 @@ const DESCRIPTORS = {
60
62
  { key: 'allowUserIds', type: 'idlist' },
61
63
  { key: 'maxChunkChars', type: 'chunk' },
62
64
  { key: 'blockOnSkipPermissions', type: 'bool' },
65
+ { key: 'ackCard', type: 'bool', default: true },
63
66
  ],
64
67
  },
65
68
  wecom: {
@@ -67,7 +70,7 @@ const DESCRIPTORS = {
67
70
  allowListField: 'allowUserIds',
68
71
  defaults: {
69
72
  enabled: false, botId: '', secret: '', allowUserIds: [],
70
- maxChunkChars: 3800, blockOnSkipPermissions: false,
73
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true,
71
74
  },
72
75
  fields: [
73
76
  { key: 'enabled', type: 'bool' },
@@ -76,6 +79,7 @@ const DESCRIPTORS = {
76
79
  { key: 'allowUserIds', type: 'idlist' },
77
80
  { key: 'maxChunkChars', type: 'chunk' },
78
81
  { key: 'blockOnSkipPermissions', type: 'bool' },
82
+ { key: 'ackCard', type: 'bool', default: true },
79
83
  ],
80
84
  },
81
85
  discord: {
@@ -84,7 +88,7 @@ const DESCRIPTORS = {
84
88
  defaults: {
85
89
  // 1900 < Discord's hard 2000-char/message limit (the adapter also hard-splits as defense).
86
90
  enabled: false, botToken: '', allowUserIds: [],
87
- maxChunkChars: 1900, blockOnSkipPermissions: false,
91
+ maxChunkChars: 1900, blockOnSkipPermissions: false, ackCard: true,
88
92
  },
89
93
  fields: [
90
94
  { key: 'enabled', type: 'bool' },
@@ -92,6 +96,7 @@ const DESCRIPTORS = {
92
96
  { key: 'allowUserIds', type: 'idlist' },
93
97
  { key: 'maxChunkChars', type: 'chunk', default: 1900 }, // < Discord's 2000-char limit
94
98
  { key: 'blockOnSkipPermissions', type: 'bool' },
99
+ { key: 'ackCard', type: 'bool', default: true },
95
100
  ],
96
101
  },
97
102
  };
@@ -155,7 +160,7 @@ function normalizeIdList(v) {
155
160
 
156
161
  function normField(type, v, dflt) {
157
162
  switch (type) {
158
- case 'bool': return !!v;
163
+ case 'bool': return v !== undefined && v !== null ? !!v : (dflt !== undefined ? !!dflt : false);
159
164
  case 'cred':
160
165
  case 'secret': return typeof v === 'string' ? v.trim() : '';
161
166
  case 'idlist': return normalizeIdList(v);
@@ -169,7 +174,7 @@ function decodeField(type, v, dflt) {
169
174
  switch (type) {
170
175
  case 'cred':
171
176
  case 'secret': return decodeSecret(v);
172
- case 'bool': return !!v;
177
+ case 'bool': return v !== undefined && v !== null ? !!v : (dflt !== undefined ? !!dflt : false);
173
178
  case 'idlist': return normalizeIdList(v);
174
179
  case 'chunk': return clampChunk(v, dflt);
175
180
  case 'region': return v === 'lark' ? 'lark' : 'feishu';
@@ -22,7 +22,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
22
  export function resolveNodeBinary() {
23
23
  if (!process.versions.electron) return process.execPath;
24
24
  try {
25
- const out = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8' });
25
+ const out = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8', windowsHide: true });
26
26
  const p = process.platform === 'win32' ? out.split('\n')[0].trim() : out.trim();
27
27
  if (p) return p;
28
28
  } catch { /* fall through */ }
@@ -0,0 +1,73 @@
1
+ // IM 发送者身份持久化 —— 每个 IM worker 把解析到的「发送者 senderId → {name, avatar}」写到
2
+ // ~/.claude/cc-viewer/IM_<id>/im-senders.json,供主进程的 /api/im/:platform/senders 读取,
3
+ // 让「对话记录」弹窗能按 senderId 显示真实姓名 + 头像。
4
+ //
5
+ // 设计(与 im-lock.js 同风格):
6
+ // - 原子写:temp + renameSyncWithRetry,读方永不见半写 JSON。
7
+ // - 读容忍:坏 / 缺 / 非对象一律降级为 {},绝不抛。
8
+ // - 容量上限:按 ts 保留最近 MAX_SENDERS 个,避免无限增长(群聊发送者可能很多)。
9
+ // - 这些是本地数据(不入 preferences、不外发),与现有本地日志同级。
10
+ import { openSync, closeSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { randomBytes } from 'node:crypto';
13
+ import { imDir } from './im-lock.js';
14
+ import { renameSyncWithRetry } from './file-api.js';
15
+
16
+ export const MAX_SENDERS = 500;
17
+
18
+ function sendersPath(id) { return join(imDir(id), 'im-senders.json'); }
19
+
20
+ /** 读发送者映射;不存在 / 坏 JSON / 非对象 → {}。 */
21
+ export function readSenders(id) {
22
+ try {
23
+ const o = JSON.parse(readFileSync(sendersPath(id), 'utf-8'));
24
+ return (o && typeof o === 'object' && !Array.isArray(o)) ? o : {};
25
+ } catch { return {}; }
26
+ }
27
+
28
+ /** 原子写整张映射(temp + rename)。 */
29
+ function writeSenders(id, map) {
30
+ const dir = imDir(id);
31
+ mkdirSync(dir, { recursive: true });
32
+ const tmp = join(dir, `im-senders.json.tmp-${process.pid}-${randomBytes(4).toString('hex')}`);
33
+ try {
34
+ writeFileSync(tmp, JSON.stringify(map), { mode: 0o600 });
35
+ renameSyncWithRetry(tmp, sendersPath(id));
36
+ } catch (err) {
37
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * upsert 一个发送者。merge 进现有映射,盖上 ts;超过 MAX_SENDERS 时丢弃最旧的。
44
+ * name/avatar 任一为空都接受(部分平台只有名字没头像)。整段失败静默(持久化失败非致命)。
45
+ * @returns {boolean} 是否实际写入
46
+ */
47
+ export function upsertSender(id, senderId, profile = {}) {
48
+ if (typeof senderId !== 'string' || !senderId) return false;
49
+ const name = profile.name != null ? String(profile.name) : null;
50
+ const avatar = profile.avatar != null ? String(profile.avatar) : null;
51
+ try {
52
+ const map = readSenders(id);
53
+ const prev = map[senderId];
54
+ // 无新信息(name/avatar 都没变且都已存在)→ 跳过写盘,省 IO。
55
+ if (prev && prev.name === name && prev.avatar === avatar) return false;
56
+ map[senderId] = { name, avatar, ts: tsNow() };
57
+
58
+ const keys = Object.keys(map);
59
+ if (keys.length > MAX_SENDERS) {
60
+ keys
61
+ .sort((a, b) => (map[a].ts || 0) - (map[b].ts || 0))
62
+ .slice(0, keys.length - MAX_SENDERS)
63
+ .forEach((k) => { delete map[k]; });
64
+ }
65
+ writeSenders(id, map);
66
+ return true;
67
+ } catch {
68
+ return false; // 持久化失败不影响消息流
69
+ }
70
+ }
71
+
72
+ // 单独抽出便于测试注入(避免直接用 Date.now 影响可测性,但保持简单)。
73
+ function tsNow() { return Date.now(); }
@@ -20,7 +20,6 @@ const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
20
20
  const ARCHIVE_MAX_BYTES = 400 * 1024 * 1024;
21
21
 
22
22
  function syncSleep(ms) {
23
- // Atomics.wait 在主线程上真睡眠(不像 busy-wait 占满 CPU 或阻塞 event-loop heuristics)
24
23
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
25
24
  }
26
25