cc-viewer 1.6.348 → 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 (126) hide show
  1. package/README.md +13 -1
  2. package/cli.js +116 -37
  3. package/dist/assets/App-DTkacfRW.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-C5Q6uUyH.js} +1 -1
  5. package/dist/assets/Mobile-MoBp9c8k.js +1 -0
  6. package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
  7. package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
  11. package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
  22. package/dist/assets/clone-BQtvy9Ae.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
  33. package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
  34. package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
  35. package/dist/assets/index-Ac8vnV3v.js +2 -0
  36. package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
  37. package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
  38. package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
  39. package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
  40. package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
  41. package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
  45. package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
  47. package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
  48. package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
  49. package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
  50. package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
  56. package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +2 -0
  57. package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-CxuD1CYl.css} +1 -1
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
  62. package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/findcc.js +4 -3
  73. package/package.json +2 -1
  74. package/server/i18n.js +21 -141
  75. package/server/interceptor.js +211 -403
  76. package/server/lib/async-write-queue.js +12 -1
  77. package/server/lib/delta-reconstructor.js +3 -3
  78. package/server/lib/error-report.js +45 -0
  79. package/server/lib/im-log-watcher.js +68 -8
  80. package/server/lib/interceptor-core.js +19 -174
  81. package/server/lib/log-management.js +214 -247
  82. package/server/lib/log-stream.js +59 -108
  83. package/server/lib/log-watcher.js +34 -272
  84. package/server/lib/log-zip.js +148 -0
  85. package/server/lib/session-boundary.js +266 -0
  86. package/server/lib/session-pin-store.js +10 -19
  87. package/server/lib/stats-worker.js +138 -267
  88. package/server/lib/user-prompt-extract.js +250 -0
  89. package/server/lib/v2/adapter.js +1059 -0
  90. package/server/lib/v2/blob-store.js +44 -0
  91. package/server/lib/v2/conversation-store.js +217 -0
  92. package/server/lib/v2/convert-manager.js +79 -0
  93. package/server/lib/v2/convert-worker.js +42 -0
  94. package/server/lib/v2/convert.js +409 -0
  95. package/server/lib/v2/identity.js +204 -0
  96. package/server/lib/v2/journal.js +62 -0
  97. package/server/lib/v2/layout.js +167 -0
  98. package/server/lib/v2/live-feed.js +451 -0
  99. package/server/lib/v2/migrate-prompt.js +54 -0
  100. package/server/lib/v2/replay.js +191 -0
  101. package/server/lib/v2/session-select.js +222 -0
  102. package/server/lib/v2/singleflight.js +104 -0
  103. package/server/lib/v2/v2-writer.js +513 -0
  104. package/server/lib/v2/verify.js +233 -0
  105. package/server/routes/events.js +30 -130
  106. package/server/routes/files-fs.js +4 -2
  107. package/server/routes/im.js +11 -2
  108. package/server/routes/logs.js +194 -152
  109. package/server/routes/preferences.js +0 -3
  110. package/server/routes/project-meta.js +2 -3
  111. package/server/routes/session-pin.js +7 -8
  112. package/server/routes/workspaces.js +44 -9
  113. package/server/server.js +47 -65
  114. package/server/workspace-registry.js +24 -4
  115. package/dist/assets/App-VTo4NVdo.js +0 -2
  116. package/dist/assets/Mobile-CcmYvMm_.js +0 -1
  117. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  118. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  119. package/dist/assets/clone-md-Mc1-T.js +0 -1
  120. package/dist/assets/index-DCo650PG.js +0 -2
  121. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  122. package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
  123. package/server/lib/instance-registry.js +0 -57
  124. package/server/lib/jsonl-archive.js +0 -180
  125. package/server/lib/teammate-detect.js +0 -32
  126. package/server/lib/zip-safety.js +0 -68
