cc-viewer 1.6.348 → 1.7.1

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 (222) hide show
  1. package/README.md +13 -1
  2. package/cli.js +116 -37
  3. package/dist/assets/App-gUSmU6Ny.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-DAR39A3S.js} +1 -1
  5. package/dist/assets/Mobile-qfRsRth0.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-B51Ci0GL.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-B5Zy_CC_.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  57. package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
  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 +3 -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 +62 -274
  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 +1124 -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 +558 -0
  99. package/server/lib/v2/meta-rows.js +360 -0
  100. package/server/lib/v2/migrate-prompt.js +54 -0
  101. package/server/lib/v2/replay.js +191 -0
  102. package/server/lib/v2/session-select.js +222 -0
  103. package/server/lib/v2/singleflight.js +104 -0
  104. package/server/lib/v2/v2-writer.js +528 -0
  105. package/server/lib/v2/verify.js +233 -0
  106. package/server/lib/wire-compress.js +167 -0
  107. package/server/routes/events.js +120 -150
  108. package/server/routes/files-fs.js +4 -2
  109. package/server/routes/im.js +11 -2
  110. package/server/routes/logs.js +209 -164
  111. package/server/routes/preferences.js +0 -3
  112. package/server/routes/project-meta.js +2 -3
  113. package/server/routes/session-pin.js +7 -8
  114. package/server/routes/v2.js +60 -0
  115. package/server/routes/workspaces.js +50 -14
  116. package/server/server.js +70 -66
  117. package/server/workspace-registry.js +24 -4
  118. package/src/utils/apiUrl.js +26 -0
  119. package/src/utils/askFallback.js +21 -0
  120. package/src/utils/askOptionDesc.js +55 -0
  121. package/src/utils/askPortalMatcher.js +54 -0
  122. package/src/utils/autoApproveOptions.js +27 -0
  123. package/src/utils/avatarAnimationPostPass.js +49 -0
  124. package/src/utils/builtinPresets.js +18 -0
  125. package/src/utils/clearCheckpoint.js +7 -0
  126. package/src/utils/commandValidator.js +45 -0
  127. package/src/utils/contentFilter.js +620 -0
  128. package/src/utils/contextRaw.js +18 -0
  129. package/src/utils/contextSidebarNavigation.js +16 -0
  130. package/src/utils/contextTurns.js +117 -0
  131. package/src/utils/displayScaleHelper.js +47 -0
  132. package/src/utils/effectiveModel.js +13 -0
  133. package/src/utils/entry-slim.js +563 -0
  134. package/src/utils/entryCache.js +144 -0
  135. package/src/utils/errorReport.js +6 -0
  136. package/src/utils/fileExpandedPathsStorage.js +70 -0
  137. package/src/utils/fileIcons.jsx +36 -0
  138. package/src/utils/fileOpen.js +37 -0
  139. package/src/utils/formatters.js +48 -0
  140. package/src/utils/gitApi.js +50 -0
  141. package/src/utils/gitTreeBuilder.js +19 -0
  142. package/src/utils/helpers.js +846 -0
  143. package/src/utils/identityHeal.js +99 -0
  144. package/src/utils/imConnState.js +59 -0
  145. package/src/utils/imOrigin.js +24 -0
  146. package/src/utils/imTr.js +7 -0
  147. package/src/utils/imageCompress.js +83 -0
  148. package/src/utils/imageDownscale.js +28 -0
  149. package/src/utils/imageResize.js +112 -0
  150. package/src/utils/ingestPipeline.js +47 -0
  151. package/src/utils/lazyWithReload.js +53 -0
  152. package/src/utils/markdown.js +30 -0
  153. package/src/utils/markdownIncremental.js +39 -0
  154. package/src/utils/markdownProfiler.js +113 -0
  155. package/src/utils/mdExtensionDetect.js +64 -0
  156. package/src/utils/memoryLinkParser.js +44 -0
  157. package/src/utils/modalMask.js +15 -0
  158. package/src/utils/monotime.js +6 -0
  159. package/src/utils/pinnedMenu.js +55 -0
  160. package/src/utils/presetShortcuts.js +14 -0
  161. package/src/utils/projectAlias.js +135 -0
  162. package/src/utils/promptClassifier.js +112 -0
  163. package/src/utils/promptDetect.js +196 -0
  164. package/src/utils/promptNav.js +60 -0
  165. package/src/utils/ptyChunkBuilder.js +251 -0
  166. package/src/utils/quickMenuHoverIntent.js +35 -0
  167. package/src/utils/rateLimitParser.js +119 -0
  168. package/src/utils/readResultPool.js +106 -0
  169. package/src/utils/refreshCachedItemProp.js +30 -0
  170. package/src/utils/requestType.js +208 -0
  171. package/src/utils/resizeCalc.js +21 -0
  172. package/src/utils/resolveLocalized.js +17 -0
  173. package/src/utils/searchApi.js +47 -0
  174. package/src/utils/searchReplace.js +51 -0
  175. package/src/utils/seqResourceLoaders.js +79 -0
  176. package/src/utils/sessionManager.js +471 -0
  177. package/src/utils/sessionMerge.js +180 -0
  178. package/src/utils/skillModalController.js +111 -0
  179. package/src/utils/skillsParser.js +116 -0
  180. package/src/utils/slashCommandLabels.js +80 -0
  181. package/src/utils/splitDragCalc.js +73 -0
  182. package/src/utils/stickyBottomController.js +643 -0
  183. package/src/utils/svgSanitize.js +48 -0
  184. package/src/utils/systemTags.js +47 -0
  185. package/src/utils/tClaude.js +20 -0
  186. package/src/utils/teamModalBuilder.js +351 -0
  187. package/src/utils/teamSessionParser.js +190 -0
  188. package/src/utils/teammateAvatars.js +194 -0
  189. package/src/utils/teammateDetector.js +121 -0
  190. package/src/utils/termDiag.js +114 -0
  191. package/src/utils/terminalClipboard.js +117 -0
  192. package/src/utils/terminalWriteQueue.js +398 -0
  193. package/src/utils/thinkingMerge.js +49 -0
  194. package/src/utils/toolCatalog.js +29 -0
  195. package/src/utils/toolResultBuilder.js +393 -0
  196. package/src/utils/toolResultClassifier.js +16 -0
  197. package/src/utils/toolResultCore.js +167 -0
  198. package/src/utils/toolsDiff.js +44 -0
  199. package/src/utils/toolsXmlFormatter.js +3 -0
  200. package/src/utils/ultraplanController.js +109 -0
  201. package/src/utils/ultraplanExperts.js +86 -0
  202. package/src/utils/ultraplanTemplates.js +154 -0
  203. package/src/utils/userImageRefs.js +49 -0
  204. package/src/utils/v3Assembler.js +112 -0
  205. package/src/utils/v3Rows.js +48 -0
  206. package/src/utils/voicePackPlayer.js +233 -0
  207. package/src/utils/webSearchGrouping.js +111 -0
  208. package/src/utils/workflowFormat.js +52 -0
  209. package/src/utils/workflowRuns.js +118 -0
  210. package/src/utils/workflowStore.js +94 -0
  211. package/dist/assets/App-VTo4NVdo.js +0 -2
  212. package/dist/assets/Mobile-CcmYvMm_.js +0 -1
  213. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  214. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  215. package/dist/assets/clone-md-Mc1-T.js +0 -1
  216. package/dist/assets/index-DCo650PG.js +0 -2
  217. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  218. package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
  219. package/server/lib/instance-registry.js +0 -57
  220. package/server/lib/jsonl-archive.js +0 -180
  221. package/server/lib/teammate-detect.js +0 -32
  222. package/server/lib/zip-safety.js +0 -68
