cc-viewer 1.6.345 → 1.7.0

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 (142) hide show
  1. package/README.md +14 -2
  2. package/cli.js +116 -37
  3. package/dist/assets/App-DTkacfRW.js +2 -0
  4. package/dist/assets/App-dYPa5-df.css +1 -0
  5. package/dist/assets/{MdxEditorPanel-C8ITEOzT.js → MdxEditorPanel-C5Q6uUyH.js} +1 -1
  6. package/dist/assets/Mobile-MoBp9c8k.js +1 -0
  7. package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
  8. package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
  9. package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
  10. package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
  11. package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
  12. package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
  13. package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
  14. package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
  15. package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
  16. package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
  18. package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
  19. package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
  20. package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
  21. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
  22. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
  23. package/dist/assets/clone-BQtvy9Ae.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
  25. package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
  26. package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
  27. package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
  28. package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
  29. package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
  30. package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
  31. package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
  32. package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
  34. package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
  35. package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
  36. package/dist/assets/index-Ac8vnV3v.js +2 -0
  37. package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
  38. package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
  39. package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
  40. package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
  41. package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
  42. package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
  43. package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
  44. package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
  45. package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
  46. package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
  47. package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
  48. package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
  49. package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
  50. package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
  51. package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
  52. package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
  53. package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
  54. package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
  55. package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
  56. package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
  57. package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +2 -0
  58. package/dist/assets/{seqResourceLoaders-B3TSSS6T.css → seqResourceLoaders-CxuD1CYl.css} +1 -1
  59. package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
  60. package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
  61. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
  62. package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
  63. package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
  64. package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
  65. package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
  66. package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
  67. package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
  68. package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
  69. package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
  70. package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
  71. package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
  72. package/dist/index.html +4 -4
  73. package/findcc.js +79 -7
  74. package/package.json +2 -1
  75. package/server/i18n.js +21 -141
  76. package/server/interceptor.js +211 -403
  77. package/server/lib/adapters/dingtalk-adapter.js +16 -2
  78. package/server/lib/adapters/discord-adapter.js +28 -6
  79. package/server/lib/adapters/feishu-adapter.js +50 -4
  80. package/server/lib/adapters/wecom-adapter.js +24 -0
  81. package/server/lib/async-write-queue.js +12 -1
  82. package/server/lib/create_system_prompt.js +13 -7
  83. package/server/lib/delta-reconstructor.js +3 -3
  84. package/server/lib/error-report.js +45 -0
  85. package/server/lib/im-bridge-core.js +71 -4
  86. package/server/lib/im-lock.js +16 -2
  87. package/server/lib/im-log-watcher.js +68 -8
  88. package/server/lib/im-process-manager.js +7 -1
  89. package/server/lib/interceptor-core.js +19 -174
  90. package/server/lib/log-management.js +214 -247
  91. package/server/lib/log-stream.js +59 -108
  92. package/server/lib/log-watcher.js +34 -272
  93. package/server/lib/log-zip.js +148 -0
  94. package/server/lib/model-system-prompts.js +10 -6
  95. package/server/lib/session-boundary.js +266 -0
  96. package/server/lib/session-pin-store.js +10 -19
  97. package/server/lib/spawn-model-resolver.js +109 -0
  98. package/server/lib/stats-worker.js +138 -267
  99. package/server/lib/system-prompt-files.js +7 -3
  100. package/server/lib/system-prompt-render.js +79 -0
  101. package/server/lib/user-prompt-extract.js +250 -0
  102. package/server/lib/v2/adapter.js +1059 -0
  103. package/server/lib/v2/blob-store.js +44 -0
  104. package/server/lib/v2/conversation-store.js +217 -0
  105. package/server/lib/v2/convert-manager.js +79 -0
  106. package/server/lib/v2/convert-worker.js +42 -0
  107. package/server/lib/v2/convert.js +409 -0
  108. package/server/lib/v2/identity.js +204 -0
  109. package/server/lib/v2/journal.js +62 -0
  110. package/server/lib/v2/layout.js +167 -0
  111. package/server/lib/v2/live-feed.js +451 -0
  112. package/server/lib/v2/migrate-prompt.js +54 -0
  113. package/server/lib/v2/replay.js +191 -0
  114. package/server/lib/v2/session-select.js +222 -0
  115. package/server/lib/v2/singleflight.js +104 -0
  116. package/server/lib/v2/v2-writer.js +513 -0
  117. package/server/lib/v2/verify.js +233 -0
  118. package/server/pty-manager.js +115 -36
  119. package/server/routes/dingtalk.js +9 -3
  120. package/server/routes/events.js +30 -130
  121. package/server/routes/files-fs.js +4 -2
  122. package/server/routes/im.js +67 -20
  123. package/server/routes/logs.js +194 -152
  124. package/server/routes/preferences.js +0 -3
  125. package/server/routes/project-meta.js +2 -3
  126. package/server/routes/session-pin.js +7 -8
  127. package/server/routes/workspaces.js +44 -9
  128. package/server/server.js +59 -66
  129. package/server/workspace-registry.js +24 -4
  130. package/dist/assets/App-BjHJ9KJ6.css +0 -1
  131. package/dist/assets/App-CCmR0aki.js +0 -2
  132. package/dist/assets/Mobile-DCHg97f1.js +0 -1
  133. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  134. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  135. package/dist/assets/clone-md-Mc1-T.js +0 -1
  136. package/dist/assets/index-CfPXhStV.js +0 -2
  137. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  138. package/dist/assets/seqResourceLoaders-CAYxnMSH.js +0 -2
  139. package/server/lib/instance-registry.js +0 -57
  140. package/server/lib/jsonl-archive.js +0 -180
  141. package/server/lib/teammate-detect.js +0 -32
  142. package/server/lib/zip-safety.js +0 -68