@@ -0,0 +1,44 @@
1
+ // Wire Format v2 — content-addressed blob store for body.tools / body.system
2
+ // (docs/refactor/WIRE_FORMAT_V2.md §7).
3
+ //
4
+ // key = sha256(JSON.stringify(value)) hex, first 16 chars. File content is the
5
+ // raw JSON value. Writes go through writeFileAtomicSync (tmp → fsync → rename):
6
+ // the blob is the ONE durability barrier of the write-order protocol — its
7
+ // journal reference may only become durable after the blob itself (spec §1.3).
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync } from 'node:fs';
11
+ import { blobPath, writeFileAtomicSync } from './layout.js';
12
+
13
+ export class BlobStore {
14
+ /** @param {object} paths - sessionPaths() object for one session */
15
+ constructor(paths) {
16
+ this._paths = paths;
17
+ this._written = new Set(); // refs confirmed on disk this process lifetime
18
+ }
19
+
20
+ /**
21
+ * Store a JSON-serializable value, return its ref ('sha256-<hex16>').
22
+ * Idempotent: same content → same ref, at most one disk write per process
23
+ * (existsSync re-check makes restarts cheap too). Returns null for
24
+ * undefined/null values so callers can spread `...(ref && {tools: ref})`.
25
+ *
26
+ * KEEP IN SYNC: replay.js `blobRefOf` re-implements this exact formula
27
+ * (sha256 of JSON.stringify, hex, first 16) so the verifier can compare v1
28
+ * inline values against journal refs WITHOUT reading blob files — change the
29
+ * hash, the slice length, or the serialization here and the verify gate
30
+ * silently produces false diffs/OKs unless blobRefOf changes with it.
31
+ */
32
+ put(value) {
33
+ if (value === undefined || value === null) return null;
34
+ const json = JSON.stringify(value);
35
+ const ref = 'sha256-' + createHash('sha256').update(json).digest('hex').slice(0, 16);
36
+ if (this._written.has(ref)) return ref;
37
+ const finalPath = blobPath(this._paths, ref);
38
+ if (!existsSync(finalPath)) {
39
+ writeFileAtomicSync(finalPath, json);
40
+ }
41
+ this._written.add(ref);
42
+ return ref;
43
+ }
44
+ }
@@ -0,0 +1,217 @@
1
+ // Wire Format v2 — conversation event store (docs/refactor/WIRE_FORMAT_V2.md §6).
2
+ //
3
+ // The write side performs EXACTLY ONE cheap judgement — the prefix-extension
4
+ // test (same one v1's delta path does) — and stores wire events faithfully:
5
+ // append — wire messages prefix-extend the previous state → store the tail;
6
+ // snapshot — anything else (v1 §3.1 plan-mode window, §3.2 K-tail overlap,
7
+ // unexplained shrink, process restart) → store the FULL wire array
8
+ // and let the READ-side materializer resolve it with the shared
9
+ // reverse-anchor module. No merge logic ever runs at write time
10
+ // (plan decision F6: a write-side merge mistake would be permanent).
11
+ // ctl — replace-tail (v1 §3.3 in-place replace) / compact continuation.
12
+ //
13
+ // /clear (shared predicate, §3.4) closes the epoch and opens e<N+1>;
14
+ // /compact (§3.5) stays in the SAME epoch with a ctl marker.
15
+ //
16
+ // Every stored line carries the initiating seq + rid (plan risk F8): physical
17
+ // append order is completion order, semantic order is seq.
18
+
19
+ import { readdirSync } from 'node:fs';
20
+ import { isPostClearCheckpoint, isCompactContinuation, normalizeMsgForEquality } from '../session-boundary.js';
21
+ import { fingerprintMsg } from '../interceptor-core.js';
22
+ import { ensureConvDirSync, convEpochPath, convDir } from './layout.js';
23
+
24
+ /** FNV-1a over a string — cheap full-content discriminator for exact mode. */
25
+ function fnv1a(s) {
26
+ let h = 0x811c9dc5;
27
+ for (let i = 0; i < s.length; i++) {
28
+ h ^= s.charCodeAt(i);
29
+ h = Math.imul(h, 0x01000193);
30
+ }
31
+ return (h >>> 0).toString(36);
32
+ }
33
+
34
+ export class ConversationStore {
35
+ /**
36
+ * @param {object} paths - sessionPaths() object
37
+ * @param {{appendTo: Function}} queue - AsyncWriteQueue (explicit-path API)
38
+ * @param {{exactFps?: boolean}} [opts] - exact mode: judgement fingerprints
39
+ * additionally hash the FULL message JSON. The offline converter needs
40
+ * byte-grade faithfulness (its golden gate replays byte-identical wire);
41
+ * fingerprintMsg's 80-char first-block snip cannot distinguish wires that
42
+ * share a long prefix (real data 2026-07-14: sub prompts identical in the
43
+ * first 80 chars, differing later, judged "unchanged" → replay stuck on
44
+ * the first wire). The live request path keeps the cheap default.
45
+ */
46
+ constructor(paths, queue, opts = {}) {
47
+ this._paths = paths;
48
+ this._queue = queue;
49
+ this._convs = new Map(); // convKey → { epoch, count, fps, dirEnsured }
50
+ // Exact fps hash the NORMALIZED message (S4 decision 2026-07-14): the
51
+ // client migrates cache_control breakpoints onto old messages (string →
52
+ // block array), which is invisible to fingerprintMsg but flips the full
53
+ // JSON hash — without normalization the converter degrades to a snapshot
54
+ // per request on affected conversations (real-data storm, ~62% of corpus
55
+ // bytes). Storage still records the wire verbatim; only the judgement
56
+ // compares modulo cache_control/form.
57
+ this._fpOf = opts.exactFps
58
+ ? (m) => { const n = normalizeMsgForEquality(m); return `${fingerprintMsg(n)}:${fnv1a(JSON.stringify(n))}`; }
59
+ : fingerprintMsg;
60
+ }
61
+
62
+ _state(convKey) {
63
+ let st = this._convs.get(convKey);
64
+ if (!st) {
65
+ // fps: per-message judgement fingerprints of the CURRENT wire state.
66
+ // Tail-only comparison proved insufficient on real data (2026-07-14):
67
+ // interleaved same-sid streams (pre-flag proxy teammates, dual writers)
68
+ // share prefixes/tails while differing in the middle — append/replace
69
+ // judgements must verify the WHOLE prefix or replay produces a
70
+ // franken-mix. Strings are ~90 chars × wire length: negligible memory.
71
+ //
72
+ // epoch is SEEDED from the epoch files already on disk (2026-07-15 fix):
73
+ // a fresh store on an existing conv dir (process restart / `-c`
74
+ // continuation onto the same session) must continue in the LATEST epoch.
75
+ // Starting back at e0 appended newer seqs to an older file, breaking the
76
+ // global "file order == seq order" the live reader relies on (the
77
+ // journal seq already reseeds from disk; the epoch did not).
78
+ st = { epoch: this._seedEpoch(convKey), count: 0, fps: [], dirEnsured: false, everWrote: false };
79
+ this._convs.set(convKey, st);
80
+ }
81
+ return st;
82
+ }
83
+
84
+ /** Highest e<N>.jsonl already on disk for this conv (0 when none/unreadable). */
85
+ _seedEpoch(convKey) {
86
+ let max = 0;
87
+ try {
88
+ for (const f of readdirSync(convDir(this._paths, convKey))) {
89
+ const m = f.match(/^e(\d+)\.jsonl$/);
90
+ if (m) { const n = Number(m[1]); if (n > max) max = n; }
91
+ }
92
+ } catch { /* no conv dir yet — brand-new conversation */ }
93
+ return max;
94
+ }
95
+
96
+ /** All fps[0..n) pairwise equal between the stored state and the incoming wire. */
97
+ static _prefixEqual(prevFps, fpsIn, n) {
98
+ for (let i = 0; i < n; i++) {
99
+ if (prevFps[i] !== fpsIn[i]) return false;
100
+ }
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * Ingest one request's wire messages for a conversation.
106
+ * @param {string} convKey
107
+ * @param {Array} messages - ORIGINAL wire messages (pre any v1 delta mutation)
108
+ * @param {{seq:number, rid:string}} ids
109
+ * @returns {{evt:'append'|'snapshot'|null, ctl:string|null, epoch:number,
110
+ * boundary:'clear'|'compact'|'replace-tail'|null,
111
+ * msgFrom:number, msgTo:number}}
112
+ * evt/ctl describe what was written (journal req line mirrors this, §4);
113
+ * msgFrom/msgTo are the [from,to) wire counts for integrity checks.
114
+ */
115
+ ingest(convKey, messages, ids) {
116
+ const st = this._state(convKey);
117
+ const msgs = Array.isArray(messages) ? messages : [];
118
+ const len = msgs.length;
119
+ // Judgement fingerprint = v1's fingerprintMsg, NOT the client-shared
120
+ // messageFingerprint: fingerprintMsg is content-aware for tool_result
121
+ // bodies (40-char snippet) where messageFingerprint keys tool_result on
122
+ // tool_use_id ONLY. A same-id tool_result tail edit must be detected here
123
+ // exactly like v1's Plan C does, or the two dual-write sides legitimately
124
+ // diverge in a way the verify digest (built on messageFingerprint) is
125
+ // structurally blind to (review P2).
126
+ const fpsIn = msgs.map(this._fpOf);
127
+ const prev = st.count;
128
+ const prevFps = st.fps;
129
+
130
+ let evt = null;
131
+ let ctl = null;
132
+ let boundary = null;
133
+ const lines = [];
134
+
135
+ // /clear has priority over every other judgement (spec §6). The predicate
136
+ // wants the v1 entry shape; at write time this IS a full wire snapshot, so
137
+ // a synthetic _isCheckpoint:true carrier is faithful.
138
+ const isClear = prev > 0 && isPostClearCheckpoint({ _isCheckpoint: true, body: { messages: msgs } }, prev);
139
+ if (isClear) {
140
+ st.epoch += 1;
141
+ st.count = 0;
142
+ st.fps = [];
143
+ boundary = 'clear';
144
+ evt = 'snapshot';
145
+ lines.push({ seq: ids.seq, rid: ids.rid, t: 'snapshot', msgs, reason: 'first' });
146
+ } else if (len === prev && ConversationStore._prefixEqual(prevFps, fpsIn, len)) {
147
+ // Unchanged wire state → no conversation event (journal still records req/done).
148
+ evt = null;
149
+ } else if (len === prev && prev > 0 && ConversationStore._prefixEqual(prevFps, fpsIn, len - 1)
150
+ && prevFps[len - 1] !== '' && fpsIn[len - 1] !== '' && fpsIn[len - 1] !== prevFps[len - 1]) {
151
+ // v1 §3.3 in-place last-message replace (same judgement family as the
152
+ // interceptor's `_sameLenInPlaceReplace`, same fingerprintMsg formula) —
153
+ // gated on FULL prefix equality: an equal-length but different wire array
154
+ // (real data: pre-flag proxy teammates / dual writers interleaved into
155
+ // one stream, §3.7 L104) must NOT be patched tail-only, or the replayed
156
+ // state becomes a franken-mix. Any prefix mismatch falls through to the
157
+ // snapshot branch (write-side never merges — snapshot is truth).
158
+ evt = 'ctl';
159
+ ctl = 'replace-tail';
160
+ boundary = 'replace-tail';
161
+ lines.push({ seq: ids.seq, rid: ids.rid, t: 'ctl', op: 'replace-tail', msg: msgs[len - 1] });
162
+ } else if (len > prev && (prev === 0 || ConversationStore._prefixEqual(prevFps, fpsIn, prev))) {
163
+ if (prev === 0 && st.count === 0 && !st.everWrote) {
164
+ // First event of a FRESH STORE → snapshot(first), so every epoch file
165
+ // is self-contained even when it starts mid-wire (teammate logs,
166
+ // process restart onto an existing session dir). No epoch===0 guard:
167
+ // a store seeded onto an existing epoch (restart continuation) must
168
+ // snapshot too — falling through to `append` would write the full
169
+ // wire as a tail onto the previous process's state (franken-mix).
170
+ evt = 'snapshot';
171
+ lines.push({ seq: ids.seq, rid: ids.rid, t: 'snapshot', msgs, reason: 'first' });
172
+ } else {
173
+ evt = 'append';
174
+ lines.push({ seq: ids.seq, rid: ids.rid, t: 'append', msgs: msgs.slice(prev) });
175
+ }
176
+ } else {
177
+ // Prefix test failed: §3.1 short window / §3.2 overlap / unexplained
178
+ // shrink / restart-onto-existing-state. Store the full wire truth.
179
+ const reason = prev === 0 ? 'first' : (len < prev ? 'shrunk' : 'tail-mismatch');
180
+ evt = 'snapshot';
181
+ lines.push({ seq: ids.seq, rid: ids.rid, t: 'snapshot', msgs, reason });
182
+ if (isCompactContinuation({ body: { messages: msgs } })) {
183
+ ctl = 'compact';
184
+ boundary = 'compact';
185
+ lines.push({ seq: ids.seq, rid: ids.rid, t: 'ctl', op: 'compact' });
186
+ }
187
+ }
188
+
189
+ if (lines.length > 0) {
190
+ if (!st.dirEnsured) {
191
+ ensureConvDirSync(this._paths, convKey);
192
+ st.dirEnsured = true;
193
+ }
194
+ const path = convEpochPath(this._paths, convKey, st.epoch);
195
+ for (const line of lines) {
196
+ this._queue.appendTo(path, JSON.stringify(line) + '\n');
197
+ }
198
+ st.everWrote = true;
199
+ }
200
+
201
+ // Advance state to this wire truth (eagerly, mirroring v1's eager-update
202
+ // fix for <30ms bursts — the next request must compare against THIS one).
203
+ st.count = len;
204
+ st.fps = fpsIn;
205
+
206
+ // [from,to) of the wire messages this event covers (spec §4):
207
+ // snapshot ⇒ [0,len); append ⇒ [prev,len); replace-tail ⇒ [len-1,len);
208
+ // no event ⇒ empty range at len.
209
+ const msgFrom = evt === 'snapshot' ? 0 : (ctl === 'replace-tail' ? Math.max(0, len - 1) : (evt === 'append' ? prev : len));
210
+ return { evt, ctl, epoch: st.epoch, boundary, msgFrom, msgTo: len };
211
+ }
212
+
213
+ /** Forget in-memory conversation states (workspace reset / resume hooks). */
214
+ reset() {
215
+ this._convs.clear();
216
+ }
217
+ }
@@ -0,0 +1,79 @@
1
+ // Wire Format v2 — S8 conversion manager (resident-task supervision).
2
+ //
3
+ // Owns the single in-process conversion worker: start/stop/status for the
4
+ // routes, plus boot-time auto-resume — a state file left at 'running' or
5
+ // 'verifying' means the previous server died mid-conversion, so the task
6
+ // restarts itself until the project reaches 'done' (user decision: the
7
+ // migration is resident until complete). File-level checkpoints make the
8
+ // re-run safe and cheap (done files are skipped).
9
+
10
+ import { Worker } from 'node:worker_threads';
11
+ import { join } from 'node:path';
12
+ import { readConvertState } from './convert.js';
13
+
14
+ let _running = null; // { project, logDir, worker, startedAt, progress }
15
+ let _lastError = null; // last worker-level failure (state file may lag on hard crashes)
16
+
17
+ export function isConvertRunning() {
18
+ return !!_running;
19
+ }
20
+
21
+ /**
22
+ * Start converting a project in a background worker.
23
+ * @returns {{ok: true} | {ok: false, error: string}}
24
+ */
25
+ export function startConvert(logDir, project) {
26
+ if (!logDir || !project) return { ok: false, error: 'logDir/project required' };
27
+ if (_running) return { ok: false, error: `conversion already running for project "${_running.project}"` };
28
+ let worker;
29
+ try {
30
+ worker = new Worker(new URL('./convert-worker.js', import.meta.url));
31
+ } catch (err) {
32
+ return { ok: false, error: `failed to start worker: ${err.message}` };
33
+ }
34
+ _lastError = null;
35
+ _running = { project, logDir, worker, startedAt: new Date().toISOString(), progress: null };
36
+ worker.on('message', (msg) => {
37
+ if (!msg || !_running || _running.worker !== worker) return;
38
+ if (msg.type === 'progress') _running.progress = msg.progress;
39
+ else if (msg.type === 'final' && msg.error) _lastError = msg.error;
40
+ });
41
+ worker.on('error', (err) => {
42
+ _lastError = String(err && err.message || err);
43
+ if (_running && _running.worker === worker) _running = null;
44
+ });
45
+ worker.on('exit', () => {
46
+ if (_running && _running.worker === worker) _running = null;
47
+ });
48
+ worker.postMessage({ type: 'start', logDir, project });
49
+ return { ok: true };
50
+ }
51
+
52
+ /** Request a graceful stop (checkpoints at a file/entry boundary). */
53
+ export function stopConvert() {
54
+ if (!_running) return { ok: false, error: 'no conversion running' };
55
+ try { _running.worker.postMessage({ type: 'stop' }); } catch { }
56
+ return { ok: true };
57
+ }
58
+
59
+ /** Snapshot for GET /api/wire-v2-convert: in-memory liveness + on-disk state. */
60
+ export function convertStatus(logDir, project) {
61
+ const state = project ? readConvertState(join(logDir, project)) : null;
62
+ const runningHere = !!(_running && _running.project === project);
63
+ return {
64
+ running: runningHere,
65
+ ...(runningHere && { startedAt: _running.startedAt, progress: _running.progress }),
66
+ ...(_lastError && !runningHere && { lastWorkerError: _lastError }),
67
+ state,
68
+ };
69
+ }
70
+
71
+ /** Boot hook: resume a conversion the previous process left unfinished. */
72
+ export function maybeResumeConvert(logDir, project) {
73
+ if (!logDir || !project || _running) return false;
74
+ const state = readConvertState(join(logDir, project));
75
+ if (!state || (state.status !== 'running' && state.status !== 'verifying')) return false;
76
+ const res = startConvert(logDir, project);
77
+ if (res.ok) console.error(`[CC Viewer] wire-v2 convert: resuming unfinished migration for "${project}"`);
78
+ return res.ok;
79
+ }
@@ -0,0 +1,42 @@
1
+ // Wire Format v2 — S8 conversion worker (worker_threads entry).
2
+ //
3
+ // One conversion per worker lifetime: the manager spawns a worker per start
4
+ // (or boot-resume), sends {type:'start'}, and the worker exits after posting
5
+ // its final message. 'stop' flips a flag polled by convertProject, which
6
+ // checkpoints at a safe boundary and returns with status:'stopped'.
7
+
8
+ import { parentPort } from 'node:worker_threads';
9
+ import { convertProject } from './convert.js';
10
+
11
+ let stopRequested = false;
12
+
13
+ parentPort?.on('message', async (msg) => {
14
+ if (!msg || typeof msg !== 'object') return;
15
+ if (msg.type === 'stop') {
16
+ stopRequested = true;
17
+ return;
18
+ }
19
+ if (msg.type !== 'start') return;
20
+
21
+ const { logDir, project } = msg;
22
+ let lastProgressAt = 0;
23
+ try {
24
+ const state = await convertProject(logDir, project, {
25
+ shouldStop: () => stopRequested,
26
+ onProgress: (p) => {
27
+ // ≥1s throttle: progress is UI polish, not a firehose. Terminal phases
28
+ // always go through so the manager's snapshot ends accurate.
29
+ const now = Date.now();
30
+ if (p.phase !== 'done' && now - lastProgressAt < 1000) return;
31
+ lastProgressAt = now;
32
+ parentPort?.postMessage({ type: 'progress', project, progress: p });
33
+ },
34
+ });
35
+ parentPort?.postMessage({ type: 'final', project, state });
36
+ } catch (err) {
37
+ // convertProject already persisted status:'error' to the state file.
38
+ parentPort?.postMessage({ type: 'final', project, error: String(err && err.message || err) });
39
+ }
40
+ // Let the queued postMessage drain, then end the thread.
41
+ setImmediate(() => process.exit(0));
42
+ });