@@ -0,0 +1,409 @@
1
+ // Wire Format v2 — S8 offline v1→v2 converter (docs/refactor/WIRE_FORMAT_V2_PLAN.md §S8).
2
+ //
3
+ // Converts a project's historical v1 JSONL files into v2 session dirs:
4
+ // enumerate *.jsonl ascending by filename timestamp (sessions span
5
+ // rotation files, so order is mandatory) → per file: fresh incremental
6
+ // reconstructor (rotation resets v1 delta state, so every file is
7
+ // self-contained) feeding ONE shared V2Writer bound to the STAGING dir
8
+ // (`sessions-migrating/`) → full golden verify (verifyV1File against the
9
+ // staging root) → promote each staged session into `sessions/` via renameSync.
10
+ //
11
+ // STRICTLY ADDITIVE: v1 files are never written, moved, or deleted. The only
12
+ // writes are the staging tree, the promoted session dirs, and the state file.
13
+ //
14
+ // Resume: file-level checkpoints in `wire-v2-convert-state.json`. A restart
15
+ // re-runs the first incomplete file; sessions spanning the resume boundary are
16
+ // safe by construction (Journal seeds seq from disk, ConversationStore falls
17
+ // back to a full snapshot when the prefix test fails) at a small size cost.
18
+ //
19
+ // Session-level skip: a sid whose real `sessions/<sid>` dir already exists is
20
+ // dual-write output — authoritative, never re-ingested. Convert while
21
+ // dual-write is enabled (the UI says so): then any tail appended to the active
22
+ // v1 file after conversion belongs to dual-written sessions and skipping is
23
+ // exact. (With dual-write off and CC actively writing, the then-active
24
+ // session's post-conversion tail would stay unconverted — documented edge.)
25
+
26
+ import { readdirSync, existsSync, statSync, statfsSync, renameSync, rmSync, readFileSync, mkdirSync } from 'node:fs';
27
+ import { join, basename } from 'node:path';
28
+ import { iterateRawEntriesAsync } from '../log-stream.js';
29
+ import { createIncrementalReconstructor } from '../delta-reconstructor.js';
30
+ import { isLogFileName, parseLogTs } from '../log-management.js';
31
+ import { reportSwallowed } from '../error-report.js';
32
+ import { V2Writer } from './v2-writer.js';
33
+ import { verifyV1File } from './verify.js';
34
+ import { parseUserId } from './identity.js';
35
+ import { sanitizePathComponent, writeFileAtomicSync, sessionPaths } from './layout.js';
36
+ import { listSessionIds } from './replay.js';
37
+ import { resolveSessionDirName } from './session-select.js';
38
+
39
+ export const STAGING_DIR_NAME = 'sessions-migrating';
40
+ // Sessions that fail golden verify are moved here instead of being promoted into
41
+ // `sessions/`. The migration still completes — a single bad session is held for
42
+ // inspection, never a batch-wide abort (weak verify dependency, user 2026-07-15).
43
+ export const QUARANTINE_DIR_NAME = 'sessions-quarantine';
44
+ export const STATE_FILE_NAME = 'wire-v2-convert-state.json';
45
+ const STATE_VERSION = 1;
46
+ const STOP_CHECK_EVERY = 50; // entries between shouldStop() polls
47
+
48
+ /** Enumerate a project's v1 log files, ascending by filename timestamp.
49
+ * Excludes `_temp.jsonl` (resume scratch). */
50
+ export function listV1Files(projectDir) {
51
+ if (!existsSync(projectDir)) return [];
52
+ return readdirSync(projectDir)
53
+ .filter(name => isLogFileName(name) && !name.endsWith('_temp.jsonl'))
54
+ .sort((a, b) => parseLogTs(a).localeCompare(parseLogTs(b)) || a.localeCompare(b));
55
+ }
56
+
57
+ /** Projects under logDir that have at least one v1 log file (CLI --all). */
58
+ export function listConvertibleProjects(logDir) {
59
+ if (!existsSync(logDir)) return [];
60
+ const out = [];
61
+ for (const entry of readdirSync(logDir, { withFileTypes: true })) {
62
+ if (!entry.isDirectory()) continue;
63
+ try {
64
+ if (listV1Files(join(logDir, entry.name)).length > 0) out.push(entry.name);
65
+ } catch { /* unreadable project dir — not convertible */ }
66
+ }
67
+ return out.sort();
68
+ }
69
+
70
+ export function readConvertState(projectDir) {
71
+ try {
72
+ const raw = readFileSync(join(projectDir, STATE_FILE_NAME), 'utf-8');
73
+ const state = JSON.parse(raw);
74
+ return state && state.version === STATE_VERSION ? state : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeConvertState(projectDir, state) {
81
+ writeFileAtomicSync(join(projectDir, STATE_FILE_NAME), JSON.stringify(state, null, 2) + '\n');
82
+ }
83
+
84
+ /** done-line historical timestamp: request start + duration (falls back to start). */
85
+ function doneTsOf(entry) {
86
+ const start = Date.parse(entry.timestamp);
87
+ if (!Number.isFinite(start)) return entry.timestamp || undefined;
88
+ const dur = typeof entry.duration === 'number' && entry.duration > 0 ? entry.duration : 0;
89
+ return new Date(start + dur).toISOString();
90
+ }
91
+
92
+ /**
93
+ * Convert one project's v1 logs to v2. Long-running; the caller (worker/CLI)
94
+ * owns threading. Progress/state are observable via `onProgress` and the
95
+ * state file. Throws on fatal errors after persisting status:'error'.
96
+ *
97
+ * @param {string} logDir
98
+ * @param {string} project - directory name under logDir (no path separators)
99
+ * @param {object} [opts]
100
+ * @param {Function} [opts.onProgress] - ({phase, file, fileIndex, filesTotal, entries, sessionsConverted, sessionsSkipped})
101
+ * @param {Function} [opts.shouldStop] - polled between entries; true → persist 'stopped' and return
102
+ * @param {boolean} [opts.verify=true] - golden verify before promote (non-fatal:
103
+ * failing sessions are quarantined, not aborted; the migration still completes)
104
+ * @param {Function} [opts.verifyFn] - injected verifyV1File (tests)
105
+ * @param {Function} [opts.statfs] - injected statfsSync (tests)
106
+ * @param {number} [opts.spaceFactor=2] - free-space assertion multiplier
107
+ * @returns {Promise<object>} final state object
108
+ */
109
+ export async function convertProject(logDir, project, opts = {}) {
110
+ if (!project || project !== sanitizePathComponent(project)) {
111
+ throw new Error(`invalid project name: ${JSON.stringify(project)}`);
112
+ }
113
+ const projectDir = join(logDir, project);
114
+ const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {};
115
+ const shouldStop = typeof opts.shouldStop === 'function' ? opts.shouldStop : () => false;
116
+ const doVerify = opts.verify !== false;
117
+ const verifyFn = typeof opts.verifyFn === 'function' ? opts.verifyFn : verifyV1File;
118
+ const statfs = opts.statfs || statfsSync;
119
+ const spaceFactor = typeof opts.spaceFactor === 'number' ? opts.spaceFactor : 2;
120
+
121
+ const files = listV1Files(projectDir);
122
+ const prev = readConvertState(projectDir);
123
+ const prevFiles = new Map((prev && Array.isArray(prev.files) ? prev.files : []).map(f => [f.name, f]));
124
+
125
+ const state = {
126
+ version: STATE_VERSION,
127
+ status: 'running',
128
+ startedAt: new Date().toISOString(),
129
+ finishedAt: null,
130
+ currentFile: null,
131
+ // A previously-done file is only trusted at the same size: a grown file
132
+ // (the active log) is re-run in full — its new sessions get converted, its
133
+ // already-promoted/dual-written sessions are skipped at session level.
134
+ files: files.map(name => {
135
+ const old = prevFiles.get(name);
136
+ let size = 0;
137
+ try { size = statSync(join(projectDir, name)).size; } catch { }
138
+ const done = !!(old && old.done && old.size === size);
139
+ return { name, size, done };
140
+ }),
141
+ sessionsConverted: prev && prev.status !== 'done' ? (prev.sessionsConverted || 0) : 0,
142
+ sessionsSkipped: 0,
143
+ sessionsQuarantined: 0,
144
+ quarantined: [], // [{ sessionId (dir name), uuid, reasons: string[] }] — held-back sessions
145
+ entries: 0,
146
+ // Verify-phase progress, persisted so the frontend (which renders from the
147
+ // state file only) can show "verifying (x/n) · m entries" instead of a
148
+ // static label. verifyEntries accumulates ACROSS files (the moving signal
149
+ // when one big file dominates); all three keep their final values on done.
150
+ verifyIndex: 0,
151
+ verifyTotal: 0,
152
+ verifyEntries: 0,
153
+ lastError: null,
154
+ };
155
+
156
+ // Free-space assertion: staging + promoted copies of everything still pending.
157
+ const pendingBytes = state.files.filter(f => !f.done).reduce((a, f) => a + f.size, 0);
158
+ try {
159
+ const st = statfs(logDir);
160
+ const free = Number(st.bavail) * Number(st.bsize);
161
+ if (free < pendingBytes * spaceFactor) {
162
+ state.status = 'error';
163
+ state.lastError = `insufficient disk space: ${free} bytes free < ${spaceFactor}x pending ${pendingBytes} bytes`;
164
+ state.finishedAt = new Date().toISOString();
165
+ writeConvertState(projectDir, state);
166
+ throw new Error(state.lastError);
167
+ }
168
+ } catch (err) {
169
+ if (state.status === 'error') throw err;
170
+ reportSwallowed('v2-convert.statfs', err); // statfs failure alone doesn't block conversion
171
+ }
172
+ writeConvertState(projectDir, state);
173
+
174
+ const writer = new V2Writer({
175
+ logDir,
176
+ project,
177
+ enabled: true,
178
+ sessionsDirName: STAGING_DIR_NAME,
179
+ metaExtra: { origin: 'convert', convertedAt: new Date().toISOString() },
180
+ exactConvFps: true, // golden gate replays byte-identical wire — 80-char snips are too coarse
181
+ statfs,
182
+ });
183
+
184
+ const skipSids = new Set(); // dual-write-authoritative sessions
185
+ const seenSids = new Set();
186
+ const sidSources = new Map(); // sid → Set<file name> (stamped into meta at promote)
187
+ // Cold-start hold replay: sid-less entries at a file head return a null
188
+ // handle (V2Writer parks them until the first sid appears); calling
189
+ // ingestCompletion right away would look up _lateHandles BEFORE that flush
190
+ // and silently drop the done line. Buffer those completions and replay them
191
+ // after the first successful ingest (real-data finding 2026-07-14). Bounded
192
+ // by the writer's own 64-entry hold queue.
193
+ const pendingDone = [];
194
+
195
+ try {
196
+ for (let i = 0; i < state.files.length; i++) {
197
+ const f = state.files[i];
198
+ if (f.done) continue;
199
+ if (shouldStop()) { // file-boundary stop: exact even when files are tiny
200
+ await writer.flush();
201
+ state.status = 'stopped';
202
+ state.finishedAt = new Date().toISOString();
203
+ writeConvertState(projectDir, state);
204
+ return state;
205
+ }
206
+ state.currentFile = f.name;
207
+ writeConvertState(projectDir, state);
208
+
209
+ const filePath = join(projectDir, f.name);
210
+ // Fresh per-file reconstructor: rotation resets the v1 delta state, so the
211
+ // first mainAgent entry of every file is a full checkpoint (interceptor.js
212
+ // checkAndRotateLogFile) — no cross-file reconstruction state is needed.
213
+ const reconstructor = createIncrementalReconstructor();
214
+ let sinceStopCheck = 0;
215
+
216
+ for await (const raw of iterateRawEntriesAsync(filePath)) {
217
+ if (++sinceStopCheck >= STOP_CHECK_EVERY) {
218
+ sinceStopCheck = 0;
219
+ if (shouldStop()) {
220
+ await writer.flush(); // stopped-state on disk must not be ahead of the writes it describes
221
+ state.status = 'stopped';
222
+ state.finishedAt = new Date().toISOString();
223
+ writeConvertState(projectDir, state);
224
+ return state;
225
+ }
226
+ }
227
+ let entry;
228
+ try { entry = JSON.parse(raw); } catch { continue; } // torn tail frame
229
+ if (entry.ccvRotationContext || entry.inProgress) continue;
230
+ reconstructor.reconstruct(entry);
231
+ if (entry._staleReorder || entry._reconstructBroken) continue; // poisoned twin; the good copy converts
232
+
233
+ const parsed = parseUserId(entry.body && entry.body.metadata && entry.body.metadata.user_id);
234
+ const sid = parsed ? parsed.sessionId : null;
235
+ if (sid) {
236
+ if (!seenSids.has(sid)) {
237
+ seenSids.add(sid);
238
+ // Task C: a live dual-written session's dir is `<ts>_<uuid>`, so
239
+ // match by UUID (resolve-by-scan), not a bare `sessions/<uuid>` path.
240
+ if (resolveSessionDirName(projectDir, sid)) {
241
+ skipSids.add(sid);
242
+ state.sessionsSkipped++;
243
+ } else {
244
+ state.sessionsConverted++;
245
+ }
246
+ }
247
+ if (skipSids.has(sid)) continue;
248
+ let src = sidSources.get(sid);
249
+ if (!src) sidSources.set(sid, src = new Set());
250
+ src.add(f.name);
251
+ }
252
+ // Metadata-less entries (heartbeat/countTokens) fall through to the
253
+ // writer's _currentSid routing — same semantics as the live path.
254
+ // Old entries (pre-requestId era) get a synthetic rid: the cold-start
255
+ // hold queue recovers completions by rid, and a null rid would leave
256
+ // held requests without their done line (real-data finding 2026-07-14).
257
+ if (!entry.requestId) entry.requestId = `cvt_${i}_${state.entries}`;
258
+ const handle = writer.ingestRequest(entry, entry.body && entry.body.messages);
259
+ if (handle) {
260
+ writer.ingestCompletion(handle, entry, { doneTs: doneTsOf(entry) });
261
+ if (pendingDone.length > 0) {
262
+ // The hold queue has flushed (a sid resolved) — late handles exist now.
263
+ for (const p of pendingDone.splice(0)) writer.ingestCompletion(null, p.entry, p.opts);
264
+ }
265
+ } else {
266
+ pendingDone.push({ entry, opts: { doneTs: doneTsOf(entry) } });
267
+ }
268
+ state.entries++;
269
+ }
270
+
271
+ await writer.flush(); // file checkpoint must not be ahead of its writes
272
+ f.done = true;
273
+ state.currentFile = null;
274
+ writeConvertState(projectDir, state);
275
+ onProgress({ phase: 'convert', file: f.name, fileIndex: i + 1, filesTotal: state.files.length, entries: state.entries, sessionsConverted: state.sessionsConverted, sessionsSkipped: state.sessionsSkipped });
276
+ }
277
+ await writer.flush();
278
+
279
+ // ---- golden verify (staging) — weak, per-session dependency -------------
280
+ // Verify no longer gates the whole batch: a diff/integrity violation flags
281
+ // only the SPECIFIC staged session that produced it (verify attributes every
282
+ // diff to its sessionId). Those sessions are quarantined below; everything
283
+ // clean still promotes. One bad session never aborts the migration
284
+ // (user 2026-07-15). `suspectSessions` is uncapped, so a >50-diff file still
285
+ // yields the complete quarantine set.
286
+ const quarantineSids = new Map(); // staged dir name → reasons[] (merged across files)
287
+ if (doVerify && listSessionIds(projectDir, STAGING_DIR_NAME).length > 0) {
288
+ state.status = 'verifying';
289
+ state.verifyTotal = state.files.length;
290
+ writeConvertState(projectDir, state);
291
+ // Per-entry progress: verifyEntries accumulates across files; state-file
292
+ // writes are time-throttled (≥1s) so a huge file can't turn the scan into
293
+ // an fsync storm. The per-FILE advance below is written unconditionally.
294
+ let entriesBase = 0;
295
+ let lastEntriesWrite = 0;
296
+ for (let i = 0; i < state.files.length; i++) {
297
+ const f = state.files[i];
298
+ let report = null;
299
+ try {
300
+ report = await verifyFn(join(projectDir, f.name), {
301
+ sessionsDirName: STAGING_DIR_NAME,
302
+ onProgress: ({ entriesScanned }) => {
303
+ state.verifyEntries = entriesBase + (entriesScanned || 0);
304
+ const now = Date.now();
305
+ if (now - lastEntriesWrite >= 1000) {
306
+ lastEntriesWrite = now;
307
+ writeConvertState(projectDir, state);
308
+ }
309
+ },
310
+ });
311
+ } catch (err) {
312
+ // Verify is a soft check — a crash in the verifier itself (e.g. an
313
+ // unreadable staged session) must not sink the migration. Skip this
314
+ // file's attribution and keep going; unverified sessions still promote.
315
+ reportSwallowed('v2-convert.verify', err);
316
+ }
317
+ if (report) {
318
+ for (const s of report.suspectSessions || []) {
319
+ const prev = quarantineSids.get(s.sessionId) || [];
320
+ quarantineSids.set(s.sessionId, [...prev, ...(s.reasons || [])].slice(0, 10));
321
+ }
322
+ }
323
+ // Advance the persisted counter even when verifyFn threw — x must reach
324
+ // n at the end of the loop, never skip numbers.
325
+ entriesBase = state.verifyEntries;
326
+ state.verifyIndex = i + 1;
327
+ writeConvertState(projectDir, state);
328
+ onProgress({ phase: 'verify', file: f.name, fileIndex: i + 1, filesTotal: state.files.length, entries: state.entries, sessionsConverted: state.sessionsConverted, sessionsSkipped: state.sessionsSkipped, quarantined: quarantineSids.size });
329
+ if (shouldStop()) {
330
+ state.status = 'stopped';
331
+ state.finishedAt = new Date().toISOString();
332
+ writeConvertState(projectDir, state);
333
+ return state;
334
+ }
335
+ }
336
+ }
337
+
338
+ // ---- promote staging → sessions/ (same volume, per-session atomic) ------
339
+ const stagingRoot = join(projectDir, STAGING_DIR_NAME);
340
+ const quarantineRoot = join(projectDir, QUARANTINE_DIR_NAME);
341
+ if (listSessionIds(projectDir, STAGING_DIR_NAME).length > 0) {
342
+ mkdirSync(join(projectDir, 'sessions'), { recursive: true }); // fresh project: parent may not exist yet
343
+ }
344
+ for (const sid of listSessionIds(projectDir, STAGING_DIR_NAME)) {
345
+ const staged = join(stagingRoot, sid);
346
+ // Task C: `sid` is the staged dir name `<ts>_<uuid>`. A LIVE dual-write dir
347
+ // for the same UUID may carry a DIFFERENT ts prefix, so detect the
348
+ // collision by UUID (from the staged meta.sessionId), not the exact name.
349
+ let stagedUuid = sid;
350
+ try { stagedUuid = JSON.parse(readFileSync(join(staged, 'meta.json'), 'utf-8')).sessionId || sid; } catch { /* fall back to name */ }
351
+ if (resolveSessionDirName(projectDir, stagedUuid)) {
352
+ // A dual-write session appeared for this uuid mid-conversion — live data
353
+ // wins (authoritative), so drop the staged copy even if it was flagged.
354
+ rmSync(staged, { recursive: true, force: true });
355
+ continue;
356
+ }
357
+ // Verify flagged this session: hold it in `sessions-quarantine/` for
358
+ // inspection instead of promoting corrupt data into the live view. The
359
+ // migration still completes; the user is told which sessions were held.
360
+ if (quarantineSids.has(sid)) {
361
+ mkdirSync(quarantineRoot, { recursive: true });
362
+ const held = join(quarantineRoot, sid);
363
+ rmSync(held, { recursive: true, force: true }); // idempotent across re-runs
364
+ renameSync(staged, held);
365
+ state.sessionsQuarantined++;
366
+ state.quarantined.push({ sessionId: sid, uuid: stagedUuid, reasons: quarantineSids.get(sid) });
367
+ continue;
368
+ }
369
+ const target = sessionPaths(logDir, project, sid).dir;
370
+ // sidSources is keyed by the UUID (ingest time), not the staged dir name.
371
+ const src = sidSources.get(stagedUuid);
372
+ if (src && src.size > 0) {
373
+ try {
374
+ const metaPath = join(staged, 'meta.json');
375
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
376
+ meta.sources = [...src].sort();
377
+ writeFileAtomicSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
378
+ } catch (err) {
379
+ reportSwallowed('v2-convert.meta-sources', err); // sources are advisory
380
+ }
381
+ }
382
+ renameSync(staged, target);
383
+ }
384
+ // Every staged session has been renamed away or dropped above — anything
385
+ // left is junk (.DS_Store from Finder browsing, real-data 2026-07-14).
386
+ // Guard on "no session dirs remain" before the recursive removal.
387
+ try {
388
+ if (existsSync(stagingRoot) && listSessionIds(projectDir, STAGING_DIR_NAME).length === 0) {
389
+ rmSync(stagingRoot, { recursive: true, force: true });
390
+ }
391
+ } catch { /* leftover staging dir is cosmetic — never fail a completed migration on it */ }
392
+
393
+ state.status = 'done';
394
+ state.finishedAt = new Date().toISOString();
395
+ writeConvertState(projectDir, state);
396
+ onProgress({ phase: 'done', filesTotal: state.files.length, entries: state.entries, sessionsConverted: state.sessionsConverted, sessionsSkipped: state.sessionsSkipped, quarantined: state.sessionsQuarantined });
397
+ return state;
398
+ } catch (err) {
399
+ if (state.status !== 'error') {
400
+ state.status = 'error';
401
+ state.lastError = String(err && err.message || err);
402
+ state.finishedAt = new Date().toISOString();
403
+ try { writeConvertState(projectDir, state); } catch { }
404
+ }
405
+ throw err;
406
+ } finally {
407
+ await writer.close();
408
+ }
409
+ }
@@ -0,0 +1,204 @@
1
+ // Wire Format v2 — identity resolution (docs/refactor/WIRE_FORMAT_V2.md §8/§10).
2
+ //
3
+ // Two jobs, both pure except for the small per-process state the caller owns:
4
+ // 1. parseUserId — extract session_id from body.metadata.user_id, which exists
5
+ // in TWO wire encodings (JSON string in newer CC; underscore-delimited in
6
+ // older CC — both present in real logs, verified during planning).
7
+ // 2. ConvResolver — derive a stable conversation key for every request:
8
+ // main / sub-<toolUseId> / sub-fp-<fingerprint> / misc, disambiguating
9
+ // parallel same-prompt subagents via the spawning Agent block's tool_use.id
10
+ // (which v1's prompt-prefix registry loses, plan risk #7).
11
+
12
+ // Matches v1's extractAgentSpawnPairs keying so both sides observe the same
13
+ // prefix (server/lib/interceptor-core.js TEAMMATE_PROMPT_PREFIX_LEN).
14
+ export const SPAWN_PROMPT_PREFIX_LEN = 60;
15
+
16
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+
18
+ /**
19
+ * Parse a raw metadata.user_id string into { sessionId, encoding } or null.
20
+ * Encodings (spec §8):
21
+ * - 'json': '{"device_id":…,"account_uuid":…,"session_id":"<uuid>"}'
22
+ * - 'delimited': 'user_<hash>_account_<acct?>_session_<uuid>'
23
+ */
24
+ export function parseUserId(userIdRaw) {
25
+ if (typeof userIdRaw !== 'string' || userIdRaw === '') return null;
26
+ try {
27
+ const obj = JSON.parse(userIdRaw);
28
+ if (obj && typeof obj.session_id === 'string' && obj.session_id !== '') {
29
+ return { sessionId: obj.session_id, encoding: 'json' };
30
+ }
31
+ return null; // valid JSON but no session_id — treat as unparseable
32
+ } catch { /* not JSON → try the delimited form */ }
33
+ const idx = userIdRaw.lastIndexOf('_session_');
34
+ if (idx >= 0) {
35
+ const tail = userIdRaw.slice(idx + '_session_'.length);
36
+ if (UUID_RE.test(tail)) return { sessionId: tail, encoding: 'delimited' };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ const REMINDER_OPEN = '<system-reminder>';
42
+ const REMINDER_CLOSE = '</system-reminder>';
43
+
44
+ /**
45
+ * Strip harness-injected <system-reminder>…</system-reminder> preambles from
46
+ * the head of a prompt. The harness prepends them to a subagent's first user
47
+ * message on the wire, but Agent/Task tool_use.input.prompt (the spawn-registry
48
+ * key) never contains them — without stripping, the prompt-prefix lookup gets
49
+ * 0 hits and every parallel sub shares one boilerplate fingerprint (real-data
50
+ * finding, plan "关键事实" 2026-07-14). Only leading reminders are removed:
51
+ * callers fingerprint the prompt START.
52
+ */
53
+ function stripLeadingReminders(text) {
54
+ let t = text;
55
+ for (;;) {
56
+ const s = t.trimStart();
57
+ if (!s.startsWith(REMINDER_OPEN)) return s;
58
+ const end = s.indexOf(REMINDER_CLOSE);
59
+ if (end < 0) return ''; // unterminated reminder — no usable prompt text
60
+ t = s.slice(end + REMINDER_CLOSE.length);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * First user message's prompt text (string or concatenated text blocks),
66
+ * with leading <system-reminder> preambles stripped and whitespace trimmed
67
+ * at the start.
68
+ */
69
+ export function firstUserPromptText(messages) {
70
+ if (!Array.isArray(messages)) return '';
71
+ for (const m of messages) {
72
+ if (!m || m.role !== 'user') continue;
73
+ const c = m.content;
74
+ if (typeof c === 'string') return stripLeadingReminders(c);
75
+ if (Array.isArray(c)) {
76
+ let text = '';
77
+ for (const block of c) {
78
+ if (block && block.type === 'text' && typeof block.text === 'string') text += block.text;
79
+ }
80
+ return stripLeadingReminders(text);
81
+ }
82
+ return '';
83
+ }
84
+ return '';
85
+ }
86
+
87
+ /** Cheap non-cryptographic fingerprint for the fp-fallback conv key. */
88
+ export function promptFingerprint(text) {
89
+ let h = 0x811c9dc5; // FNV-1a 32-bit
90
+ const s = String(text);
91
+ for (let i = 0; i < s.length; i++) {
92
+ h ^= s.charCodeAt(i);
93
+ h = (h * 0x01000193) >>> 0;
94
+ }
95
+ return h.toString(16).padStart(8, '0');
96
+ }
97
+
98
+ /**
99
+ * Conversation-key resolver for one v2 session. Owns:
100
+ * - spawn registry: prompt prefix → FIFO of unclaimed Agent tool_use.ids
101
+ * (a queue, not a single slot — parallel same-prompt spawns each hold their
102
+ * own id and are claimed in order, unlike v1's overwrite-on-collision map);
103
+ * - active sub-conversation states for continuity: an incoming sub request
104
+ * belongs to the existing conversation whose message count it extends.
105
+ */
106
+ export class ConvResolver {
107
+ constructor() {
108
+ this._spawnIds = new Map(); // promptPrefix → [toolUseId, ...] unclaimed FIFO
109
+ this._subs = new Map(); // convKey → { firstFp, count }
110
+ this._fpOrdinal = new Map(); // firstFp → next ordinal for fp-fallback keys
111
+ }
112
+
113
+ /**
114
+ * Register Agent-spawn pairs from a completed response body (assistant
115
+ * content blocks with type 'tool_use' and name 'Agent'/'Task'). Same keying
116
+ * idea as v1 extractAgentSpawnPairs but keeps the block id (spec §10) and
117
+ * covers 'Task' too (v1's extractor only handles 'Agent'; 'Task' is a
118
+ * recognized spawn tool elsewhere in the codebase, e.g. interceptor-core).
119
+ */
120
+ registerSpawns(responseBody) {
121
+ const content = responseBody && Array.isArray(responseBody.content) ? responseBody.content : null;
122
+ if (!content) return;
123
+ for (const block of content) {
124
+ if (!block || block.type !== 'tool_use') continue;
125
+ if (block.name !== 'Agent' && block.name !== 'Task') continue;
126
+ const prompt = block.input && typeof block.input.prompt === 'string' ? block.input.prompt : '';
127
+ if (!prompt || typeof block.id !== 'string' || block.id === '') continue;
128
+ const prefix = prompt.trimStart().slice(0, SPAWN_PROMPT_PREFIX_LEN);
129
+ if (!this._spawnIds.has(prefix)) this._spawnIds.set(prefix, []);
130
+ this._spawnIds.get(prefix).push(block.id);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Resolve the conv key for a non-main request's messages array.
136
+ * Continuity first (an existing sub whose count this request extends or
137
+ * re-sends), then a fresh key: claimed tool_use.id if the spawn registry has
138
+ * one for this prompt prefix, else fp-fallback with an ordinal suffix so
139
+ * parallel identical prompts without a registered id still split apart.
140
+ */
141
+ resolveSub(messages) {
142
+ const prompt = firstUserPromptText(messages);
143
+ const fp = promptFingerprint(prompt.trimStart().slice(0, 200));
144
+ const len = Array.isArray(messages) ? messages.length : 0;
145
+
146
+ // Continuity: same first-message fp AND the message count grows (or stays,
147
+ // for retries) relative to that conversation's last seen count. Prefer the
148
+ // candidate with the highest count still <= len (the closest ancestor).
149
+ // Same-length tie-break: while an UNCLAIMED spawn id exists for this prompt
150
+ // prefix, a same-length duplicate is a parallel sibling spawn, not a retry —
151
+ // claim the fresh id instead of stealing the sibling's conversation.
152
+ // Residual mis-attribution edges (accepted; keys stay distinct, only the
153
+ // label can be wrong): (1) a same-length RETRY racing an unclaimed sibling
154
+ // spawn of the identical prompt claims the sibling's id; (2) a fresh
155
+ // sibling whose first request already has MORE messages than an existing
156
+ // sibling's count extends that one instead of opening its own key.
157
+ const prefix = prompt.trimStart().slice(0, SPAWN_PROMPT_PREFIX_LEN);
158
+ const pendingSpawn = this._spawnIds.has(prefix);
159
+ let best = null;
160
+ for (const [key, st] of this._subs) {
161
+ if (st.firstFp !== fp) continue;
162
+ const extend = len > st.count || (len === st.count && !pendingSpawn);
163
+ if (extend && (!best || st.count > best.st.count)) best = { key, st };
164
+ }
165
+ if (best) {
166
+ best.st.count = len;
167
+ return { convKey: best.key, isNew: false };
168
+ }
169
+
170
+ // New sub conversation — prefer the spawning Agent block's tool_use.id.
171
+ const queue = this._spawnIds.get(prefix);
172
+ let convKey;
173
+ if (queue && queue.length > 0) {
174
+ convKey = `sub-${queue.shift().slice(-12)}`;
175
+ if (queue.length === 0) this._spawnIds.delete(prefix);
176
+ } else {
177
+ const n = (this._fpOrdinal.get(fp) || 0) + 1;
178
+ this._fpOrdinal.set(fp, n);
179
+ convKey = n === 1 ? `sub-fp-${fp}` : `sub-fp-${fp}-${n}`;
180
+ }
181
+ this._subs.set(convKey, { firstFp: fp, count: len });
182
+ return { convKey, isNew: true };
183
+ }
184
+
185
+ reset() {
186
+ this._spawnIds.clear();
187
+ this._subs.clear();
188
+ this._fpOrdinal.clear();
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Classify a request entry into a journal `kind` (spec §4). `entry` is the v1
194
+ * requestEntry shape at initiation time (isHeartbeat/isCountTokens/mainAgent/
195
+ * teammate already stamped by the interceptor).
196
+ */
197
+ export function classifyKind(entry) {
198
+ if (entry.isHeartbeat) return 'heartbeat';
199
+ if (entry.isCountTokens) return 'countTokens';
200
+ if (entry.teammate) return 'teammate';
201
+ if (entry.mainAgent) return 'main';
202
+ if (entry.body && Array.isArray(entry.body.messages)) return 'sub';
203
+ return 'misc';
204
+ }
@@ -0,0 +1,62 @@
1
+ // Wire Format v2 — journal writer (docs/refactor/WIRE_FORMAT_V2.md §4).
2
+ //
3
+ // The journal is the single ordering axis. Two-phase, append-only:
4
+ // req line — built in the SAME synchronous segment as the request initiation
5
+ // (the caller allocates seq via nextSeq() there; enqueuing later
6
+ // is fine, MUTATING history lines is not);
7
+ // done line — on completion/error.
8
+ // The reader folds the two phases by seq; a req without a done is in-flight
9
+ // (or the process died) — that IS the v1 placeholder, minus the duplicated body.
10
+
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+
13
+ export class Journal {
14
+ /**
15
+ * @param {object} paths - sessionPaths() object
16
+ * @param {{appendTo: Function}} queue - AsyncWriteQueue (explicit-path API)
17
+ */
18
+ constructor(paths, queue) {
19
+ this._paths = paths;
20
+ this._queue = queue;
21
+ // Seed from any existing journal so seq stays monotonic per session FILE,
22
+ // not per Journal instance (spec §4 "session 内单调"). Without this, a
23
+ // session-object recreation onto the same dir (workspace switch A→B→A,
24
+ // resetSessions, process re-attach) would restart at 1 and collide with
25
+ // prior lines — the reader folds by seq, so collisions silently corrupt.
26
+ // Journal lines are small; a one-time full read at session bind is cheap.
27
+ this._seq = Journal._maxSeqIn(paths.journalPath);
28
+ }
29
+
30
+ static _maxSeqIn(journalPath) {
31
+ try {
32
+ if (!existsSync(journalPath)) return 0;
33
+ let max = 0;
34
+ // Regex scan instead of per-line JSON.parse: tolerant of a truncated
35
+ // tail line and ~10x cheaper on large journals.
36
+ for (const m of readFileSync(journalPath, 'utf-8').matchAll(/"seq":\s*(\d+)/g)) {
37
+ const n = Number(m[1]);
38
+ if (n > max) max = n;
39
+ }
40
+ return max;
41
+ } catch {
42
+ return 0; // unreadable journal → fresh numbering beats crashing the writer
43
+ }
44
+ }
45
+
46
+ /** Allocate the next initiation-order seq. MUST be called in the request's
47
+ * synchronous initiation segment (§3.7 guard — completion order must never
48
+ * become the semantic order). */
49
+ nextSeq() {
50
+ return ++this._seq;
51
+ }
52
+
53
+ /** Enqueue a req-phase line. `fields` carries the spec §4 req schema minus ph. */
54
+ writeReq(fields, onDone) {
55
+ this._queue.appendTo(this._paths.journalPath, JSON.stringify({ ph: 'req', ...fields }) + '\n', onDone);
56
+ }
57
+
58
+ /** Enqueue a done-phase line. */
59
+ writeDone(fields, onDone) {
60
+ this._queue.appendTo(this._paths.journalPath, JSON.stringify({ ph: 'done', ...fields }) + '\n', onDone);
61
+ }
62
+ }