@@ -179,21 +179,35 @@ const dingtalkAdapter = {
179
179
 
180
180
  async connect(cfg, hooks) {
181
181
  let client;
182
+ // keepAlive is required for silent-drop detection: without it the SDK never pings, so a
183
+ // network loss with no FIN leaves the socket "open" and `client.connected` true forever.
184
+ // Known SDK quirk: dingtalk-stream arms a new heartbeat interval on every socket 'open'
185
+ // without clearing the previous one, so each reconnect leaks one 8s interval (bounded by
186
+ // reconnect count; disconnect() clears the last). Accepted — detection matters more.
187
+ const opts = { clientId: cfg.appKey, clientSecret: cfg.appSecret, keepAlive: true };
182
188
  if (clientFactory) {
183
- client = clientFactory({ clientId: cfg.appKey, clientSecret: cfg.appSecret });
189
+ client = clientFactory(opts);
184
190
  if (typeof client.registerCallbackListener === 'function') {
185
191
  client.registerCallbackListener('__test__', (res) => hooks.onInbound(normalizeInbound(res), res));
186
192
  }
187
193
  } else {
188
194
  const mod = await import('dingtalk-stream');
189
195
  const { DWClient, TOPIC_ROBOT } = mod;
190
- client = new DWClient({ clientId: cfg.appKey, clientSecret: cfg.appSecret });
196
+ client = new DWClient(opts);
191
197
  client.registerCallbackListener(TOPIC_ROBOT, (res) => hooks.onInbound(normalizeInbound(res), res));
192
198
  }
193
199
  await client.connect?.();
194
200
  return client;
195
201
  },
196
202
 
203
+ // dingtalk-stream emits no client-level lifecycle events (open/close/error live on an internal
204
+ // ws that is replaced on every reconnect), so the core polls this instead. `userDisconnect` is
205
+ // TS-private but a plain runtime field; it flips true only on manual disconnect().
206
+ connectionProbe(client) {
207
+ if (!client || client.userDisconnect) return 'disconnected';
208
+ return client.connected ? 'connected' : 'reconnecting';
209
+ },
210
+
197
211
  async disconnect(client) {
198
212
  try { await client?.disconnect?.(); } catch { /* best-effort */ }
199
213
  },
@@ -80,10 +80,21 @@ const discordAdapter = {
80
80
  });
81
81
  ctx.store.client = client;
82
82
  // Persistent error listener for the life of the client: a Node EventEmitter that emits 'error'
