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
@@ -0,0 +1,233 @@
1
+ // Wire Format v2 — S4 dual-write consistency verifier (plan gate methodology).
2
+ //
3
+ // Compares one v1 JSONL file against the v2 session dirs of the same project,
4
+ // per request, on WIRE TRUTH:
5
+ // ① per-request wire-messages digest: v1 incremental delta reconstruction vs
6
+ // v2 mechanical replay (this is trajectory-grade — every intermediate
7
+ // state is compared, not just the cold final one);
8
+ // ② per-request tools/system content hash: v1 inline value hashed with the
9
+ // BlobStore formula vs the v2 journal's CAS ref (byte-equality by CAS);
10
+ // ③ teammate entries verified the same per-request way (v1's leader file
11
+ // holds them inline; v2 holds them in the teammate's own session dir —
12
+ // matching is by `${ts}|${url}`, so no re-join ordering is needed here);
13
+ // ④ inflight/error accounting: v1 keys that never completed vs v2
14
+ // req-without-done;
15
+ // ⑤ journal msgTo vs replayed length integrity (internal v2 consistency).
16
+ //
17
+ // v1-only requests are counted, not failed: history from before dual-write was
18
+ // enabled (or while it was toggled off) legitimately has no v2 twin.
19
+ //
20
+ // KNOWN LIMITATION (review): the ① digest is built on the client-shared
21
+ // messageFingerprint, which keys tool_result blocks on tool_use_id ONLY — a
22
+ // divergence confined to tool_result BODY content between v1 and v2 collides
23
+ // to identical digests and is invisible to this gate. The write side now uses
24
+ // the content-aware fingerprintMsg for its judgement (conversation-store.js),
25
+ // which removes the known producer of that divergence class; the residual risk
26
+ // is a v2 bug that corrupts tool_result content while preserving ids.
27
+
28
+ import { statSync } from 'node:fs';
29
+ import { dirname } from 'node:path';
30
+ import { iterateRawEntriesAsync } from '../log-stream.js';
31
+ import { createIncrementalReconstructor } from '../delta-reconstructor.js';
32
+ import { listSessionIds, readSession, indexSession, messagesDigest, blobRefOf } from './replay.js';
33
+
34
+ const MAX_REPORTED_DIFFS = 50;
35
+
36
+ /**
37
+ * Verify one v1 file against its project's v2 sessions.
38
+ * @param {string} v1File - absolute path to a v1 .jsonl (its dirname is the project dir)
39
+ * @param {object} [opts]
40
+ * @param {string} [opts.projectDir] - override project dir (defaults to dirname(v1File))
41
+ * @param {string} [opts.sessionsDirName] - session-dir name under the project
42
+ * dir; the offline converter verifies its staging area ('sessions-migrating')
43
+ * before promoting. Defaults to the live 'sessions'.
44
+ * @param {(p: {entriesScanned: number}) => void} [opts.onProgress] - called
45
+ * every 1000 scanned v1 entries and once after the scan completes, so a
46
+ * long-running verify of one big file can surface live progress. Callback
47
+ * errors are swallowed — progress must never break the verification.
48
+ * @returns {Promise<object>} report (see shape at the bottom)
49
+ */
50
+ export async function verifyV1File(v1File, opts = {}) {
51
+ const projectDir = opts.projectDir || dirname(v1File);
52
+ const sessionsDirName = opts.sessionsDirName || 'sessions';
53
+ const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : null;
54
+
55
+ // ---- v2 side: index every session of the project --------------------------
56
+ // `${ts}|${url}` is NOT unique in real logs (same-millisecond countTokens
57
+ // bursts, §3.6 rotation twins) — keep ALL records per key and match each v1
58
+ // entry to its best unmatched candidate below (real-data finding 2026-07-14).
59
+ const v2Index = new Map(); // key → rec[]
60
+ const integrity = [];
61
+ const v2Sessions = [];
62
+ const unsupportedSessions = []; // reader version gate (spec §14): a session
63
+ // this build can't read means the gate has a coverage hole — FAIL, don't skip.
64
+ for (const sid of listSessionIds(projectDir, sessionsDirName)) {
65
+ const session = readSession(projectDir, sid, sessionsDirName);
66
+ // Report the session IDENTITY (UUID from meta), not the dir name — the dir
67
+ // is `<ts>_<uuid>` post task C; falls back to the dir name on torn meta.
68
+ v2Sessions.push((session.meta && session.meta.sessionId) || sid);
69
+ if (session.unsupported) {
70
+ unsupportedSessions.push({ sessionId: sid, wireFormat: session.wireFormat });
71
+ continue;
72
+ }
73
+ const { index, integrity: viol } = indexSession(session);
74
+ integrity.push(...viol);
75
+ for (const [key, recs] of index) {
76
+ const arr = v2Index.get(key);
77
+ if (arr) arr.push(...recs); else v2Index.set(key, [...recs]);
78
+ }
79
+ }
80
+
81
+ // ---- v1 side: stream, reconstruct incrementally, compare ------------------
82
+ const reconstructor = createIncrementalReconstructor();
83
+ const diffs = [];
84
+ const inflightV1 = new Set(); // keys seen only as inProgress
85
+ const counters = {
86
+ v1Completed: 0, v1Skipped: 0, matched: 0, v1Only: 0, v1DuplicateKey: 0,
87
+ heartbeatsV1: 0, countTokensV1: 0, teammateV1: 0, staleOrBroken: 0,
88
+ };
89
+ // Per-session suspicion tally, keyed by the v2 session dir name (the same
90
+ // value `listSessionIds` / the converter's promote loop iterate). UNCAPPED —
91
+ // unlike `diffs` (bounded by MAX_REPORTED_DIFFS for memory/reporting), this
92
+ // must hold EVERY suspect session so the converter can quarantine the full
93
+ // set; capping it would silently promote corrupt sessions past diff #50.
94
+ const suspectSessions = new Map(); // sessionId → { count, reasons: string[] }
95
+ const MAX_REASONS = 5;
96
+ const flagSession = (sessionId, reason) => {
97
+ if (!sessionId) return;
98
+ let rec = suspectSessions.get(sessionId);
99
+ if (!rec) suspectSessions.set(sessionId, rec = { count: 0, reasons: [] });
100
+ rec.count++;
101
+ if (rec.reasons.length < MAX_REASONS) rec.reasons.push(reason);
102
+ };
103
+ const addDiff = (d) => {
104
+ flagSession(d.sessionId, `${d.type}@seq${d.seq}`);
105
+ if (diffs.length < MAX_REPORTED_DIFFS) diffs.push(d);
106
+ };
107
+
108
+ const onScan = (raw) => {
109
+ let entry;
110
+ try { entry = JSON.parse(raw); } catch { return; }
111
+ if (entry.ccvRotationContext) return;
112
+ const key = `${entry.timestamp}|${entry.url}`;
113
+ if (entry.inProgress) { inflightV1.add(key); return; }
114
+ inflightV1.delete(key);
115
+
116
+ // Incremental reconstruction restores the full wire messages for delta
117
+ // entries (teammate/sub entries pass through untouched, already full).
118
+ reconstructor.reconstruct(entry);
119
+ if (entry._staleReorder || entry._reconstructBroken) { counters.staleOrBroken++; return; }
120
+ counters.v1Completed++;
121
+ if (entry.isHeartbeat) counters.heartbeatsV1++;
122
+ if (entry.isCountTokens) counters.countTokensV1++;
123
+ if (entry.teammate) counters.teammateV1++;
124
+
125
+ const candidates = v2Index.get(key);
126
+ if (!candidates) { counters.v1Only++; return; }
127
+ const v1Digest = entry.body && Array.isArray(entry.body.messages) ? messagesDigest(entry.body.messages) : null;
128
+ const unmatched = candidates.filter(c => !c._matched);
129
+ if (unmatched.length === 0) {
130
+ // Every v2 twin already consumed: this v1 line is a duplicate completed
131
+ // write of the same request (§3.6 rotation race) — count, don't fail.
132
+ counters.v1DuplicateKey++;
133
+ return;
134
+ }
135
+ // Best candidate: digest-equal first (same-key bursts carry different
136
+ // payloads — pairing by content avoids cross-matching twins), else FIFO.
137
+ const v2 = (v1Digest && unmatched.find(c => c.digest === v1Digest)) || unmatched[0];
138
+ counters.matched++;
139
+ v2._matched = true;
140
+
141
+ // ① wire-messages digest (skip heartbeats — no conversation on either side)
142
+ if (v2.conv && v1Digest != null) {
143
+ if (v1Digest !== v2.digest) {
144
+ addDiff({ type: 'messages-digest', sessionId: v2.sessionId, key, seq: v2.seq, conv: v2.conv, v1: v1Digest, v2: v2.digest, v1Len: entry.body.messages.length, v2Len: v2.len });
145
+ }
146
+ }
147
+ // ② tools/system CAS equality
148
+ const v1Tools = blobRefOf(entry.body && entry.body.tools);
149
+ const v1Sys = blobRefOf(entry.body && entry.body.system);
150
+ if (v1Tools !== v2.toolsRef) addDiff({ type: 'tools-ref', sessionId: v2.sessionId, key, seq: v2.seq, v1: v1Tools, v2: v2.toolsRef });
151
+ if (v1Sys !== v2.sysRef) addDiff({ type: 'system-ref', sessionId: v2.sessionId, key, seq: v2.seq, v1: v1Sys, v2: v2.sysRef });
152
+ // ③ teammate attribution
153
+ if (!!entry.teammate !== (v2.kind === 'teammate')) {
154
+ addDiff({ type: 'teammate-kind', sessionId: v2.sessionId, key, seq: v2.seq, v1: !!entry.teammate, v2: v2.kind });
155
+ }
156
+ // ④ completion accounting
157
+ if (!v2.done) addDiff({ type: 'missing-done', sessionId: v2.sessionId, key, seq: v2.seq });
158
+ };
159
+
160
+ // Single streaming pass (1MB chunks): verify must stay memory-bounded on
161
+ // 300MB soak files — no dedup map, no second pass (review P2).
162
+ let entriesScanned = 0;
163
+ for await (const raw of iterateRawEntriesAsync(v1File)) {
164
+ onScan(raw);
165
+ entriesScanned++;
166
+ if (onProgress && entriesScanned % 1000 === 0) {
167
+ try { onProgress({ entriesScanned }); } catch { /* progress must never break the verify */ }
168
+ }
169
+ }
170
+ if (onProgress) {
171
+ try { onProgress({ entriesScanned }); } catch { /* progress must never break the verify */ }
172
+ }
173
+
174
+ // v2 records never matched by any v1 entry in THIS file: only suspicious if
175
+ // their req/done both exist (a live v2-only write) — they may simply belong
176
+ // to another v1 segment (rotation), so count, don't fail.
177
+ let v2Unmatched = 0;
178
+ const v2ReqWithoutDone = [];
179
+ for (const [key, recs] of v2Index) {
180
+ for (const rec of recs) {
181
+ if (!rec._matched) v2Unmatched++;
182
+ if (!rec.done) v2ReqWithoutDone.push(key);
183
+ }
184
+ }
185
+
186
+ // Integrity violations and unsupported-wire sessions are collected during the
187
+ // v2-side indexing pass above (before `flagSession` exists), so fold them into
188
+ // the per-session tally here — they condemn a session just as a diff does.
189
+ for (const v of integrity) flagSession(v.sessionId, `integrity@seq${v.seq}`);
190
+ for (const u of unsupportedSessions) flagSession(u.sessionId, `unsupported:${u.wireFormat}`);
191
+
192
+ const fileSize = (() => { try { return statSync(v1File).size; } catch { return 0; } })();
193
+ const report = {
194
+ v1File,
195
+ projectDir,
196
+ fileSize,
197
+ v2Sessions,
198
+ counters: { ...counters, v2Requests: v2Index.size, v2Unmatched, v1InflightOnly: inflightV1.size, v2ReqWithoutDone: v2ReqWithoutDone.length },
199
+ diffs,
200
+ integrity,
201
+ unsupportedSessions,
202
+ // Complete (uncapped) set of suspect sessions for the converter to quarantine.
203
+ suspectSessions: [...suspectSessions.entries()].map(([sessionId, r]) => ({ sessionId, count: r.count, reasons: r.reasons })),
204
+ ok: diffs.length === 0 && integrity.length === 0 && unsupportedSessions.length === 0,
205
+ };
206
+ return report;
207
+ }
208
+
209
+ /** Human-readable one-screen rendering for the CLI. */
210
+ export function renderReport(report) {
211
+ const c = report.counters;
212
+ const lines = [];
213
+ lines.push(`wire-v2 verify — ${report.v1File}`);
214
+ lines.push(` project: ${report.projectDir}`);
215
+ lines.push(` v2 sessions scanned: ${report.v2Sessions.length}, v2 requests indexed: ${c.v2Requests}`);
216
+ lines.push(` v1 completed: ${c.v1Completed} (heartbeat ${c.heartbeatsV1}, countTokens ${c.countTokensV1}, teammate ${c.teammateV1}, stale/broken skipped ${c.staleOrBroken})`);
217
+ lines.push(` matched: ${c.matched} v1-only (no v2 twin — pre-dual-write history): ${c.v1Only} v1-duplicate-key (rotation twins): ${c.v1DuplicateKey || 0} v2-unmatched (other segments): ${c.v2Unmatched}`);
218
+ lines.push(` inflight: v1-only ${c.v1InflightOnly}, v2 req-without-done ${c.v2ReqWithoutDone}`);
219
+ if (report.unsupportedSessions && report.unsupportedSessions.length > 0) {
220
+ lines.push(` UNSUPPORTED WIRE FORMAT (reader version gate): ${report.unsupportedSessions.length}`);
221
+ for (const u of report.unsupportedSessions.slice(0, 10)) lines.push(` ${u.sessionId} wireFormat=${u.wireFormat}`);
222
+ }
223
+ if (report.integrity.length > 0) {
224
+ lines.push(` INTEGRITY VIOLATIONS (journal msgTo vs replay): ${report.integrity.length}`);
225
+ for (const v of report.integrity.slice(0, 10)) lines.push(` ${v.sessionId} seq=${v.seq} conv=${v.conv} msgTo=${v.msgTo} replayed=${v.replayedLen}`);
226
+ }
227
+ if (report.diffs.length > 0) {
228
+ lines.push(` DIFFS: ${report.diffs.length}${report.diffs.length >= 50 ? '+ (capped)' : ''}`);
229
+ for (const d of report.diffs.slice(0, 10)) lines.push(` [${d.type}] seq=${d.seq} ${d.key}\n v1=${JSON.stringify(d.v1)} v2=${JSON.stringify(d.v2)}`);
230
+ }
231
+ lines.push(report.ok ? ' RESULT: OK — zero diffs' : ' RESULT: FAILED');
232
+ return lines.join('\n');
233
+ }
@@ -8,8 +8,9 @@ import { prepareEmbeddedShellSpawn, stripClaudeNoFlickerUnlessOptedIn, applyClau
8
8
  import { killPtyTree } from './lib/term-signals.js';
9
9
  import { findSafeSliceStart, splitTrailingIncomplete } from './lib/ansi-safe-slice.js';
10
10
  import { buildSystemPromptFileArgs } from './lib/system-prompt-files.js';
11
+ import { renderSystemPromptFileArgs } from './lib/system-prompt-render.js';
11
12
  import { MODEL_PROMPT_DIR } from './lib/model-system-prompts.js';
12
- import { readClaudeProjectModel } from './lib/context-watcher.js';
13
+ import { resolveSpawnModel } from './lib/spawn-model-resolver.js';
13
14
 
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = dirname(__filename);
@@ -50,20 +51,31 @@ export function _setPtyImportForTests(fn) {
50
51
  _ptyImportForTests = fn;
51
52
  }
52
53
 
53
- // spawn 时读取「上次启动所用模型 id」供模型定制 system prompt 匹配。
54
- // readClaudeProjectModel 读的是真实 ~/.claude.json(无 NODE_TEST_CONTEXT 屏障),
55
- // 而多数 pty 单测以仓库根为 cwd —— 默认读取器在测试上下文里必须失活,
56
- // 否则开发机的真实模型记录会漏进单测(机器状态依赖)。测试用 _setSpawnModelReaderForTests 显式注入。
57
- // env/reader 参数化只为可测性:单测能直接断言「测试上下文 → null / 生产 → 透传」,
58
- // 而无需真的读 ~/.claude.json(见 test/pty-manager.test.js 的 guard 单测)。
59
- export function _defaultSpawnModelReader(c, env = process.env, reader = readClaudeProjectModel) {
60
- return env.NODE_TEST_CONTEXT ? null : reader(c);
54
+ // spawn 时解析「当前生效配置」下的模型 id 供模型定制 system prompt 匹配(resolveSpawnModel:
55
+ // 激活的三方 proxy profile 模型映射 > env CLAUDE_MODEL/ANTHROPIC_MODEL > settings.json
56
+ // 无实时配置信号 null 不注入模型条目)。旧判据读 ~/.claude.json 的 lastModelUsage
57
+ // ——那是上次会话的使用统计而非配置,陈旧记录会把三方模型的 override 提示词强加给
58
+ // 官方模型会话(review round:deepseek 残留记录事故)。
59
+ // NODE_TEST_CONTEXT 屏障保留:resolveSpawnModel 会读 process.env 的模型变量,开发机
60
+ // shell export 会漏进单测(机器状态依赖);测试用 _setSpawnModelReaderForTests 显式注入。
61
+ // env/reader 参数化只为可测性(见 test/pty-manager.test.js guard 单测)
62
+ export function _defaultSpawnModelReader(c, env = process.env, reader = resolveSpawnModel) {
63
+ return env.NODE_TEST_CONTEXT ? null : reader(c, env);
61
64
  }
62
65
  let _spawnModelReader = _defaultSpawnModelReader;
63
66
  export function _setSpawnModelReaderForTests(fn) {
64
67
  _spawnModelReader = fn || _defaultSpawnModelReader;
65
68
  }
66
69
 
70
+ // 启动兜底的时间源与窗口:spawn 后在窗口内死亡视为「引导期死亡」。真实引导崩溃 <1s,
71
+ // 5s 足够;更长的窗口只会扩大「用户快速主动退出」的误报面(评审取值)。
72
+ // _now 可注入:兜底用例需要拨表模拟「存活超窗后退出」。
73
+ const SYS_PROMPT_BOOT_WINDOW_MS = 5000;
74
+ let _now = Date.now;
75
+ export function _setNowForTests(fn) {
76
+ _now = fn || Date.now;
77
+ }
78
+
67
79
  async function getPty() {
68
80
  if (typeof _ptyImportForTests === 'function') {
69
81
  return _ptyImportForTests();
@@ -152,8 +164,15 @@ const _thinkingDisplayRejectedPaths = new Set();
152
164
  // 启动目录里的 CC_SYSTEM.md / CC_APPEND_SYSTEM.md 自动注入 --system-prompt-file/--append-system-prompt-file。
153
165
  // 若目标 claude(或三方 fork/wrapper)不识别该 flag,onExit 检测 "unknown option" 后把 claudePath 记入此集,
154
166
  // 下次 spawn 跳过注入并去 flag 重启(对齐 _thinkingDisplayRejectedPaths 自愈)。
167
+ // 语义:永久(进程级)——"unknown option" 是该二进制不支持 flag 的确定性能力信号。
155
168
  const _systemPromptFileRejectedPaths = new Set();
156
169
 
170
+ // 一次性跳过令牌:启动兜底一级的放宽半支(引导窗口内非信号 exit≠0)覆盖的是**瞬态**崩溃
171
+ // (API key 失效/网络抖动/与注入无关的秒退),不能写进上面的永久拒绝集——否则一次瞬态故障
172
+ // 会在整个 ccv 进程生命周期内静默禁用该二进制的注入(review P1)。令牌在下一次 spawn 被
173
+ // 消费(delete):恰好保证去注入重试一次,之后的 spawn 恢复正常注入尝试。
174
+ const _skipInjectionOncePaths = new Set();
175
+
157
176
  // 内部重启(-c 重试 / flag 自愈)时抑制一次注入提示,避免终端重复打印同一行。
158
177
  let _suppressNextSpawnNotice = false;
159
178
 
@@ -172,9 +191,10 @@ export function _markThinkingDisplayRejected(claudePath) {
172
191
  _thinkingDisplayRejectedPaths.add(claudePath);
173
192
  }
174
193
 
175
- // 仅用于测试/内部:清空 system-prompt-file 拒绝集
194
+ // 仅用于测试/内部:清空 system-prompt-file 拒绝集(连同一次性跳过令牌,保持用例间干净)
176
195
  export function _clearSystemPromptFileRejectedPaths() {
177
196
  _systemPromptFileRejectedPaths.clear();
197
+ _skipInjectionOncePaths.clear();
178
198
  }
179
199
 
180
200
  // 仅用于测试:查询路径是否已被标记为不支持 --system-prompt-file
@@ -299,21 +319,40 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
299
319
  // LOG_DIR 内的 spawn(IM worker 工作目录 = <LOG_DIR>/IM_<id>/)跳过模型匹配:
300
320
  // IM 人格依赖默认 sentinel CC_APPEND_SYSTEM.md 注入,全局模型条目不得静默取代它。
301
321
  const spawnDir = cwd || process.cwd();
322
+ // insideLogDir 留在 try 外:下方 onExit 的启动兜底门控也要用它。
302
323
  const insideLogDir = spawnDir === LOG_DIR || spawnDir.startsWith(LOG_DIR + sep);
303
- const resolvedModelId = insideLogDir ? null : _spawnModelReader(spawnDir);
304
- let sysPrompt = buildSystemPromptFileArgs(spawnDir, finalExtraArgs, process.env, {
305
- modelId: resolvedModelId,
306
- globalModelDir: join(LOG_DIR, MODEL_PROMPT_DIR),
307
- });
308
- if (_systemPromptFileRejectedPaths.has(claudePath)) {
324
+ // 整个 system prompt 构建 + 渲染管道包在 try-catch 里(PR#128):任何意外抛错(模型解析、
325
+ // buildSystemPromptFileArgs 文件系统竞态、渲染的 git 子进程异常)都走兜底——按「没命中任何
326
+ // 条目」处理,launch 不带 --system-prompt-file/--append-system-prompt-file,claude 用自身
327
+ // 默认 system prompt 启动。注入失败绝不能阻断 spawn。
328
+ let sysPrompt = { args: [], loaded: [], model: null };
329
+ // 一次性跳过令牌在进入管道前无条件消费(delete):放宽半支的兜底只跳过紧随其后的这一次注入。
330
+ // 若放在 build 之后,build 抛错时令牌残留、会再多跳过一次(违反 exactly-once,review)。
331
+ const skipOnce = _skipInjectionOncePaths.delete(claudePath);
332
+ try {
333
+ const resolvedModelId = insideLogDir ? null : _spawnModelReader(spawnDir);
334
+ sysPrompt = buildSystemPromptFileArgs(spawnDir, finalExtraArgs, process.env, {
335
+ modelId: resolvedModelId,
336
+ globalModelDir: join(LOG_DIR, MODEL_PROMPT_DIR),
337
+ });
338
+ if (_systemPromptFileRejectedPaths.has(claudePath) || skipOnce) {
339
+ sysPrompt = { args: [], loaded: [], model: null };
340
+ } else if (resolvedModelId && !sysPrompt.model && !sysPrompt.suppressed
341
+ && (existsSync(join(spawnDir, MODEL_PROMPT_DIR)) || existsSync(join(LOG_DIR, MODEL_PROMPT_DIR)))) {
342
+ // The one diagnostic case worth a warning: a system_prompt dir is configured
343
+ // but the resolved model matched no entry (likely a misnamed file). Intentional
344
+ // skips (CCV_DISABLE_AUTO_SYSTEM_PROMPT=1, or a manual --system-prompt flag
345
+ // suppressing a matched entry) carry `suppressed` and stay quiet. The
346
+ // successful-injection notice is emitted below via emitSpawnNotice (with
347
+ // internal-restart suppression); no-modelId spawns are the normal quiet path.
348
+ console.warn(`[CC Viewer] model-specific prompt: modelId="${resolvedModelId}" resolved from active config but no matching entry found in workspace or global ${MODEL_PROMPT_DIR}/`);
349
+ }
350
+ // Resolve `${...}` template variables in the injected files (editor stores them literal —
351
+ // the substitution documented by the editor's parameter reference happens here, at launch).
352
+ sysPrompt = renderSystemPromptFileArgs(sysPrompt, { cwd: spawnDir, modelId: resolvedModelId });
353
+ } catch (err) {
354
+ console.warn('[CC Viewer] system prompt build/render failed, launching without injected prompt:', err?.message || err);
309
355
  sysPrompt = { args: [], loaded: [], model: null };
310
- } else if (resolvedModelId && !sysPrompt.model
311
- && (existsSync(join(spawnDir, MODEL_PROMPT_DIR)) || existsSync(join(LOG_DIR, MODEL_PROMPT_DIR)))) {
312
- // The one diagnostic case worth a warning: a system_prompt dir is configured
313
- // but the resolved model matched no entry (likely a misnamed file). The
314
- // successful-injection notice is emitted below via emitSpawnNotice (with
315
- // internal-restart suppression); no-modelId spawns are the normal quiet path.
316
- console.warn(`[CC Viewer] model-specific prompt: modelId="${resolvedModelId}" resolved but no matching entry found in workspace or global ${MODEL_PROMPT_DIR}/`);
317
356
  }
318
357
  const launchArgs = sysPrompt.args.length ? [...finalExtraArgs, ...sysPrompt.args] : finalExtraArgs;
319
358
 
@@ -330,6 +369,9 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
330
369
  outputBuffer = '';
331
370
  currentWorkspacePath = cwd || process.cwd();
332
371
  lastWorkspacePath = currentWorkspacePath;
372
+ // Boot-window anchor for the injection fallback tiers below (same clock as the
373
+ // comparison — _now(), never Date.now(), so tests can steer both ends together).
374
+ const spawnedAt = _now();
333
375
 
334
376
  ptyProcess = pty.spawn(command, args, {
335
377
  name: 'xterm-256color',
@@ -342,13 +384,8 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
342
384
  // --allow-dangerously-skip-permissions only enables a later toggle, so it must NOT count.
343
385
  ptySkipPermissions = extraArgs.includes('--dangerously-skip-permissions');
344
386
 
345
- // 注入了 system prompt 文件时向终端打印一行提示(可见性/安全);内部重启已抑制以免重复。
346
- if (sysPrompt.loaded.length && !_suppressNextSpawnNotice) {
347
- const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
348
- emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
349
- }
350
- _suppressNextSpawnNotice = false;
351
-
387
+ // PTY 事件处理器必须紧随 spawn 注册(PR#128):若子进程在 onExit 挂载前就退出(二进制缺失/
388
+ // 秒崩/拒绝注入 flag),exit 事件会丢失——句柄释放后事件循环可能排空。注入提示挪到注册之后。
352
389
  ptyProcess.onData((data) => {
353
390
  outputBuffer += data;
354
391
  if (outputBuffer.length > MAX_BUFFER) {
@@ -363,12 +400,16 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
363
400
  }
364
401
  });
365
402
 
366
- ptyProcess.onExit(({ exitCode }) => {
403
+ ptyProcess.onExit(({ exitCode, signal }) => {
367
404
  flushBatch(true);
368
405
  lastExitCode = exitCode;
369
406
  ptyProcess = null;
370
407
  ptyKind = null;
371
408
  ptySkipPermissions = false;
409
+ // 引导期死亡:spawn 后窗口内退出。窗口外的退出一律不属于「注入拖崩启动」的兜底范围。
410
+ // 单次取 _now(评审):一级/二级共用同一时刻,注入的 fake clock 不会在分支间发散。
411
+ const elapsedMs = _now() - spawnedAt;
412
+ const diedInBootWindow = elapsedMs < SYS_PROMPT_BOOT_WINDOW_MS;
372
413
 
373
414
  // Auto-retry without -c/--continue if "No conversation found"
374
415
  // 注意:早退 return 会跳过下方的 exitListeners 广播——第一次失败的 pty 死亡对消费者
@@ -399,18 +440,48 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
399
440
  return;
400
441
  }
401
442
 
402
- // 事后兜底:claude(或三方 fork/wrapper)不识别 --system-prompt-file/--append-system-prompt-file 而崩溃时,
403
- // 记下该 claudePath、跳过注入并重启一次,避免自动注入把会话拖崩(对齐上面的 --thinking-display 自愈)。
443
+ // 事后兜底一级:注入过 system-prompt 文件的 claude 非正常死亡时,跳过注入重启一次
444
+ // (对齐上面的 --thinking-display 自愈;该确诊分支必须在前——用户自传 --thinking-display
445
+ // 且有注入时这里最多浪费一次去注入重试,第二次即广播真实报错)。两个半支的**持久性不同**:
446
+ // - 精确半支(原语义):输出含 "unknown option --system-prompt-file" —— 确诊该二进制不支持
447
+ // flag(稳定能力信号)→ 写永久拒绝集;任何场景(含 IM worker,否则它永远起不来)都自愈。
448
+ // - 放宽半支(启动兜底):引导窗口内 exit≠0 —— 注入**可能**是拖崩启动的原因(也可能是无关的
449
+ // 瞬态故障)→ 只发一次性跳过令牌,绝不写永久集(review P1:一次瞬态崩溃不得在整个进程
450
+ // 生命周期内静默禁用注入)。门控 !signal(用户 Ctrl-C/关标签/切 workspace 的 killPtyTree
451
+ // 都是信号终止,不是引导崩溃,盲目重启会把用户刚关的会话强行拉回来;Windows ConPTY 无
452
+ // POSIX 信号语义,已知限制:Ctrl-C 可能多一次无害重试)与 !insideLogDir(IM worker
453
+ // 「去注入重启」= 剥离 CC_APPEND_SYSTEM.md 人格后存活,比崩溃更难排查——IM 秒退只广播
454
+ // 真实报错)。
455
+ // 无死循环:respawn 时拒绝集/令牌把 loaded 清空 → 本分支不再命中,恰好重试一次。
456
+ // 一级重试只去掉注入、其余参数原样;根因是别的(如 API key 失效)时首次报错已实时流入
457
+ // 终端 scrollback 不丢失,第二次照常死亡并广播。
458
+ const unknownSysFileFlag = /unknown option ['"]--(append-)?system-prompt-file/i.test(outputBuffer);
459
+ const injectedBootCrash = !insideLogDir && !signal && diedInBootWindow;
404
460
  const sysFileRejected = sysPrompt.loaded.length > 0 && exitCode !== 0
405
- && /unknown option ['"]--(append-)?system-prompt-file/i.test(outputBuffer);
461
+ && (unknownSysFileFlag || injectedBootCrash);
406
462
  if (sysFileRejected) {
407
- console.error('[CC Viewer] claude rejected --system-prompt-file, skipping CC_SYSTEM.md/CC_APPEND_SYSTEM.md and retrying');
408
- _systemPromptFileRejectedPaths.add(claudePath);
463
+ if (unknownSysFileFlag) {
464
+ console.error('[CC Viewer] claude rejected --system-prompt-file, marking as unsupported and retrying without injection');
465
+ _systemPromptFileRejectedPaths.add(claudePath);
466
+ } else {
467
+ console.error(`[CC Viewer] claude exited (code ${exitCode}) ${Math.round(elapsedMs / 1000)}s after launch with injected system prompt (${sysPrompt.loaded.join(', ')}); retrying once without injection`);
468
+ _skipInjectionOncePaths.add(claudePath);
469
+ }
409
470
  _suppressNextSpawnNotice = true;
471
+ // 措辞留有余地(评审):引导期死亡可能与注入无关(API key/网络等),不断言因果。
472
+ emitSpawnNotice(`[CC Viewer] claude exited during boot (code ${exitCode}); the injected system prompt may or may not be the cause — retrying once without ${sysPrompt.loaded.join(', ')}`);
410
473
  spawnClaude(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
411
474
  return;
412
475
  }
413
476
 
477
+ // 事后兜底二级:注入过且 exit=0 秒退 —— 无法与「用户主动快速 /exit」区分(日常高频),
478
+ // 所以只打诊断提示、不自动重启、不加入拒绝集(自动关停会因使用习惯静默禁用注入,评审否决),
479
+ // 也不早退——照常广播 exit,前端退出横幅路径与用户正常 /exit 完全一致。
480
+ // !insideLogDir:IM worker 的 pty 数据流可能被桥接转发,诊断行不该漏进 IM 会话(评审)。
481
+ if (sysPrompt.loaded.length > 0 && exitCode === 0 && diedInBootWindow && !insideLogDir) {
482
+ emitSpawnNotice(`[CC Viewer] claude exited ${Math.round(elapsedMs / 1000)}s after launch with an injected system prompt (${sysPrompt.loaded.join(', ')}). If this keeps happening the injected prompt may be incompatible — remove the entry or set CCV_DISABLE_AUTO_SYSTEM_PROMPT=1 to skip injection.`);
483
+ }
484
+
414
485
  // 保留 lastWorkspacePath,不清除,用于 respawn
415
486
  currentWorkspacePath = null;
416
487
  for (const cb of exitListeners) {
@@ -418,6 +489,14 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
418
489
  }
419
490
  });
420
491
 
492
+ // 注入了 system prompt 文件时向终端打印一行提示(可见性/安全);内部重启已抑制以免重复。
493
+ // 必须在 onData/onExit 注册之后再打(PR#128),缩小「子进程在处理器挂载前退出」的丢事件窗口。
494
+ if (sysPrompt.loaded.length && !_suppressNextSpawnNotice) {
495
+ const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
496
+ emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
497
+ }
498
+ _suppressNextSpawnNotice = false;
499
+
421
500
  return ptyProcess;
422
501
  }
423
502
 
@@ -27,14 +27,20 @@ async function dingtalkStatus(req, res, parsedUrl, isLocal, deps) {
27
27
  conn = deps.dingtalk.getBridgeStatus();
28
28
  } else {
29
29
  processInfo = await deps.dingtalk.getProcessStatus();
30
- conn = { running: processInfo.running, connected: processInfo.connected };
30
+ conn = {
31
+ running: processInfo.running,
32
+ connected: processInfo.connected,
33
+ connectionState: processInfo.connectionState,
34
+ lastError: processInfo.lastError ?? null,
35
+ };
31
36
  }
32
37
  res.writeHead(200, JSON_HEADERS);
33
38
  if (!isLocal) {
39
+ // connectionState passes through as-is; lastError stays loopback-only (mirrors routes/im.js).
34
40
  res.end(JSON.stringify({
35
41
  enabled: loadDingTalkState().enabled,
36
42
  hasSecret: loadDingTalkState().hasSecret,
37
- connection: { running: conn.running, connected: conn.connected },
43
+ connection: { running: conn.running, connected: conn.connected, connectionState: conn.connectionState },
38
44
  }));
39
45
  return;
40
46
  }
@@ -93,7 +99,7 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
93
99
  console.error('[CC Viewer] IM config apply failed for dingtalk:', e?.message || e);
94
100
  }
95
101
  res.writeHead(200, JSON_HEADERS);
96
- res.end(JSON.stringify({ ...loadDingTalkState(), connection: { running: !!(saved?.enabled ?? incoming.enabled), connected: false } }));
102
+ res.end(JSON.stringify({ ...loadDingTalkState(), connection: { running: !!(saved?.enabled ?? incoming.enabled), connected: false, connectionState: 'disconnected' } }));
97
103
  });
98
104
  }
99
105