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.
- package/README.md +13 -1
- package/cli.js +116 -37
- package/dist/assets/App-gUSmU6Ny.js +2 -0
- package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-DAR39A3S.js} +1 -1
- package/dist/assets/Mobile-qfRsRth0.js +1 -0
- package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
- package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
- package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
- package/dist/assets/clone-BQtvy9Ae.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
- package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
- package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
- package/dist/assets/index-B51Ci0GL.js +2 -0
- package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
- package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
- package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
- package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
- package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
- package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
- package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
- package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
- package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
- package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
- package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
- package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
- package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
- package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
- package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
- package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
- package/dist/index.html +4 -4
- package/findcc.js +4 -3
- package/package.json +3 -1
- package/server/i18n.js +21 -141
- package/server/interceptor.js +211 -403
- package/server/lib/async-write-queue.js +12 -1
- package/server/lib/delta-reconstructor.js +3 -3
- package/server/lib/error-report.js +45 -0
- package/server/lib/im-log-watcher.js +68 -8
- package/server/lib/interceptor-core.js +19 -174
- package/server/lib/log-management.js +214 -247
- package/server/lib/log-stream.js +59 -108
- package/server/lib/log-watcher.js +62 -274
- package/server/lib/log-zip.js +148 -0
- package/server/lib/session-boundary.js +266 -0
- package/server/lib/session-pin-store.js +10 -19
- package/server/lib/stats-worker.js +138 -267
- package/server/lib/user-prompt-extract.js +250 -0
- package/server/lib/v2/adapter.js +1124 -0
- package/server/lib/v2/blob-store.js +44 -0
- package/server/lib/v2/conversation-store.js +217 -0
- package/server/lib/v2/convert-manager.js +79 -0
- package/server/lib/v2/convert-worker.js +42 -0
- package/server/lib/v2/convert.js +409 -0
- package/server/lib/v2/identity.js +204 -0
- package/server/lib/v2/journal.js +62 -0
- package/server/lib/v2/layout.js +167 -0
- package/server/lib/v2/live-feed.js +558 -0
- package/server/lib/v2/meta-rows.js +360 -0
- package/server/lib/v2/migrate-prompt.js +54 -0
- package/server/lib/v2/replay.js +191 -0
- package/server/lib/v2/session-select.js +222 -0
- package/server/lib/v2/singleflight.js +104 -0
- package/server/lib/v2/v2-writer.js +528 -0
- package/server/lib/v2/verify.js +233 -0
- package/server/lib/wire-compress.js +167 -0
- package/server/routes/events.js +120 -150
- package/server/routes/files-fs.js +4 -2
- package/server/routes/im.js +11 -2
- package/server/routes/logs.js +209 -164
- package/server/routes/preferences.js +0 -3
- package/server/routes/project-meta.js +2 -3
- package/server/routes/session-pin.js +7 -8
- package/server/routes/v2.js +60 -0
- package/server/routes/workspaces.js +50 -14
- package/server/server.js +70 -66
- package/server/workspace-registry.js +24 -4
- package/src/utils/apiUrl.js +26 -0
- package/src/utils/askFallback.js +21 -0
- package/src/utils/askOptionDesc.js +55 -0
- package/src/utils/askPortalMatcher.js +54 -0
- package/src/utils/autoApproveOptions.js +27 -0
- package/src/utils/avatarAnimationPostPass.js +49 -0
- package/src/utils/builtinPresets.js +18 -0
- package/src/utils/clearCheckpoint.js +7 -0
- package/src/utils/commandValidator.js +45 -0
- package/src/utils/contentFilter.js +620 -0
- package/src/utils/contextRaw.js +18 -0
- package/src/utils/contextSidebarNavigation.js +16 -0
- package/src/utils/contextTurns.js +117 -0
- package/src/utils/displayScaleHelper.js +47 -0
- package/src/utils/effectiveModel.js +13 -0
- package/src/utils/entry-slim.js +563 -0
- package/src/utils/entryCache.js +144 -0
- package/src/utils/errorReport.js +6 -0
- package/src/utils/fileExpandedPathsStorage.js +70 -0
- package/src/utils/fileIcons.jsx +36 -0
- package/src/utils/fileOpen.js +37 -0
- package/src/utils/formatters.js +48 -0
- package/src/utils/gitApi.js +50 -0
- package/src/utils/gitTreeBuilder.js +19 -0
- package/src/utils/helpers.js +846 -0
- package/src/utils/identityHeal.js +99 -0
- package/src/utils/imConnState.js +59 -0
- package/src/utils/imOrigin.js +24 -0
- package/src/utils/imTr.js +7 -0
- package/src/utils/imageCompress.js +83 -0
- package/src/utils/imageDownscale.js +28 -0
- package/src/utils/imageResize.js +112 -0
- package/src/utils/ingestPipeline.js +47 -0
- package/src/utils/lazyWithReload.js +53 -0
- package/src/utils/markdown.js +30 -0
- package/src/utils/markdownIncremental.js +39 -0
- package/src/utils/markdownProfiler.js +113 -0
- package/src/utils/mdExtensionDetect.js +64 -0
- package/src/utils/memoryLinkParser.js +44 -0
- package/src/utils/modalMask.js +15 -0
- package/src/utils/monotime.js +6 -0
- package/src/utils/pinnedMenu.js +55 -0
- package/src/utils/presetShortcuts.js +14 -0
- package/src/utils/projectAlias.js +135 -0
- package/src/utils/promptClassifier.js +112 -0
- package/src/utils/promptDetect.js +196 -0
- package/src/utils/promptNav.js +60 -0
- package/src/utils/ptyChunkBuilder.js +251 -0
- package/src/utils/quickMenuHoverIntent.js +35 -0
- package/src/utils/rateLimitParser.js +119 -0
- package/src/utils/readResultPool.js +106 -0
- package/src/utils/refreshCachedItemProp.js +30 -0
- package/src/utils/requestType.js +208 -0
- package/src/utils/resizeCalc.js +21 -0
- package/src/utils/resolveLocalized.js +17 -0
- package/src/utils/searchApi.js +47 -0
- package/src/utils/searchReplace.js +51 -0
- package/src/utils/seqResourceLoaders.js +79 -0
- package/src/utils/sessionManager.js +471 -0
- package/src/utils/sessionMerge.js +180 -0
- package/src/utils/skillModalController.js +111 -0
- package/src/utils/skillsParser.js +116 -0
- package/src/utils/slashCommandLabels.js +80 -0
- package/src/utils/splitDragCalc.js +73 -0
- package/src/utils/stickyBottomController.js +643 -0
- package/src/utils/svgSanitize.js +48 -0
- package/src/utils/systemTags.js +47 -0
- package/src/utils/tClaude.js +20 -0
- package/src/utils/teamModalBuilder.js +351 -0
- package/src/utils/teamSessionParser.js +190 -0
- package/src/utils/teammateAvatars.js +194 -0
- package/src/utils/teammateDetector.js +121 -0
- package/src/utils/termDiag.js +114 -0
- package/src/utils/terminalClipboard.js +117 -0
- package/src/utils/terminalWriteQueue.js +398 -0
- package/src/utils/thinkingMerge.js +49 -0
- package/src/utils/toolCatalog.js +29 -0
- package/src/utils/toolResultBuilder.js +393 -0
- package/src/utils/toolResultClassifier.js +16 -0
- package/src/utils/toolResultCore.js +167 -0
- package/src/utils/toolsDiff.js +44 -0
- package/src/utils/toolsXmlFormatter.js +3 -0
- package/src/utils/ultraplanController.js +109 -0
- package/src/utils/ultraplanExperts.js +86 -0
- package/src/utils/ultraplanTemplates.js +154 -0
- package/src/utils/userImageRefs.js +49 -0
- package/src/utils/v3Assembler.js +112 -0
- package/src/utils/v3Rows.js +48 -0
- package/src/utils/voicePackPlayer.js +233 -0
- package/src/utils/webSearchGrouping.js +111 -0
- package/src/utils/workflowFormat.js +52 -0
- package/src/utils/workflowRuns.js +118 -0
- package/src/utils/workflowStore.js +94 -0
- package/dist/assets/App-VTo4NVdo.js +0 -2
- package/dist/assets/Mobile-CcmYvMm_.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
- package/dist/assets/clone-md-Mc1-T.js +0 -1
- package/dist/assets/index-DCo650PG.js +0 -2
- package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
- package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
- package/server/lib/instance-registry.js +0 -57
- package/server/lib/jsonl-archive.js +0 -180
- package/server/lib/teammate-detect.js +0 -32
- package/server/lib/zip-safety.js +0 -68
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// wire-v2 S6a: extract an uploaded v2-session zip into a temp dir so the read
|
|
2
|
+
// adapter can synthesize (and stream) it for ephemeral viewing.
|
|
3
|
+
//
|
|
4
|
+
// A v2 session is a FOLDER (meta.json + journal.jsonl + conversations/ + blobs/
|
|
5
|
+
// + responses.jsonl). The download side (`/api/download-log?...&format=raw`)
|
|
6
|
+
// zips that folder wrapped under `<dirName>/`; this module is the inverse.
|
|
7
|
+
//
|
|
8
|
+
// Security posture builds on server/routes/skills.js's zip import: INVALID_ZIP
|
|
9
|
+
// guard, symlink rejection (S_IFLNK mask), an entry-count ceiling, and a
|
|
10
|
+
// zip-slip resolve-within-target guard. It goes one step further than skills.js
|
|
11
|
+
// on decompression bombs: instead of adm-zip's uncapped `getData()` (which
|
|
12
|
+
// inflates a whole member into heap BEFORE any size check — a lying header can
|
|
13
|
+
// OOM the process), deflate members are inflated through Node's zlib with
|
|
14
|
+
// `maxOutputLength: maxPerFile`, so the allocation is bounded up front and an
|
|
15
|
+
// over-cap member throws ZIP_BOMB before it can balloon.
|
|
16
|
+
// The extraction is placed under `<destParent>/<SESSIONS_DIR_NAME>/<sid>/` on
|
|
17
|
+
// purpose: the adapter re-derives its journal path as
|
|
18
|
+
// `dirname(dirname(sessionDir))/sessions/basename(sessionDir)` (adapter.js /
|
|
19
|
+
// replay.js), so a bare dir would silently synthesize zero entries — hence the
|
|
20
|
+
// shared SESSIONS_DIR_NAME constant rather than a divergent literal.
|
|
21
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
23
|
+
import zlib from 'node:zlib';
|
|
24
|
+
import AdmZip from 'adm-zip';
|
|
25
|
+
import { sanitizePathComponent, SESSIONS_DIR_NAME } from './v2/layout.js';
|
|
26
|
+
|
|
27
|
+
export const MAX_PER_FILE = 100 * 1024 * 1024; // 100MB per member
|
|
28
|
+
export const MAX_TOTAL_UNCOMPRESSED = 400 * 1024 * 1024; // 400MB expanded
|
|
29
|
+
export const MAX_ENTRY_COUNT = 100_000; // v2 sessions have many blob files; bomb guard
|
|
30
|
+
|
|
31
|
+
function err(message, status, code) {
|
|
32
|
+
return Object.assign(new Error(message), { status, code });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read one member's bytes with a bounded allocation. Deflate members are
|
|
37
|
+
* inflated through zlib with `maxOutputLength: cap` so a spoofed-small header
|
|
38
|
+
* can't force an unbounded heap spike (adm-zip's getData() has no such cap);
|
|
39
|
+
* an over-cap member throws ZIP_BOMB, a corrupt stream throws INVALID_ZIP
|
|
40
|
+
* (400, not a 500). Stored members return their raw payload; any other method
|
|
41
|
+
* falls back to adm-zip and is length-checked afterward.
|
|
42
|
+
*/
|
|
43
|
+
export function boundedGetData(e, cap) {
|
|
44
|
+
const method = e.header?.method;
|
|
45
|
+
if (method === 8) { // deflate
|
|
46
|
+
try {
|
|
47
|
+
return zlib.inflateRawSync(e.getCompressedData(), { maxOutputLength: cap });
|
|
48
|
+
} catch (er) {
|
|
49
|
+
if (er?.code === 'ERR_BUFFER_TOO_LARGE') throw err('File actual size too large', 400, 'ZIP_BOMB');
|
|
50
|
+
throw err('Invalid zip archive', 400, 'INVALID_ZIP');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const data = method === 0 ? e.getCompressedData() : e.getData();
|
|
54
|
+
if (data.length > cap) throw err('File actual size too large', 400, 'ZIP_BOMB');
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract an uploaded v2-session zip buffer into `<destParent>/sessions/<sid>/`.
|
|
60
|
+
* Validates the archive and that it actually contains a v2 session (a
|
|
61
|
+
* `journal.jsonl`). Throws `{ status, code }` errors for the caller to map to
|
|
62
|
+
* HTTP. Returns the absolute path of the nested session dir, ready to hand to
|
|
63
|
+
* `streamRawEntriesAsync`.
|
|
64
|
+
*
|
|
65
|
+
* @param {Buffer} buffer the raw zip bytes
|
|
66
|
+
* @param {string} destParent a caller-owned temp dir (e.g. from mkdtempSync)
|
|
67
|
+
* @param {{maxPerFile?:number,maxTotal?:number,maxEntryCount?:number}} [limits]
|
|
68
|
+
* size/count ceilings; default to the module constants (overridable for tests).
|
|
69
|
+
* @returns {string} absolute session dir path (`<destParent>/sessions/<sid>`)
|
|
70
|
+
*/
|
|
71
|
+
export function extractV2Zip(buffer, destParent, limits = {}) {
|
|
72
|
+
const maxPerFile = limits.maxPerFile ?? MAX_PER_FILE;
|
|
73
|
+
const maxTotal = limits.maxTotal ?? MAX_TOTAL_UNCOMPRESSED;
|
|
74
|
+
const maxEntryCount = limits.maxEntryCount ?? MAX_ENTRY_COUNT;
|
|
75
|
+
// adm-zip parses lazily — a corrupt-but-constructable buffer throws at
|
|
76
|
+
// getEntries(), not at the constructor, so both must be under the guard
|
|
77
|
+
// (else a bad upload surfaces as a 500 instead of a 400).
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = new AdmZip(buffer).getEntries();
|
|
81
|
+
} catch {
|
|
82
|
+
throw err('Invalid zip archive', 400, 'INVALID_ZIP');
|
|
83
|
+
}
|
|
84
|
+
if (entries.length > maxEntryCount) {
|
|
85
|
+
throw err('Too many entries in archive', 400, 'ZIP_BOMB');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Pass 1: symlink reject + size guard against the (attacker-controllable)
|
|
89
|
+
// central-directory sizes.
|
|
90
|
+
let declaredTotal = 0;
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
if (e.isDirectory) continue;
|
|
93
|
+
const unixMode = (e.attr >>> 16) & 0xffff;
|
|
94
|
+
if ((unixMode & 0o170000) === 0o120000) {
|
|
95
|
+
throw err('Symlinks not allowed in zip', 400, 'INVALID_ZIP');
|
|
96
|
+
}
|
|
97
|
+
const sizeRaw = e.header?.size || 0;
|
|
98
|
+
if (sizeRaw > maxPerFile) throw err('File too large in archive', 400, 'ZIP_BOMB');
|
|
99
|
+
declaredTotal += sizeRaw;
|
|
100
|
+
if (declaredTotal > maxTotal) throw err('Archive expands too large', 400, 'ZIP_BOMB');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Locate the session root = the shallowest dir holding `journal.jsonl`
|
|
104
|
+
// (handles both a wrapped `<dir>/journal.jsonl` and a bare-root layout).
|
|
105
|
+
let bestEntry = null;
|
|
106
|
+
let bestDepth = Infinity;
|
|
107
|
+
for (const e of entries) {
|
|
108
|
+
if (e.isDirectory) continue;
|
|
109
|
+
const en = e.entryName.replace(/\\/g, '/');
|
|
110
|
+
if ((en.split('/').pop() || '') === 'journal.jsonl') {
|
|
111
|
+
const depth = en.split('/').length;
|
|
112
|
+
if (depth < bestDepth) { bestDepth = depth; bestEntry = e; }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!bestEntry) throw err('Not a v2 session (no journal.jsonl)', 400, 'NOT_V2');
|
|
116
|
+
|
|
117
|
+
const rootEntry = bestEntry.entryName.replace(/\\/g, '/');
|
|
118
|
+
const lastSlash = rootEntry.lastIndexOf('/');
|
|
119
|
+
const rootPrefix = lastSlash >= 0 ? rootEntry.slice(0, lastSlash + 1) : '';
|
|
120
|
+
const sidRaw = rootPrefix ? rootPrefix.replace(/\/$/, '').split('/').pop() : 'uploaded-session';
|
|
121
|
+
const sid = sanitizePathComponent(sidRaw) || 'uploaded-session';
|
|
122
|
+
|
|
123
|
+
const sessionDir = join(destParent, SESSIONS_DIR_NAME, sid);
|
|
124
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
125
|
+
const resolvedTarget = resolve(sessionDir) + sep;
|
|
126
|
+
|
|
127
|
+
// Pass 2: extract members under the session root with bounded inflation, and
|
|
128
|
+
// enforce the zip-slip containment guard.
|
|
129
|
+
let actualTotal = 0;
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
if (e.isDirectory) continue;
|
|
132
|
+
const en = e.entryName.replace(/\\/g, '/');
|
|
133
|
+
if (rootPrefix && !en.startsWith(rootPrefix)) continue;
|
|
134
|
+
const rel = rootPrefix ? en.slice(rootPrefix.length) : en;
|
|
135
|
+
// Reject a real `..` path SEGMENT (not a substring — a legit `a..b.json`
|
|
136
|
+
// name must survive); the resolve guard below is the belt-and-suspenders.
|
|
137
|
+
if (!rel || rel.split('/').includes('..')) continue;
|
|
138
|
+
const data = boundedGetData(e, maxPerFile);
|
|
139
|
+
actualTotal += data.length;
|
|
140
|
+
if (actualTotal > maxTotal) throw err('Archive actual size too large', 400, 'ZIP_BOMB');
|
|
141
|
+
const dest = join(sessionDir, rel);
|
|
142
|
+
if (!resolve(dest).startsWith(resolvedTarget)) continue; // zip-slip
|
|
143
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
144
|
+
writeFileSync(dest, data);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return sessionDir;
|
|
148
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// CLIENT-SAFE: no node deps. Imported by src/ — do not add fs/process/node: imports.
|
|
2
|
+
// (Same contract as delta-reconstructor.js.)
|
|
3
|
+
//
|
|
4
|
+
// Shared session-boundary + reverse-anchor module — single source of truth for
|
|
5
|
+
// "where does a logical conversation start/align" used by BOTH sides:
|
|
6
|
+
// - client: src/utils/clearCheckpoint.js (re-export shell), sessionMerge.js,
|
|
7
|
+
// sessionManager.js, entry-slim.js, contentFilter.js
|
|
8
|
+
// - server (wire-v2, S2+): conversation-store epoch routing and the read-side
|
|
9
|
+
// materializer (docs/refactor/WIRE_FORMAT_V2.md §6/§9)
|
|
10
|
+
//
|
|
11
|
+
// Functions moved VERBATIM (comments included) from src/utils/clearCheckpoint.js
|
|
12
|
+
// (isPostClearCheckpoint / isCompactContinuation / isSessionBoundary) and
|
|
13
|
+
// src/utils/sessionMerge.js (messageFingerprint / findReverseAnchor) in wire-v2
|
|
14
|
+
// step S1 — see docs/refactor/WIRE_FORMAT_V2_PLAN.md. Do not fork copies; the
|
|
15
|
+
// intentionally-divergent bigDrop mirrors in entry-slim.js stay where they are.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 检测一个 mainAgent entry 是否是 /clear 之后的首个 checkpoint。
|
|
19
|
+
*
|
|
20
|
+
* 单独抽成无依赖的模块(不引 contentFilter,避免 node --test 走 bare import 失败)。
|
|
21
|
+
*
|
|
22
|
+
* 必要条件三选三:
|
|
23
|
+
* 1. entry._isCheckpoint === true(delta 重建器认为这是一个完整快照)
|
|
24
|
+
* 2. body.messages.length 比 prevMessageCount 小(真正"缩短",排除增量再快照)
|
|
25
|
+
* 3. msg[0] 是 user 消息且含 `<command-name>/clear</command-name>` 标记
|
|
26
|
+
*
|
|
27
|
+
* 用于 _processEntries / sessionMerge 区分真实 /clear 起点 vs 普通 /compact 缩短。
|
|
28
|
+
* /compact 的 msg[0] 是 summary,没有 /clear 标记,自然返回 false。
|
|
29
|
+
*
|
|
30
|
+
* @param {object} entry
|
|
31
|
+
* @param {number} [prevMessageCount=0]
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isPostClearCheckpoint(entry, prevMessageCount = 0) {
|
|
35
|
+
if (!entry || entry._isCheckpoint !== true) return false;
|
|
36
|
+
const msgs = entry.body && entry.body.messages;
|
|
37
|
+
if (!Array.isArray(msgs) || msgs.length === 0) return false;
|
|
38
|
+
if (prevMessageCount > 0 && msgs.length >= prevMessageCount) return false;
|
|
39
|
+
const m0 = msgs[0];
|
|
40
|
+
if (!m0 || m0.role !== 'user' || !Array.isArray(m0.content)) return false;
|
|
41
|
+
for (let i = 0; i < m0.content.length; i++) {
|
|
42
|
+
const block = m0.content[i];
|
|
43
|
+
if (block && block.type === 'text' && typeof block.text === 'string' &&
|
|
44
|
+
block.text.indexOf('<command-name>/clear</command-name>') !== -1) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// /compact 摘要续写检测:CLI 在 /compact(手动或自动 auto-compact)后,把整段历史压成一条
|
|
52
|
+
// summary 作为新 msg[0] 重新起流。其 msg[0] 是 CLI 合成的 summary/continuation prompt,
|
|
53
|
+
// 匹配下面两种固定开头之一(与 contentFilter.js 的 Compact 合成 prompt 判据同源,此处内联
|
|
54
|
+
// 一份纯正则以保持本模块零依赖,可被 node --test 直接 bare import)。
|
|
55
|
+
//
|
|
56
|
+
// 用途:区分「大幅缩短的 mainAgent checkpoint」到底是——
|
|
57
|
+
// (a) /compact 续写:msg[0] 命中本判据 → 属【同一会话延续】,不应触发新会话切换;
|
|
58
|
+
// (b) 全新终端会话:msg[0] 是用户真实首个输入 → 不命中 → 属【新会话起点】。
|
|
59
|
+
// 在同机器多终端场景下 user_id(device_id+account_uuid)完全相同,无法据此区分会话,
|
|
60
|
+
// 故本判据是「大幅缩短」信号下把 /compact 和真·新会话拆开的唯一可靠依据。
|
|
61
|
+
const COMPACT_SUMMARY_RE = /^(Your task is to create a detailed summary of the conversation|This session is being continued from a previous conversation)/i;
|
|
62
|
+
export function isCompactContinuation(entry) {
|
|
63
|
+
const msgs = entry && entry.body && entry.body.messages;
|
|
64
|
+
if (!Array.isArray(msgs) || msgs.length === 0) return false;
|
|
65
|
+
const m0 = msgs[0];
|
|
66
|
+
if (!m0 || m0.role !== 'user') return false;
|
|
67
|
+
let text = '';
|
|
68
|
+
if (typeof m0.content === 'string') {
|
|
69
|
+
text = m0.content;
|
|
70
|
+
} else if (Array.isArray(m0.content)) {
|
|
71
|
+
for (let i = 0; i < m0.content.length; i++) {
|
|
72
|
+
const block = m0.content[i];
|
|
73
|
+
if (block && block.type === 'text' && typeof block.text === 'string') {
|
|
74
|
+
text += block.text;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return COMPACT_SUMMARY_RE.test(text.trimStart());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Shared session-boundary predicate — the single source of truth for "does this
|
|
83
|
+
* mainAgent entry start a NEW logical session?", used by BOTH the batch path
|
|
84
|
+
* (applyBatchEntryTimestamps → _processOneEntry) and the live SSE path
|
|
85
|
+
* (_flushPendingEntries). Keeping the two paths on one predicate guarantees the
|
|
86
|
+
* session segmentation (and thus each session's stable id = messages[0]._timestamp)
|
|
87
|
+
* is identical live and after a reload, which the "only show current session"
|
|
88
|
+
* pin depends on.
|
|
89
|
+
*
|
|
90
|
+
* Rules:
|
|
91
|
+
* 1. Post-/clear checkpoint → always a boundary (bypasses everything else).
|
|
92
|
+
* 2. Big message-count drop (count < 50% of prev AND drop > 4) → boundary,
|
|
93
|
+
* UNLESS the entry is a /compact continuation. Slimmed entries have
|
|
94
|
+
* body.messages emptied, so isCompactContinuation() can no longer see the
|
|
95
|
+
* summary — the slimmer stamps `entry._compactContinuation` beforehand
|
|
96
|
+
* (entry-slim.js) and we trust that flag here.
|
|
97
|
+
* 3. user_id change with an established previous session (prevCount > 0) →
|
|
98
|
+
* boundary (different device/account writing into the same log).
|
|
99
|
+
* 4. (task B) v2 `_seqEpoch` change → boundary. A different session id is a
|
|
100
|
+
* definitive boundary the count/user heuristics miss when a SHORT prior
|
|
101
|
+
* session precedes the current one (cold-load fallback → live supersede).
|
|
102
|
+
* Three-part guard: only when BOTH sides carry an epoch and they differ,
|
|
103
|
+
* so v1 leftovers (no epoch) and epoch-less entries fall through unchanged.
|
|
104
|
+
*
|
|
105
|
+
* KEEP IN SYNC: the bigDrop formula (rule 2, minus the compact exclusion) is
|
|
106
|
+
* mirrored in entry-slim.js's three slimmer predicates (process/finalize/
|
|
107
|
+
* incremental), which stay intentionally divergent for restore-guard reasons —
|
|
108
|
+
* tune the 0.5 ratio or the >4 drop threshold in all four places together.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} entry - mainAgent entry (may be _slimmed)
|
|
111
|
+
* @param {object} ctx
|
|
112
|
+
* @param {number} ctx.prevCount - message count accumulated before this entry
|
|
113
|
+
* @param {number} ctx.count - this entry's message count
|
|
114
|
+
* @param {string|null} ctx.prevUserId - user_id of the previous entry/session
|
|
115
|
+
* @param {string|null} ctx.userId - this entry's user_id
|
|
116
|
+
* @param {string|null} [ctx.prevEpoch] - _seqEpoch of the previous session
|
|
117
|
+
* @param {string|null} [ctx.epoch] - this entry's _seqEpoch
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
export function isSessionBoundary(entry, { prevCount, count, prevUserId, userId, prevEpoch, epoch }) {
|
|
121
|
+
if (isPostClearCheckpoint(entry, prevCount)) return true;
|
|
122
|
+
// Epoch change is a definitive boundary (bypasses count/user heuristics).
|
|
123
|
+
if (epoch && prevEpoch && epoch !== prevEpoch) return true;
|
|
124
|
+
const bigDrop = prevCount > 0 && count < prevCount * 0.5 && (prevCount - count) > 4;
|
|
125
|
+
const compactLike = (entry && entry._compactContinuation === true) || isCompactContinuation(entry);
|
|
126
|
+
if (bigDrop && !compactLike) return true;
|
|
127
|
+
if (prevCount > 0 && prevUserId && userId && userId !== prevUserId) return true;
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Equality normalization (wire-v2 S4 decision 2026-07-14): the CC client
|
|
133
|
+
* migrates ttl-based cache_control breakpoints onto OLD messages
|
|
134
|
+
* mid-conversation, rewriting string content in place into a block array
|
|
135
|
+
* carrying cache_control. v1's delta format cannot express an in-place
|
|
136
|
+
* old-message mutation, and no consumer depends on cache_control placement —
|
|
137
|
+
* so EQUALITY judgements (verify's messagesDigest, the converter's exactFps)
|
|
138
|
+
* normalize each message in two steps before comparing:
|
|
139
|
+
* 1. string content → [{type:'text',text}] (unified form, so
|
|
140
|
+
* messageFingerprint's `s|` / `t|` markers no longer fork on migration);
|
|
141
|
+
* 2. strip cache_control from top-level content blocks.
|
|
142
|
+
* NEVER use this on the storage path — persisted events keep the wire verbatim.
|
|
143
|
+
*/
|
|
144
|
+
export function normalizeMsgForEquality(msg) {
|
|
145
|
+
try {
|
|
146
|
+
if (!msg || typeof msg !== 'object') return msg;
|
|
147
|
+
let c = msg.content;
|
|
148
|
+
if (typeof c === 'string') {
|
|
149
|
+
c = [{ type: 'text', text: c }];
|
|
150
|
+
} else if (Array.isArray(c)) {
|
|
151
|
+
let changed = false;
|
|
152
|
+
const out = c.map((b) => {
|
|
153
|
+
if (b && typeof b === 'object' && b.cache_control !== undefined) {
|
|
154
|
+
changed = true;
|
|
155
|
+
const { cache_control, ...rest } = b;
|
|
156
|
+
return rest;
|
|
157
|
+
}
|
|
158
|
+
return b;
|
|
159
|
+
});
|
|
160
|
+
if (!changed) return msg; // common path: no allocation
|
|
161
|
+
c = out;
|
|
162
|
+
} else {
|
|
163
|
+
return msg;
|
|
164
|
+
}
|
|
165
|
+
return { ...msg, content: c };
|
|
166
|
+
} catch {
|
|
167
|
+
return msg; // malicious getter etc.: fall back to the original, same policy as messageFingerprint
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 计算消息的轻量内容指纹,用于「反向锚点」对齐:以 `newMessages[0]` 的 fp 为锚,
|
|
173
|
+
* 从 `curMsgs` 末尾向头部反向扫,配合多块连续等价校验决定 append / no-op / rebuild。
|
|
174
|
+
*
|
|
175
|
+
* 关键点:
|
|
176
|
+
* - tool_use / tool_result 用 API 强保证唯一的 id 作主键,永不碰撞。
|
|
177
|
+
* - text / thinking 用 `length + first32 + last32` 三元组——比单纯 `slice(0, 64)`
|
|
178
|
+
* 抗碰撞强得多(同前缀 `<system-reminder>...` / `<command-name>/...` 不会再误命中),
|
|
179
|
+
* 但仍只触一次字符串切片,比哈希便宜。
|
|
180
|
+
* - 保持纯函数 / 同步 / 无副作用——`mergeMainAgentSessions` 在流式热路径每条 SSE 都会调用。
|
|
181
|
+
*/
|
|
182
|
+
export function messageFingerprint(msg) {
|
|
183
|
+
// 异常隔离:用户提供的 msg.content 可能含恶意 getter(`{ get text() { throw } }`),
|
|
184
|
+
// 整段 try-catch 让单条 entry 异常不级联污染整个流式合并路径。返回空串作"匿名 fp",
|
|
185
|
+
// 调用方 findReverseAnchor 看到 !fp0 || endsWith('|empty') 会拒当锚点,安全 fallback。
|
|
186
|
+
try {
|
|
187
|
+
if (!msg || !msg.role) return '';
|
|
188
|
+
const c = msg.content;
|
|
189
|
+
if (typeof c === 'string') return `${msg.role}|s|${c.length}|${c.slice(0, 32)}|${c.slice(-32)}`;
|
|
190
|
+
if (!Array.isArray(c) || c.length === 0) return `${msg.role}|empty`;
|
|
191
|
+
const b = c[0];
|
|
192
|
+
if (b.type === 'tool_use') return `${msg.role}|tu|${b.id || b.name || ''}`;
|
|
193
|
+
if (b.type === 'tool_result') return `${msg.role}|tr|${b.tool_use_id || ''}`;
|
|
194
|
+
if (b.type === 'text') {
|
|
195
|
+
const t = b.text || '';
|
|
196
|
+
return `${msg.role}|t|${t.length}|${t.slice(0, 32)}|${t.slice(-32)}`;
|
|
197
|
+
}
|
|
198
|
+
if (b.type === 'thinking') {
|
|
199
|
+
const t = b.thinking || '';
|
|
200
|
+
return `${msg.role}|th|${t.length}|${t.slice(0, 32)}|${t.slice(-32)}`;
|
|
201
|
+
}
|
|
202
|
+
return `${msg.role}|${b.type || 'unknown'}`;
|
|
203
|
+
} catch {
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 反向锚点搜索:在 `curMsgs` 中从末尾向头部找一个位置 p,使得
|
|
210
|
+
* `newMessages[0..L]` 的 fp 与 `curMsgs[p..p+L]` 的 fp **逐条**等价,
|
|
211
|
+
* 其中 L = min(newLen, curLen - p)。返回最右(即最贴近末尾)的命中。
|
|
212
|
+
*
|
|
213
|
+
* 设计选择:
|
|
214
|
+
* - 反向扫起点为 `curLen - 1`,因为流式 / Plan Mode 窗口的真锚点几乎都贴近末尾,
|
|
215
|
+
* 反向首个 fp0 命中即真锚点的概率最高,避免命中靠前的 fp 碰撞误锚点。
|
|
216
|
+
* - 多块连续等价校验:fp 加固后单条碰撞已罕见,多块连续碰撞概率近 0。
|
|
217
|
+
* - 复杂度:单候选 O(L),最坏 O(curLen·newLen),典型 K<200。
|
|
218
|
+
* - **fp 双向缓存**:newFps + curFpsCache 都缓存,让反向扫多候选场景下同一 curMsgs[p]
|
|
219
|
+
* 只算一次 fp(perf-security review P2,长 session 5000+/s SSE 路径节省 ~50% fp 调用)。
|
|
220
|
+
*
|
|
221
|
+
* @param {Array} newMsgs - 新消息数组
|
|
222
|
+
* @param {Array} curMsgs - 累积消息数组
|
|
223
|
+
* @param {Array<string>} [newFps] - 预计算的 newMsgs fp 数组缓存
|
|
224
|
+
* @param {Array<string>} [sharedCurFpsCache] - 调用方共享的 curMsgs fp 懒缓存(anchor miss 后
|
|
225
|
+
* 等长分支的对位比较可复用,避免对同一 curMsgs 重算 fp)
|
|
226
|
+
* @returns {{anchorIdx: number, overlapLen: number} | null}
|
|
227
|
+
*/
|
|
228
|
+
export function findReverseAnchor(newMsgs, curMsgs, newFps, sharedCurFpsCache) {
|
|
229
|
+
const newLen = newMsgs.length;
|
|
230
|
+
const curLen = curMsgs ? curMsgs.length : 0;
|
|
231
|
+
if (newLen === 0 || curLen === 0) return null;
|
|
232
|
+
const fp0 = newFps ? newFps[0] : messageFingerprint(newMsgs[0]);
|
|
233
|
+
// 空内容 fp 不当锚点:role|empty 在 curMsgs 多处可能命中(连续多条空 content 消息),
|
|
234
|
+
// 反向扫到第一个 role|empty 会误锚到错误位置,导致 overlapLen 计算偏差。
|
|
235
|
+
// 若 newMsgs[0..N-1] 是"newMsgs[0] 空 + 后续有效"的混合序列:放弃以 newMsgs[0] 为锚点,
|
|
236
|
+
// 由调用方 fallback 路径(newLen<curLen → rebuild / newLen===curLen → 等长内容感知 /
|
|
237
|
+
// newLen>curLen → push tail)兜底;这条防线保的是"全 empty 新序列误复用旧 curMsgs 末尾"。
|
|
238
|
+
if (!fp0 || fp0.endsWith('|empty')) return null;
|
|
239
|
+
// curMsgs fp 懒缓存:sparse Array 仅记录被访问过的位置,避免上来就 map 整段长 session
|
|
240
|
+
// (curLen 可能 > 5000,但实际访问命中的 p 通常 < 50 个)。
|
|
241
|
+
const curFpsCache = sharedCurFpsCache || new Array(curLen);
|
|
242
|
+
const curFpAt = (idx) => {
|
|
243
|
+
let v = curFpsCache[idx];
|
|
244
|
+
if (v === undefined) {
|
|
245
|
+
v = messageFingerprint(curMsgs[idx]);
|
|
246
|
+
curFpsCache[idx] = v;
|
|
247
|
+
}
|
|
248
|
+
return v;
|
|
249
|
+
};
|
|
250
|
+
for (let p = curLen - 1; p >= 0; p--) {
|
|
251
|
+
if (curFpAt(p) !== fp0) continue;
|
|
252
|
+
const overlapLen = Math.min(newLen, curLen - p);
|
|
253
|
+
let ok = true;
|
|
254
|
+
for (let i = 1; i < overlapLen; i++) {
|
|
255
|
+
// 边界安全:i < overlapLen ≤ newLen ≤ newFps.length,必不越界(newFps 在调用处
|
|
256
|
+
// 用 newMessages.map 整体生成)。这里 newFps 仅作命中加速缓存,传入与否语义等价。
|
|
257
|
+
const newFpI = newFps ? newFps[i] : messageFingerprint(newMsgs[i]);
|
|
258
|
+
if (newFpI !== curFpAt(p + i)) {
|
|
259
|
+
ok = false; break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (ok) return { anchorIdx: p, overlapLen };
|
|
263
|
+
// 验证失败(fp 加固后罕见)→ 继续向左找下一候选;curFpsCache 让重叠候选区域复用 fp。
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// Server-side "current session" pin store, per project
|
|
1
|
+
// Server-side "current session" pin store, per project.
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
3
|
+
// The current-session view filter needs a single "current session pointer". It used to
|
|
4
4
|
// live in the browser's localStorage (keyed by project name) — so two devices hitting the
|
|
5
|
-
// SAME ccv process diverged. We move it server-side: one tiny JSON per project
|
|
6
|
-
// suffixed by an instance id (`--pid`) so multiple ccv processes of one project can isolate.
|
|
5
|
+
// SAME ccv process diverged. We move it server-side: one tiny JSON per project.
|
|
7
6
|
//
|
|
8
7
|
// `logDir` here is the PER-PROJECT directory (interceptor._logDir = LOG_DIR/<projectName>);
|
|
9
8
|
// '' means no active project (workspace mode not yet launched) → read = null / write = no-op.
|
|
@@ -14,24 +13,16 @@ import { join } from 'node:path';
|
|
|
14
13
|
import { randomBytes } from 'node:crypto';
|
|
15
14
|
import { renameSyncWithRetry } from './file-api.js';
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (!instanceId || typeof instanceId !== 'string') return '';
|
|
21
|
-
return instanceId.replace(/[^a-zA-Z0-9_\-.]/g, '_');
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Pin file path inside the project dir: `.session-pin.json` or `.session-pin.<id>.json`. */
|
|
25
|
-
export function pinFilePath(logDir, instanceId) {
|
|
26
|
-
const id = sanitizeInstanceId(instanceId);
|
|
27
|
-
return join(logDir, id ? `.session-pin.${id}.json` : '.session-pin.json');
|
|
16
|
+
/** Pin file path inside the project dir: `.session-pin.json`. */
|
|
17
|
+
export function pinFilePath(logDir) {
|
|
18
|
+
return join(logDir, '.session-pin.json');
|
|
28
19
|
}
|
|
29
20
|
|
|
30
21
|
/** Read the pinned session id. No project / missing file / corrupt → null. */
|
|
31
|
-
export function readPin(logDir
|
|
22
|
+
export function readPin(logDir) {
|
|
32
23
|
if (!logDir) return null;
|
|
33
24
|
try {
|
|
34
|
-
const file = pinFilePath(logDir
|
|
25
|
+
const file = pinFilePath(logDir);
|
|
35
26
|
if (!existsSync(file)) return null;
|
|
36
27
|
const obj = JSON.parse(readFileSync(file, 'utf-8'));
|
|
37
28
|
const v = obj && typeof obj === 'object' ? obj.pinnedSessionId : null;
|
|
@@ -44,9 +35,9 @@ export function readPin(logDir, instanceId) {
|
|
|
44
35
|
* A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic tmp+rename.
|
|
45
36
|
* Returns true on success, false on no-project / write failure (view state is best-effort).
|
|
46
37
|
*/
|
|
47
|
-
export function writePin(logDir,
|
|
38
|
+
export function writePin(logDir, pinnedSessionId) {
|
|
48
39
|
if (!logDir) return false;
|
|
49
|
-
const file = pinFilePath(logDir
|
|
40
|
+
const file = pinFilePath(logDir);
|
|
50
41
|
const id = (typeof pinnedSessionId === 'string' && pinnedSessionId) ? pinnedSessionId : null;
|
|
51
42
|
try {
|
|
52
43
|
if (id == null) {
|