83
- // with NO listener throws and crashes the process. The connect-window once('error') below is
84
- // consumed on resolve, so without this a later gateway 'error' would be fatal. Swallow (the
85
- // gateway auto-reconnects; connection state surfaces via the bridge status).
86
- client.on('error', () => { /* swallowed see comment */ });
83
+ // with NO listener throws and crashes the process. NOTE: the connect-window once('error') below
84
+ // is NOT consumed by a successful ClientReady resolve it survives and must therefore be a
85
+ // settled-guarded no-op post-connect (see below). Record errors as lastError here (state flips
86
+ // come from the shard lifecycle events below; the gateway auto-reconnects).
87
+ client.on('error', (e) => hooks.onConnectionChange?.(null, e));
88
+ // Shard lifecycle → core tri-state. Single unsharded gateway = one shard, so no per-shard map.
89
+ // Events?.X ?? 'literal' keeps test fakes (whose Events map lacks shard constants) working.
90
+ client.on(Events?.ShardReconnecting ?? 'shardReconnecting', () => hooks.onConnectionChange?.('reconnecting'));
91
+ client.on(Events?.ShardResume ?? 'shardResume', () => hooks.onConnectionChange?.('connected'));
92
+ client.on(Events?.ShardReady ?? 'shardReady', () => hooks.onConnectionChange?.('connected'));
93
+ client.on(Events?.ShardDisconnect ?? 'shardDisconnect', (ev) =>
94
+ hooks.onConnectionChange?.('disconnected', new Error(`gateway closed (code ${ev?.code ?? 'unknown'})`)));
95
+ client.on(Events?.Invalidated ?? 'invalidated', () =>
96
+ hooks.onConnectionChange?.('disconnected', new Error('session invalidated')));
97
+ client.on(Events?.ShardError ?? 'shardError', (e) => hooks.onConnectionChange?.(null, e));
87
98
  client.on(Events.MessageCreate, (message) => {
88
99
  // Loop-guard: ignore the bot's own messages (Discord redelivers our send as a new event) and
89
100
  // all other bots. author.bot covers self; the id check is belt-and-suspenders.
@@ -98,8 +109,19 @@ const discordAdapter = {
98
109
  const timer = setTimeout(() => { try { client.destroy(); } catch { /* best-effort */ } finish(reject, new Error('connect timeout')); }, CONNECT_PROBE_MS);
99
110
  if (typeof timer.unref === 'function') timer.unref();
100
111
  client.once(Events.ClientReady, () => finish(resolve, client));
101
- client.once('error', (e) => { try { client.destroy(); } catch { /* best-effort */ } finish(reject, new Error(String(e?.message || e))); });
102
- client.login(cfg.botToken).catch((e) => { try { client.destroy(); } catch { /* best-effort */ } finish(reject, new Error(String(e?.message || e))); });
112
+ // Guard the destroy behind `settled`: this once-listener is NOT consumed by ClientReady, so
113
+ // an unguarded destroy would kill the LIVE client on the first post-connect 'error' event
114
+ // (freezing the bridge with no reconnect — the exact stale-status bug this adapter fixes).
115
+ client.once('error', (e) => {
116
+ if (settled) return; // post-connect errors are handled by the persistent listener above
117
+ try { client.destroy(); } catch { /* best-effort */ }
118
+ finish(reject, new Error(String(e?.message || e)));
119
+ });
120
+ client.login(cfg.botToken).catch((e) => {
121
+ if (settled) return;
122
+ try { client.destroy(); } catch { /* best-effort */ }
123
+ finish(reject, new Error(String(e?.message || e)));
124
+ });
103
125
  });
104
126
  },
105
127
 
@@ -18,6 +18,10 @@ import { registerAdapter } from '../im-bridge-core.js';
18
18
  let sdkFactory = null;
19
19
  export function __setClientFactory(fn) { sdkFactory = fn; }
20
20
 
21
+ // Internal connect guard (< core CONNECT_TIMEOUT_MS): bounds the onReady wait on hook-capable SDK
22
+ // builds so a misconfigured app fails with lastError instead of leaking a background retry loop.
23
+ const CONNECT_PROBE_MS = 12_000;
24
+
21
25
  const TOKEN_PATH = '/open-apis/auth/v3/tenant_access_token/internal';
22
26
  function tokenHost(region) {
23
27
  return region === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
@@ -90,17 +94,59 @@ const feishuAdapter = {
90
94
  const base = { appId: cfg.appId, appSecret: cfg.appSecret, domain };
91
95
  // Outbound client (auto-manages the tenant_access_token cache).
92
96
  ctx.store.sendClient = new Lark.Client(base);
93
- // Inbound long-connection event client. wsConfig is REQUIRED — omitting PingInterval/PingTimeout
94
- // makes start() throw or hang on some SDK builds.
97
+ let settled = false;
98
+ let resolveReady, rejectReady;
99
+ const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
100
+ ready.catch(() => {}); // detached guard: a pre-await rejection must not raise unhandledRejection
101
+ // Inbound long-connection event client. wsConfig keys per SDK build: 1.66.0 reads ONLY the
102
+ // lowercase `pingTimeout` (seconds) — it arms the pong/liveness watchdog that terminates a
103
+ // zombie socket after a SILENT network drop (NAT idle, sleep, no FIN) and triggers reconnect;
104
+ // without it such drops are never detected. The capitalized PingInterval/PingTimeout keys are
105
+ // kept for older builds that read them (1.66.0 ignores unknown keys, so both are safe).
95
106
  const ws = new Lark.WSClient({
96
107
  ...base,
97
108
  loggerLevel: Lark.LoggerLevel ? Lark.LoggerLevel.warn : undefined, // warn: avoid noisy info-level stdout
98
- wsConfig: { PingInterval: 30, PingTimeout: 5 },
109
+ wsConfig: { PingInterval: 30, PingTimeout: 5, pingTimeout: 5 },
110
+ // Lifecycle hooks ship with newer SDK builds (present in the installed 1.66.0); an older
111
+ // build silently ignores unknown ctor options, so wiring them is always safe.
112
+ onReady: () => {
113
+ if (settled) { hooks.onConnectionChange?.('connected'); return; }
114
+ settled = true; resolveReady();
115
+ },
116
+ onReconnecting: () => hooks.onConnectionChange?.('reconnecting'),
117
+ onReconnected: () => hooks.onConnectionChange?.('connected'),
118
+ onError: (err) => {
119
+ if (settled) { hooks.onConnectionChange?.('disconnected', err); return; }
120
+ settled = true; rejectReady(err instanceof Error ? err : new Error(String(err?.message || err)));
121
+ },
99
122
  });
100
123
  const dispatcher = new Lark.EventDispatcher({}).register({
101
124
  'im.message.receive_v1': async (data) => hooks.onInbound(normalizeInbound(data), null),
102
125
  });
103
- await ws.start({ eventDispatcher: dispatcher });
126
+ if (typeof ws.getConnectionStatus === 'function') {
127
+ // Hook-capable build (feature-detected): start() resolves before the handshake completes,
128
+ // so gate on onReady/onError; bound with an internal timeout below the core's connect race.
129
+ const timer = setTimeout(() => {
130
+ if (settled) return;
131
+ settled = true;
132
+ rejectReady(new Error('connect timeout'));
133
+ }, CONNECT_PROBE_MS);
134
+ if (typeof timer.unref === 'function') timer.unref();
135
+ try {
136
+ await ws.start({ eventDispatcher: dispatcher });
137
+ await ready;
138
+ } catch (e) {
139
+ // Stop the SDK's background retry loop so a failed connect doesn't leak.
140
+ try { await ws.close?.({}); } catch { /* best-effort */ }
141
+ throw e;
142
+ } finally {
143
+ clearTimeout(timer);
144
+ }
145
+ } else {
146
+ // Older SDK (and legacy test fakes): original behavior — start() is trusted, no
147
+ // lifecycle hooks fire, connection state stays start/stop-driven.
148
+ await ws.start({ eventDispatcher: dispatcher });
149
+ }
104
150
  return ws;
105
151
  },
106
152
 
@@ -75,6 +75,30 @@ const wecomAdapter = {
75
75
  const client = new WSClient({ botId: cfg.botId, secret: cfg.secret });
76
76
  ctx.store.client = client; // single client for inbound + outbound (sendOne reads it)
77
77
  client.on('message.text', (frame) => hooks.onInbound(normalizeInbound(frame), null));
78
+ // Persistent lifecycle listeners → core tri-state. Registered up front (pre-connect emits are
79
+ // ignored by the core while not running); removed by disconnect()'s removeAllListeners.
80
+ // NOTE: one connection per bot — running testConnection against the SAME bot kicks the live
81
+ // bridge via disconnected_event, which the SDK treats as TERMINAL (isManualClose, no
82
+ // reconnect). The status will correctly show Disconnected until the bridge is restarted;
83
+ // auto-restart-after-test is a deliberate non-goal for now (backlog).
84
+ let kicked = false;
85
+ client.on('event.disconnected_event', () => {
86
+ // Server kicked this connection (a new one took over): the SDK sets isManualClose and will
87
+ // NOT reconnect, so this is terminal; the 'disconnected' emit that follows must not
88
+ // downgrade it to reconnecting.
89
+ kicked = true;
90
+ hooks.onConnectionChange?.('disconnected', new Error('kicked: new connection established'));
91
+ });
92
+ client.on('disconnected', (reason) => {
93
+ if (!kicked) hooks.onConnectionChange?.('reconnecting', reason ? new Error(String(reason)) : null);
94
+ });
95
+ client.on('reconnecting', () => hooks.onConnectionChange?.('reconnecting'));
96
+ client.on('authenticated', () => { kicked = false; hooks.onConnectionChange?.('connected'); });
97
+ client.on('error', (e) => {
98
+ const terminal = e?.name === 'WSReconnectExhaustedError' || e?.name === 'WSAuthFailureError'
99
+ || e?.code === 'WS_RECONNECT_EXHAUSTED';
100
+ hooks.onConnectionChange?.(terminal ? 'disconnected' : null, e);
101
+ });
78
102
  // connect() is synchronous; resolve once the WS handshake authenticates so the core's
79
103
  // `connected` flag and CONNECT_TIMEOUT_MS race are meaningful (bad creds → lastError, not a
80
104
  // false "connected"). Reject (and tear down) on error or an internal timeout.
@@ -36,8 +36,19 @@ export class AsyncWriteQueue {
36
36
  get pendingBytes() { return this._pendingBytes; }
37
37
 
38
38
  append(data, onDone) {
39
+ return this.appendTo(this._getPath(), data, onDone);
40
+ }
41
+
42
+ /**
43
+ * Explicit-path variant (wire-v2 S2): same queue, same ordering/drain
44
+ * machinery, caller supplies the target file. Within one drain batch, path
45
+ * groups are written in first-enqueue order, so a caller enqueuing
46
+ * conversation lines before the journal line keeps that relative order on
47
+ * disk (WIRE_FORMAT_V2.md §1.3 write-order protocol). Empty/falsy path stays
48
+ * a silent no-op — workspace mode and leaderless teammates rely on it.
49
+ */
50
+ appendTo(path, data, onDone) {
39
51
  if (this._closed) return;
40
- const path = this._getPath();
41
52
  if (!path) {
42
53
  if (onDone) try { onDone(); } catch {}
43
54
  return;
@@ -7,7 +7,7 @@
7
7
  // node server/lib/create_system_prompt.js deepseek-v4-pro # a named preset
8
8
  // node server/lib/create_system_prompt.js --list # list presets
9
9
 
10
- import { execFileSync } from 'node:child_process'
10
+ import { spawnSync } from 'node:child_process'
11
11
  import { readFileSync, statSync } from 'node:fs'
12
12
  import os from 'node:os'
13
13
  import { delimiter, join, sep } from 'node:path'
@@ -54,13 +54,16 @@ function envString(name) {
54
54
  }
55
55
 
56
56
  function commandOutput(command, args, cwd) {
57
- return stringOrEmpty(() =>
58
- execFileSync(command, args, {
57
+ return stringOrEmpty(() => {
58
+ const result = spawnSync(command, args, {
59
59
  cwd,
60
60
  encoding: 'utf8',
61
61
  stdio: ['ignore', 'pipe', 'ignore'],
62
- }).trim(),
63
- )
62
+ timeout: 15000, // 15s hard cap: hanging git (NFS / huge repo / broken index) must not block spawn
63
+ })
64
+ if (result.error || result.status !== 0) return ''
65
+ return result.stdout.trim()
66
+ })
64
67
  }
65
68
 
66
69
  function firstNonEmpty(...values) {
@@ -130,8 +133,11 @@ function resolveMemory(home, cwd) {
130
133
  return { dir, index, enabled: enabled ? 'true' : 'false' }
131
134
  }
132
135
 
133
- export function createSystemPromptVariables(overrides = {}) {
134
- const cwd = stringOrEmpty(() => process.cwd())
136
+ export function createSystemPromptVariables(overrides = {}, opts = {}) {
137
+ // opts.cwd: resolve cwd-dependent variables (environment.cwd, git.*, memory.dir) against a
138
+ // caller-supplied directory instead of process.cwd() — the spawn-time renderer passes the
139
+ // workspace being launched, which is not necessarily where the ccv server itself runs.
140
+ const cwd = (typeof opts.cwd === 'string' && opts.cwd) ? opts.cwd : stringOrEmpty(() => process.cwd())
135
141
  const now = new Date()
136
142
  const timeZone = stringOrEmpty(
137
143
  () => Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -88,7 +88,7 @@ function _seqGuardCheck(entry, st) {
88
88
  /**
89
89
  * 标记乱序条目并尽量就地补偿:accumulated 已包含更新的真值时,用其前缀
90
90
  * (= 截至该条目声称长度的最新内容)回填 body.messages,避免裸 delta 切片
91
- * 残留在请求详情面板 / mergeLogFiles 落盘产物中。
91
+ * 残留在请求详情面板中。
92
92
  * 返回 true 表示已补偿(messages 为一致全量),false 表示需后续 checkpoint 补偿。
93
93
  */
94
94
  function _markStaleEntry(entry, accumulated) {
@@ -142,7 +142,7 @@ function _integrityCheck(entry, accumulated, st) {
142
142
  * 2. 真值前缀 —— stale 就地补偿、超长 slice 修复(_staleReorder 置位,merge 跳过);
143
143
  * 3. 裸 delta 切片待补偿 —— stale 就地补偿失败 / poisoned 冻结(_staleReorder 或
144
144
  * _reconstructBroken 置位):由第二遍 checkpoint 补偿回填,无第二遍的路径靠
145
- * isMergeBlockedEntry 阻断 merge、mergeLogFiles 丢弃兜底。
145
+ * isMergeBlockedEntry 阻断 merge 兜底。
146
146
  *
147
147
  * @param {object} entry - 单条日志条目(原地修改)
148
148
  * @param {{accumulated: Array, intState: {baselineSeen: boolean, poisoned: boolean},
@@ -239,7 +239,7 @@ export function reconstructEntries(entries) {
239
239
 
240
240
  /**
241
241
  * 用候选全量条目(checkpoint/旧格式,排除 teammate)补偿一条断裂条目。
242
- * 内容已是真值前缀,清除标记让批量 merge / mergeLogFiles 正常消费。
242
+ * 内容已是真值前缀,清除标记让批量 merge 正常消费。
243
243
  * @returns {boolean} true = 已补偿
244
244
  */
245
245
  function _tryRepairFromCandidate(brokenEntry, expectedCount, candidate) {
@@ -0,0 +1,45 @@
1
+ // CLIENT-SAFE: no node deps. Imported by src/ — do not add fs/process/node: imports.
2
+ /**
3
+ * Error-reporting convention for swallowed catch blocks.
4
+ *
5
+ * Canonical home since wire-v2 S2 (moved verbatim from src/utils/errorReport.js,
6
+ * which remains as a re-export shell): server/lib/v2/* writers follow the same
7
+ * convention, and src/ is not shipped in the npm package.
8
+ *
9
+ * The rule (see CLAUDE.md): a `catch` whose failure has diagnostic value —
10
+ * anything where a throw means lost data or broken UI (SSE/WS event parsing,
11
+ * entry ingest, state updates, fetch response handling) — must call
12
+ * `reportSwallowed(tag, err)` instead of swallowing silently. Purely-benign
13
+ * best-effort guards (localStorage get/set, tabBridge IPC, revokeObjectURL,
14
+ * listener-cleanup `off()`) stay bare `catch {}`.
15
+ *
16
+ * Tag grammar: `<channel>.<event-name-verbatim>`, e.g. `sse.load_chunk`,
17
+ * `sse.stream-progress`, `ws.terminal-msg`. Keep the event name exactly as it
18
+ * appears in the code/protocol so a grep for the tag round-trips to the site.
19
+ * All reports share the greppable `[ccv:` console prefix.
20
+ *
21
+ * Dedup: identical failures usually repeat (e.g. every SSE event of a changed
22
+ * payload shape fails the same way), so each tag logs at most
23
+ * MAX_REPORTS_PER_TAG occurrences per session, then one suppression notice,
24
+ * then goes silent. Counter-based on purpose — no Date/timers, so this module
25
+ * is statically loadable and deterministic under `node --test`.
26
+ */
27
+ export const MAX_REPORTS_PER_TAG = 5;
28
+
29
+ const _counts = new Map();
30
+
31
+ export function reportSwallowed(tag, err, extra) {
32
+ const n = (_counts.get(tag) || 0) + 1;
33
+ _counts.set(tag, n);
34
+ if (n > MAX_REPORTS_PER_TAG + 1) return;
35
+ if (n === MAX_REPORTS_PER_TAG + 1) {
36
+ console.warn(`[ccv:${tag}] further occurrences suppressed`);
37
+ return;
38
+ }
39
+ if (extra !== undefined) console.warn(`[ccv:${tag}]`, err, extra);
40
+ else console.warn(`[ccv:${tag}]`, err);
41
+ }
42
+
43
+ export function _resetForTest() {
44
+ _counts.clear();
45
+ }
@@ -43,6 +43,10 @@ const STREAM_MIN_DELTA = 20; // 累计增长达 20 字才推一帧
43
43
  const STREAM_MAX_FRAMES = 25; // 每回合中途流式帧硬上限;超限停推,finalize 仍落全文
44
44
  const STREAM_MAX_CHARS = 20_000; // 卡片内容字符上限,超出截断
45
45
 
46
+ // Poll interval for adapters that expose connectionProbe() instead of lifecycle events
47
+ // (dingtalk-stream replaces its internal ws on every reconnect, so no listener survives).
48
+ let CONN_POLL_MS = 5_000;
49
+
46
50
  // ─── registry + core-global single-flight ───
47
51
  const instances = new Map(); // platformId → instance
48
52
  let activeInjection = null; // { platformId, since, target } — the one in-flight turn
@@ -61,6 +65,9 @@ function newInstance(adapter) {
61
65
  client: null,
62
66
  running: false,
63
67
  connected: false,
68
+ connectionState: 'disconnected', // 'connected' | 'reconnecting' | 'disconnected'; invariant: connected === (connectionState === 'connected')
69
+ startGen: 0, // generation counter; stale adapter listeners from a previous start are ignored
70
+ connPollTimer: null, // fallback poll timer for adapters exposing connectionProbe()
64
71
  lastError: null,
65
72
  bridgeDeps: null,
66
73
  boundConversation: null,
@@ -88,9 +95,15 @@ function newInstance(adapter) {
88
95
  * @property {Object} [capabilities] Informational flags ({inboundAck, sdkManagesToken}); not read by the core.
89
96
  * @property {(cfg:Object)=>boolean} hasCreds True when cfg carries enough creds to connect.
90
97
  * @property {(cfg:Object)=>Object} [statusFields] Extra non-secret status fields for the admin API.
91
- * @property {(cfg:Object, hooks:{onInbound:(normalized:Object, ackCtx:*)=>void}, ctx:{fetch,store})=>Promise<*>} connect
98
+ * @property {(cfg:Object, hooks:{onInbound:(normalized:Object, ackCtx:*)=>void, onConnectionChange?:(state:?string, err?:*)=>void}, ctx:{fetch,store})=>Promise<*>} connect
92
99
  * Open the long connection; return the live client. Call hooks.onInbound(normalized, ackCtx)
93
100
  * per inbound message, where normalized = {text, conversationId, senderId, msgId, target}.
101
+ * Optionally call hooks.onConnectionChange?.(state, err) on transport lifecycle changes,
102
+ * where state ∈ 'connected'|'reconnecting'|'disconnected', or null to record err only.
103
+ * Always invoke it with optional chaining — callers (tests) may pass onInbound alone.
104
+ * @property {(client:*)=>('connected'|'reconnecting'|'disconnected')} [connectionProbe]
105
+ * For SDKs without usable lifecycle events: derive the live connection state from the
106
+ * client's fields. When present, the core polls it every CONN_POLL_MS while running.
94
107
  * @property {(client:*, ctx:{fetch,store})=>Promise<void>} [disconnect] Tear down the client (best-effort).
95
108
  * @property {(ackCtx:*, client:*)=>void} [ack] Ack an inbound msg (platforms that redeliver if not acked).
96
109
  * @property {(cfg:Object, target:Object, content:string, ctx:{fetch,store})=>Promise<void>} sendOne Send one chunk.
@@ -320,6 +333,26 @@ function armActiveInjection(inst, target, since) {
320
333
  }
321
334
  }
322
335
 
336
+ /**
337
+ * Transition the tri-state connection status. `gen` binds the caller to one startBridge lifetime,
338
+ * so a stale adapter listener firing after stopBridge/reloadBridge can never flip the state.
339
+ * state === null records the error only; identical consecutive states are no-ops (collapses
340
+ * SDK error/reconnect storms into single audited transitions).
341
+ */
342
+ function setConnectionState(inst, gen, state, err) {
343
+ if (gen !== inst.startGen || !inst.running) return;
344
+ const errStr = err != null ? String(err?.message || err) : null;
345
+ if (errStr) inst.lastError = errStr;
346
+ if (state == null || state === inst.connectionState) return;
347
+ inst.connectionState = state;
348
+ inst.connected = state === 'connected';
349
+ // Recovery clears the retained disconnect cause — the UI ranks lastError above connected, so
350
+ // keeping it would leave the status stuck on "Error" after every transient drop. Deduped
351
+ // repeats and null-state calls don't clear, preserving send-failure diagnostics while healthy.
352
+ if (state === 'connected' && !errStr) inst.lastError = null;
353
+ audit(inst, 'connection', { state, ...(errStr ? { error: errStr } : {}) });
354
+ }
355
+
323
356
  // ─── small helpers ───
324
357
  function audit(inst, event, data) {
325
358
  try {
@@ -711,21 +744,45 @@ export async function startBridge(id, deps) {
711
744
  if (!inst.bridgeDeps || typeof inst.bridgeDeps.getConfig !== 'function') { audit(inst, 'start-skipped', { reason: 'no-deps' }); return; }
712
745
  const cfg = inst.bridgeDeps.getConfig();
713
746
  if (!cfg || !cfg.enabled || !inst.adapter.hasCreds(cfg)) return; // off / incomplete → no-op
747
+ // Bump AFTER the early-return guards: startBridge on an already-running instance is a legal
748
+ // no-op, and bumping there would orphan the live adapter's listeners (state frozen forever).
749
+ const gen = ++inst.startGen;
714
750
  try {
715
- const hooks = { onInbound: (normalized, ackCtx) => handleInbound(inst, normalized, ackCtx) };
751
+ const hooks = {
752
+ onInbound: (normalized, ackCtx) => handleInbound(inst, normalized, ackCtx),
753
+ onConnectionChange: (state, err) => setConnectionState(inst, gen, state, err),
754
+ };
716
755
  // Bound the connect so a hung adapter (e.g. Feishu WSClient.start() on a misconfigured app)
717
756
  // becomes a lastError instead of blocking the whole startup chain.
718
- inst.client = await Promise.race([
757
+ const client = await Promise.race([
719
758
  inst.adapter.connect(cfg, hooks, ctxFor(inst)),
720
759
  new Promise((_, reject) => { const tm = setTimeout(() => reject(new Error('connect timeout')), CONNECT_TIMEOUT_MS); if (typeof tm.unref === 'function') tm.unref(); }),
721
760
  ]);
761
+ if (gen !== inst.startGen) {
762
+ // stopBridge / reload ran while the connect was in flight: this start lost the race. Don't
763
+ // resurrect the instance — tear down the late-arriving client so it doesn't leak.
764
+ try { await inst.adapter.disconnect?.(client, ctxFor(inst)); } catch { /* best-effort */ }
765
+ audit(inst, 'start-superseded', {});
766
+ return;
767
+ }
768
+ inst.client = client;
722
769
  inst.running = true;
723
770
  inst.connected = true;
771
+ inst.connectionState = 'connected';
724
772
  inst.lastError = null;
773
+ if (typeof inst.adapter.connectionProbe === 'function') {
774
+ inst.connPollTimer = setInterval(() => {
775
+ try { setConnectionState(inst, gen, inst.adapter.connectionProbe(inst.client), null); }
776
+ catch { /* best-effort: a probe throw must not kill the interval */ }
777
+ }, CONN_POLL_MS);
778
+ if (typeof inst.connPollTimer.unref === 'function') inst.connPollTimer.unref();
779
+ }
725
780
  audit(inst, 'start', inst.adapter.statusFields ? inst.adapter.statusFields(cfg) : {});
726
781
  } catch (e) {
782
+ if (gen !== inst.startGen) return; // superseded by stop/reload — don't overwrite their state
727
783
  inst.lastError = String(e?.message || e);
728
784
  inst.connected = false;
785
+ inst.connectionState = 'disconnected';
729
786
  audit(inst, 'start-error', { error: inst.lastError });
730
787
  }
731
788
  }
@@ -733,6 +790,9 @@ export async function startBridge(id, deps) {
733
790
  export async function stopBridge(id) {
734
791
  const inst = instances.get(id);
735
792
  if (!inst) return;
793
+ // Invalidate connection hooks up front so anything the adapter teardown fires can't flip state.
794
+ inst.startGen++;
795
+ if (inst.connPollTimer) { clearInterval(inst.connPollTimer); inst.connPollTimer = null; }
736
796
  if (inst.ackCardPromise && activeInjection?.platformId === id) {
737
797
  await finalizeAckCard(inst, activeInjection.target, tr(inst, 'noSession'), 'error');
738
798
  } else {
@@ -743,6 +803,7 @@ export async function stopBridge(id) {
743
803
  inst.client = null;
744
804
  inst.running = false;
745
805
  inst.connected = false;
806
+ inst.connectionState = 'disconnected';
746
807
  inst.boundConversation = null;
747
808
  if (activeInjection && activeInjection.platformId === id) clearActiveInjection();
748
809
  inst.queue.length = 0;
@@ -760,10 +821,11 @@ export function isBridgeRunning(id) {
760
821
 
761
822
  export function getBridgeStatus(id) {
762
823
  const inst = instances.get(id);
763
- if (!inst) return { running: false, connected: false, lastError: null, boundConversationId: null };
824
+ if (!inst) return { running: false, connected: false, connectionState: 'disconnected', lastError: null, boundConversationId: null };
764
825
  const base = {
765
826
  running: inst.running,
766
827
  connected: inst.connected,
828
+ connectionState: inst.connectionState,
767
829
  lastError: inst.lastError,
768
830
  boundConversationId: inst.boundConversation?.conversationId || null,
769
831
  };
@@ -799,6 +861,8 @@ export async function stopAll() {
799
861
  // ─── test seams ───
800
862
  export function __setStreamTickMsForTests(ms) { STREAM_TICK_MS = (ms == null ? 300 : ms); }
801
863
 
864
+ export function __setConnPollMsForTests(ms) { CONN_POLL_MS = (ms == null ? 5_000 : ms); }
865
+
802
866
  export function __setMaxQueueForTests(id, n) {
803
867
  const inst = instances.get(id);
804
868
  if (inst) inst.maxQueueOverride = n;
@@ -809,6 +873,9 @@ export function __resetForTests(id) {
809
873
  const inst = instances.get(id);
810
874
  if (!inst) return;
811
875
  inst.client = null; inst.running = false; inst.connected = false; inst.lastError = null;
876
+ inst.connectionState = 'disconnected';
877
+ inst.startGen++; // orphan any listeners still bound to a previous start
878
+ if (inst.connPollTimer) { clearInterval(inst.connPollTimer); inst.connPollTimer = null; }
812
879
  inst.bridgeDeps = null; inst.boundConversation = null; inst.lastRepliedTurnTs = null;
813
880
  inst.maxQueueOverride = null;
814
881
  inst.seenMsgIds.length = 0; inst.queue.length = 0; inst.sendTimes.length = 0;
@@ -135,7 +135,15 @@ export function defaultProbe(id, port, { timeoutMs = 400 } = {}) {
135
135
  const j = JSON.parse(body);
136
136
  // 身份:能对「该 id」返回 IM status 形状的 loopback 服务即视为我们的 worker
137
137
  if (j && (j.connection || typeof j.enabled === 'boolean')) {
138
- finish({ ok: true, connected: !!(j.connection && j.connection.connected), pid: j.pid });
138
+ finish({
139
+ ok: true,
140
+ connected: !!(j.connection && j.connection.connected),
141
+ // Old workers (pre-tri-state builds) omit connectionState → derive from connected.
142
+ connectionState: j.connection?.connectionState
143
+ ?? (j.connection?.connected ? 'connected' : 'disconnected'),
144
+ lastError: j.connection?.lastError ?? null,
145
+ pid: j.pid,
146
+ });
139
147
  } else finish(null);
140
148
  } catch { finish(null); }
141
149
  });
@@ -178,7 +186,13 @@ export async function getImLiveness(id, opts = {}) {
178
186
 
179
187
  const res = await probe(id, lock.port);
180
188
  if (res && res.ok && (res.pid == null || res.pid === lock.pid)) {
181
- return { state: 'ready', lock, connected: !!res.connected };
189
+ return {
190
+ state: 'ready',
191
+ lock,
192
+ connected: !!res.connected,
193
+ connectionState: res.connectionState ?? (res.connected ? 'connected' : 'disconnected'),
194
+ lastError: res.lastError ?? null,
195
+ };
182
196
  }
183
197
  return { state: pidAlive(lock.pid) ? 'hung' : 'dead', lock };
184
198
  }