cc-viewer 1.7.23 → 1.8.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 (262) hide show
  1. package/README.md +20 -0
  2. package/cli.js +92 -11
  3. package/concepts/ar/GlobalSettings.md +2 -2
  4. package/concepts/ar/ToolsFirst.md +1 -1
  5. package/concepts/da/GlobalSettings.md +2 -2
  6. package/concepts/da/ToolsFirst.md +1 -1
  7. package/concepts/de/GlobalSettings.md +2 -2
  8. package/concepts/de/ToolsFirst.md +1 -1
  9. package/concepts/en/GlobalSettings.md +2 -2
  10. package/concepts/en/ToolsFirst.md +1 -1
  11. package/concepts/es/GlobalSettings.md +2 -2
  12. package/concepts/es/ToolsFirst.md +1 -1
  13. package/concepts/fr/GlobalSettings.md +2 -2
  14. package/concepts/fr/ToolsFirst.md +1 -1
  15. package/concepts/it/GlobalSettings.md +2 -2
  16. package/concepts/it/ToolsFirst.md +1 -1
  17. package/concepts/ja/GlobalSettings.md +2 -2
  18. package/concepts/ja/ToolsFirst.md +1 -1
  19. package/concepts/ko/GlobalSettings.md +2 -2
  20. package/concepts/ko/ToolsFirst.md +1 -1
  21. package/concepts/no/GlobalSettings.md +2 -2
  22. package/concepts/no/ToolsFirst.md +1 -1
  23. package/concepts/pl/GlobalSettings.md +2 -2
  24. package/concepts/pl/ToolsFirst.md +1 -1
  25. package/concepts/pt-BR/GlobalSettings.md +2 -2
  26. package/concepts/pt-BR/ToolsFirst.md +1 -1
  27. package/concepts/ru/GlobalSettings.md +2 -2
  28. package/concepts/ru/ToolsFirst.md +1 -1
  29. package/concepts/th/GlobalSettings.md +2 -2
  30. package/concepts/th/ToolsFirst.md +1 -1
  31. package/concepts/tr/GlobalSettings.md +2 -2
  32. package/concepts/tr/ToolsFirst.md +1 -1
  33. package/concepts/uk/GlobalSettings.md +2 -2
  34. package/concepts/uk/ToolsFirst.md +1 -1
  35. package/concepts/zh/GlobalSettings.md +2 -2
  36. package/concepts/zh/ToolsFirst.md +1 -1
  37. package/concepts/zh-TW/GlobalSettings.md +2 -2
  38. package/concepts/zh-TW/ToolsFirst.md +1 -1
  39. package/dist/assets/App-CqQJDjfT.js +2 -0
  40. package/dist/assets/App-ssYfrjwF.css +1 -0
  41. package/dist/assets/{MdxEditorPanel-e5DlfcqJ.js → MdxEditorPanel-DXwb5tSD.js} +1 -1
  42. package/dist/assets/{Mobile-B8xwCLgr.js → Mobile-DOjKURO3.js} +1 -1
  43. package/dist/assets/{ProxyStatsModal-_w677xPM.js → ProxyStatsModal-BEnxiRXF.js} +1 -1
  44. package/dist/assets/index-CH_N7vvX.js +2 -0
  45. package/dist/assets/seqResourceLoaders-BJhO6zhW.js +2 -0
  46. package/dist/assets/seqResourceLoaders-DI0G5nu1.css +41 -0
  47. package/dist/index.html +1 -1
  48. package/findcc.js +17 -8
  49. package/node_modules/@ccv/core/package.json +13 -0
  50. package/{server/lib → node_modules/@ccv/core/src}/approval-modal-prefs.js +1 -1
  51. package/node_modules/@ccv/core/src/clearCheckpoint.js +7 -0
  52. package/{src/utils → node_modules/@ccv/core/src}/contentFilter.js +207 -141
  53. package/{server/lib → node_modules/@ccv/core/src}/context-rules.js +2 -2
  54. package/{server/lib → node_modules/@ccv/core/src}/delta-reconstructor.js +60 -4
  55. package/{server/lib → node_modules/@ccv/core/src}/error-report.js +5 -4
  56. package/{server/lib → node_modules/@ccv/core/src}/session-boundary.js +3 -3
  57. package/{src/utils → node_modules/@ccv/core/src}/teammateDetector.js +9 -0
  58. package/{server/lib → node_modules/@ccv/core/src}/tools-xml-formatter.js +3 -3
  59. package/{server/lib → node_modules/@ccv/core/src}/v2-transcript-normalizer.js +1 -1
  60. package/{server/lib → node_modules/@ccv/core/src}/voice-pack-events.js +2 -2
  61. package/package.json +11 -97
  62. package/server/_paths.js +28 -7
  63. package/server/i18n.js +60 -0
  64. package/server/interceptor.js +36 -11
  65. package/server/lib/adapters/dingtalk-adapter.js +2 -2
  66. package/server/lib/adapters/discord-adapter.js +2 -2
  67. package/server/lib/adapters/feishu-adapter.js +2 -2
  68. package/server/lib/adapters/wecom-adapter.js +2 -2
  69. package/server/lib/approval-policy.js +29 -0
  70. package/server/lib/{ask-store.js → ask/ask-store.js} +3 -3
  71. package/server/lib/{perm-bridge.js → ask/perm-bridge.js} +5 -5
  72. package/server/lib/builtin-model-prompts.js +198 -0
  73. package/server/lib/chat-queue.js +341 -0
  74. package/server/lib/cli-inject.js +1 -1
  75. package/server/lib/context-watcher.js +3 -3
  76. package/server/lib/create_system_prompt.js +0 -9
  77. package/server/lib/enrich-plan-input.js +1 -1
  78. package/server/lib/ensure-hooks.js +2 -2
  79. package/server/lib/file-access-policy.js +6 -1
  80. package/server/lib/file-api.js +17 -1
  81. package/server/lib/{dingtalk-bridge.js → im/dingtalk-bridge.js} +1 -1
  82. package/server/lib/{im-append-system.js → im/im-append-system.js} +5 -13
  83. package/server/lib/{im-bridge-core.js → im/im-bridge-core.js} +12 -6
  84. package/server/lib/{im-config.js → im/im-config.js} +1 -1
  85. package/server/lib/im/im-deny.js +4 -0
  86. package/server/lib/{im-lang.js → im/im-lang.js} +1 -1
  87. package/server/lib/{im-lock.js → im/im-lock.js} +2 -2
  88. package/server/lib/{im-process-manager.js → im/im-process-manager.js} +1 -1
  89. package/server/lib/{im-senders.js → im/im-senders.js} +1 -1
  90. package/server/lib/{im-skills.js → im/im-skills.js} +2 -2
  91. package/server/lib/interceptor-core.js +8 -5
  92. package/server/lib/kv-cache-analyzer.js +6 -5
  93. package/server/lib/launch-config.js +329 -0
  94. package/server/lib/log-file-utils.js +21 -0
  95. package/server/lib/log-management.js +6 -17
  96. package/server/lib/log-stream.js +1 -1
  97. package/server/lib/log-watcher.js +1 -1
  98. package/server/lib/model-system-prompts.js +18 -2
  99. package/server/lib/prefs-store.js +2 -2
  100. package/server/lib/project-prefs.js +6 -6
  101. package/server/lib/project-state.js +15 -0
  102. package/server/lib/{proxy-retry.js → proxy/proxy-retry.js} +2 -2
  103. package/server/lib/{proxy-stats.js → proxy/proxy-stats.js} +3 -3
  104. package/server/lib/sdk-adapter.js +6 -74
  105. package/server/lib/sdk-manager.js +383 -278
  106. package/server/lib/skills-api.js +1 -1
  107. package/server/lib/spawn-model-resolver.js +21 -6
  108. package/server/lib/stats-worker.js +2 -2
  109. package/server/lib/system-prompt-files.js +53 -15
  110. package/server/lib/system-prompt-render.js +23 -7
  111. package/server/lib/system-prompt-snapshots.js +452 -0
  112. package/server/lib/updater.js +63 -3
  113. package/server/lib/user-prompt-extract.js +5 -5
  114. package/server/lib/v2/adapter.js +3 -3
  115. package/server/lib/v2/conversation-store.js +1 -1
  116. package/server/lib/v2/convert.js +3 -3
  117. package/server/lib/v2/jsonl-read.js +1 -1
  118. package/server/lib/v2/live-feed.js +3 -3
  119. package/server/lib/v2/meta-rows.js +3 -3
  120. package/server/lib/v2/replay.js +1 -1
  121. package/server/lib/v2/session-list.js +1 -1
  122. package/server/lib/v2/session-owner.js +1 -1
  123. package/server/lib/v2/v2-writer.js +26 -1
  124. package/server/lib/v2/verify.js +1 -1
  125. package/server/lib/voice-pack-manager.js +4 -5
  126. package/server/lib/wire-compress.js +1 -1
  127. package/server/proxy.js +5 -5
  128. package/server/pty-manager.js +285 -184
  129. package/server/routes/ask-perm.js +1 -1
  130. package/server/routes/dingtalk.js +3 -3
  131. package/server/routes/events.js +2 -2
  132. package/server/routes/expert.js +74 -13
  133. package/server/routes/im.js +5 -5
  134. package/server/routes/preferences.js +3 -3
  135. package/server/routes/workspaces.js +1 -1
  136. package/server/server.js +207 -58
  137. package/server/system-prompt-templates/systemPromptModel.md +0 -8
  138. package/server/system-prompt-templates/systemPromptVariables.ar.md +0 -7
  139. package/server/system-prompt-templates/systemPromptVariables.da.md +0 -7
  140. package/server/system-prompt-templates/systemPromptVariables.de.md +0 -7
  141. package/server/system-prompt-templates/systemPromptVariables.es.md +0 -7
  142. package/server/system-prompt-templates/systemPromptVariables.fr.md +0 -7
  143. package/server/system-prompt-templates/systemPromptVariables.it.md +0 -7
  144. package/server/system-prompt-templates/systemPromptVariables.ja.md +0 -7
  145. package/server/system-prompt-templates/systemPromptVariables.ko.md +0 -7
  146. package/server/system-prompt-templates/systemPromptVariables.md +0 -7
  147. package/server/system-prompt-templates/systemPromptVariables.no.md +0 -7
  148. package/server/system-prompt-templates/systemPromptVariables.pl.md +0 -7
  149. package/server/system-prompt-templates/systemPromptVariables.pt-BR.md +0 -7
  150. package/server/system-prompt-templates/systemPromptVariables.ru.md +0 -7
  151. package/server/system-prompt-templates/systemPromptVariables.th.md +0 -7
  152. package/server/system-prompt-templates/systemPromptVariables.tr.md +0 -7
  153. package/server/system-prompt-templates/systemPromptVariables.uk.md +0 -7
  154. package/server/system-prompt-templates/systemPromptVariables.zh-TW.md +0 -7
  155. package/server/system-prompt-templates/systemPromptVariables.zh.md +0 -7
  156. package/ultraAgents/README.md +3 -0
  157. package/ultraAgents/test-analysis-expert.json +45 -0
  158. package/dist/assets/App-BWajJyJh.css +0 -1
  159. package/dist/assets/App-CusqoEAB.js +0 -2
  160. package/dist/assets/index-D6FmFsIz.js +0 -2
  161. package/dist/assets/seqResourceLoaders-3K77Znll.js +0 -2
  162. package/dist/assets/seqResourceLoaders-BgD_Rwvc.css +0 -41
  163. package/src/utils/apiUrl.js +0 -26
  164. package/src/utils/askFallback.js +0 -21
  165. package/src/utils/askOptionDesc.js +0 -55
  166. package/src/utils/askPortalMatcher.js +0 -54
  167. package/src/utils/autoApproveOptions.js +0 -27
  168. package/src/utils/avatarAnimationPostPass.js +0 -49
  169. package/src/utils/builtinPresets.js +0 -18
  170. package/src/utils/clearCheckpoint.js +0 -7
  171. package/src/utils/commandValidator.js +0 -45
  172. package/src/utils/contextRaw.js +0 -18
  173. package/src/utils/contextSidebarNavigation.js +0 -16
  174. package/src/utils/contextTurns.js +0 -117
  175. package/src/utils/displayScaleHelper.js +0 -47
  176. package/src/utils/effectiveModel.js +0 -17
  177. package/src/utils/entry-slim.js +0 -563
  178. package/src/utils/entryCache.js +0 -144
  179. package/src/utils/errorReport.js +0 -6
  180. package/src/utils/fileExpandedPathsStorage.js +0 -70
  181. package/src/utils/fileIcons.jsx +0 -36
  182. package/src/utils/fileOpen.js +0 -37
  183. package/src/utils/formatters.js +0 -48
  184. package/src/utils/gitApi.js +0 -50
  185. package/src/utils/gitTreeBuilder.js +0 -19
  186. package/src/utils/helpers.js +0 -854
  187. package/src/utils/identityHeal.js +0 -108
  188. package/src/utils/imConnState.js +0 -59
  189. package/src/utils/imOrigin.js +0 -24
  190. package/src/utils/imTr.js +0 -7
  191. package/src/utils/imageCompress.js +0 -83
  192. package/src/utils/imageDownscale.js +0 -28
  193. package/src/utils/imageResize.js +0 -112
  194. package/src/utils/ingestPipeline.js +0 -47
  195. package/src/utils/isProxyMode.js +0 -11
  196. package/src/utils/lazyWithReload.js +0 -53
  197. package/src/utils/markdown.js +0 -30
  198. package/src/utils/markdownIncremental.js +0 -39
  199. package/src/utils/markdownProfiler.js +0 -113
  200. package/src/utils/mdExtensionDetect.js +0 -64
  201. package/src/utils/memoryLinkParser.js +0 -44
  202. package/src/utils/modalMask.js +0 -15
  203. package/src/utils/modelSuggestions.js +0 -48
  204. package/src/utils/monotime.js +0 -6
  205. package/src/utils/pinnedMenu.js +0 -55
  206. package/src/utils/presetShortcuts.js +0 -14
  207. package/src/utils/projectAlias.js +0 -135
  208. package/src/utils/promptClassifier.js +0 -112
  209. package/src/utils/promptDetect.js +0 -196
  210. package/src/utils/promptNav.js +0 -60
  211. package/src/utils/ptyChunkBuilder.js +0 -251
  212. package/src/utils/quickMenuHoverIntent.js +0 -35
  213. package/src/utils/rateLimitParser.js +0 -119
  214. package/src/utils/readResultPool.js +0 -106
  215. package/src/utils/refreshCachedItemProp.js +0 -30
  216. package/src/utils/resizeCalc.js +0 -21
  217. package/src/utils/resolveLocalized.js +0 -17
  218. package/src/utils/searchApi.js +0 -47
  219. package/src/utils/searchReplace.js +0 -51
  220. package/src/utils/seqResourceLoaders.js +0 -79
  221. package/src/utils/sessionManager.js +0 -482
  222. package/src/utils/sessionMerge.js +0 -239
  223. package/src/utils/skillModalController.js +0 -111
  224. package/src/utils/skillsParser.js +0 -116
  225. package/src/utils/slashCommandLabels.js +0 -80
  226. package/src/utils/splitDragCalc.js +0 -73
  227. package/src/utils/stickyBottomController.js +0 -643
  228. package/src/utils/svgSanitize.js +0 -48
  229. package/src/utils/systemTags.js +0 -47
  230. package/src/utils/tClaude.js +0 -20
  231. package/src/utils/teamModalBuilder.js +0 -351
  232. package/src/utils/teamSessionParser.js +0 -190
  233. package/src/utils/teammateAvatars.js +0 -194
  234. package/src/utils/termDiag.js +0 -114
  235. package/src/utils/terminalClipboard.js +0 -117
  236. package/src/utils/terminalWriteQueue.js +0 -398
  237. package/src/utils/thinkingMerge.js +0 -49
  238. package/src/utils/toolCatalog.js +0 -30
  239. package/src/utils/toolResultBuilder.js +0 -411
  240. package/src/utils/toolResultClassifier.js +0 -16
  241. package/src/utils/toolResultCore.js +0 -194
  242. package/src/utils/toolsDiff.js +0 -44
  243. package/src/utils/toolsXmlFormatter.js +0 -3
  244. package/src/utils/ultraplanController.js +0 -109
  245. package/src/utils/ultraplanExperts.js +0 -86
  246. package/src/utils/ultraplanTemplates.js +0 -154
  247. package/src/utils/userImageRefs.js +0 -49
  248. package/src/utils/v3Assembler.js +0 -125
  249. package/src/utils/v3Rows.js +0 -49
  250. package/src/utils/voicePackPlayer.js +0 -233
  251. package/src/utils/webSearchGrouping.js +0 -111
  252. package/src/utils/workflowFormat.js +0 -52
  253. package/src/utils/workflowRuns.js +0 -118
  254. package/src/utils/workflowStore.js +0 -94
  255. /package/{src/utils → node_modules/@ccv/core/src}/requestType.js +0 -0
  256. /package/server/lib/{ask-bridge.js → ask/ask-bridge.js} +0 -0
  257. /package/server/lib/{ask-constants.js → ask/ask-constants.js} +0 -0
  258. /package/server/lib/{ask-reaper.js → ask/ask-reaper.js} +0 -0
  259. /package/server/lib/{dingtalk-config.js → im/dingtalk-config.js} +0 -0
  260. /package/server/lib/{im-log-watcher.js → im/im-log-watcher.js} +0 -0
  261. /package/server/lib/{proxy-env.js → proxy/proxy-env.js} +0 -0
  262. /package/server/lib/{proxy-errors.js → proxy/proxy-errors.js} +0 -0
@@ -0,0 +1,452 @@
1
+ // Per-project store of the RENDERED system-prompt content each claude session was
2
+ // launched with — the "snapshot" that lets `-c`/`-r` resumes re-inject byte-identical
3
+ // text instead of re-rendering `${...}` template variables (which change between
4
+ // launches and bust the whole prompt-prefix KV cache; git.status/recentCommits/dates
5
+ // are the usual drifters).
6
+ //
7
+ // Layout under <LOG_DIR>/<projectKey>/system-prompt-snapshots/:
8
+ // <transcriptUuid>.json — { v, entries:[{flag, basename, content}], model, createdAt, boundVia }
9
+ // Only sessions that actually had an injection get records:
10
+ // empty-entries snapshots are never persisted (a resume target
11
+ // with no record simply follows the no-record path every time —
12
+ // same outcome, zero poisoning surface).
13
+ // pending.json — FIFO queue (cap PENDING_CAP) of launches awaiting binding:
14
+ // { entries, model, cwd, createdAt, pid, resumeExpected, resolvedUuid, fork }
15
+ //
16
+ // Binding channels (both consume the queue):
17
+ // - Bind A (wire): v2-writer sees a session's first MAIN request and matches its
18
+ // system text against pending contents — content is the capability, so teammate /
19
+ // subagent requests can never steal a bind. Fresh sessions: wire sid == transcript uuid.
20
+ // - Bind B (hook): the SessionStart hook reports the resumed conversation's transcript
21
+ // uuid; a resume launch's pending is consumed EXACTLY by resolvedUuid (source
22
+ // 'resume') or by fork flag (source 'fork'). Non-matching hooks consume nothing —
23
+ // a stolen in-terminal /resume hook or an out-of-order racing hook can neither
24
+ // poison an innocent conversation's record nor cascade-destroy the queue.
25
+ //
26
+ // Failure philosophy: every public function is total (returns null/false on error,
27
+ // never throws) — a lost snapshot only means a later resume falls back to the
28
+ // no-record path, never a broken spawn or a dropped log entry.
29
+ //
30
+ // GC (lazy, spawn-path only): appendPending drops records whose transcript file is
31
+ // gone (past a 1h grace window — bind-before-transcript races) or older than 30 days.
32
+ // Identical re-binds refresh createdAt, so actively-resumed conversations stay alive.
33
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
34
+ import { join, basename, dirname } from 'node:path';
35
+ import { randomBytes } from 'node:crypto';
36
+ import { reportSwallowed } from '@ccv/core/error-report';
37
+ import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
38
+ import { renameSyncWithRetry } from './file-api.js';
39
+
40
+ const STORE_DIR_NAME = 'system-prompt-snapshots';
41
+ const PENDING_FILE = 'pending.json';
42
+ const PENDING_CAP = 16; // generous headroom for sequential launches; FIFO beyond
43
+ // Bind B freshness window: a resume launch's hook lands seconds after spawn; the TTL
44
+ // only bounds how long an abandoned pending (crashed launch, hook never fired) stays
45
+ // consumable. Bind A ignores age — content matching is self-validating regardless of
46
+ // how long the user waited to send the first message.
47
+ const RESUME_PENDING_TTL_MS = 15 * 60 * 1000;
48
+ const QUEUE_GC_AGE_MS = 7 * 24 * 60 * 60 * 1000;
49
+ const SNAPSHOT_GC_AGE_MS = 30 * 24 * 60 * 60 * 1000;
50
+ // A just-bound record can predate claude's transcript flush (fork hooks fire before the
51
+ // first message; /compact rename windows). Records younger than this are never
52
+ // transcript-gone-collected.
53
+ const GC_TRANSCRIPT_GRACE_MS = 60 * 60 * 1000;
54
+ const MAX_STORE_FILE_BYTES = 8 * 1024 * 1024; // corrupt multi-GB files must not stall spawns
55
+ const MAX_ENTRY_CONTENT_BYTES = 256 * 1024; // rendered prompts are KB-scale; bigger = junk
56
+ const TRANSCRIPT_HEAD_BYTES = 64 * 1024; // -c candidate filter reads only the head
57
+
58
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
59
+ const KNOWN_FLAGS = new Set(['--system-prompt-file', '--append-system-prompt-file']);
60
+ // `-c` candidate filter: teammate and sidechain transcripts share the project's
61
+ // transcript dir but are never claude's continue target (mirrors claude's own filter).
62
+ const TRANSCRIPT_SKIP_MARKERS = ['"isSidechain":true', '"teamName"'];
63
+
64
+ // Test seam (repo _setXxxForTests style): the clock. The projects dir is steered via
65
+ // CCV_PROJECTS_DIR / CLAUDE_CONFIG_DIR envs (findcc's test barriers apply).
66
+ let _now = Date.now;
67
+ export function _setSnapshotDepsForTests({ now } = {}) {
68
+ _now = typeof now === 'function' ? now : Date.now;
69
+ }
70
+
71
+ /** projectName derivation — same algorithm as interceptor.js (basename + sanitize). */
72
+ export function projectKeyForCwd(cwd) {
73
+ if (!cwd || typeof cwd !== 'string') return '';
74
+ const key = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
75
+ // Parity with v2/layout.js sanitizePathComponent: all-dots degenerate names must not
76
+ // become path traversal segments.
77
+ return (!key || /^\.+$/.test(key)) ? '_' : key;
78
+ }
79
+
80
+ /** claude transcript projects root: CCV_PROJECTS_DIR override, else <config>/projects. */
81
+ function projectsDir() {
82
+ return process.env.CCV_PROJECTS_DIR || join(getClaudeConfigDir(), 'projects');
83
+ }
84
+
85
+ /** claude transcript dir for a launch cwd: <projects>/<slug>/ (slug = non-alnum → '-'). */
86
+ export function transcriptDirForCwd(cwd) {
87
+ if (!cwd || typeof cwd !== 'string') return '';
88
+ return join(projectsDir(), cwd.replace(/[^A-Za-z0-9]/g, '-'));
89
+ }
90
+
91
+ /** Flatten a request body's system field (string | array of text blocks) for matching. */
92
+ export function systemTextOfBody(body) {
93
+ const system = body && body.system;
94
+ if (typeof system === 'string') return system;
95
+ if (Array.isArray(system)) return system.map(s => (s && s.text) || '').join('');
96
+ return '';
97
+ }
98
+
99
+ // The spawn side learns cwd from the launch, the hook side from claude's payload —
100
+ // symlinks (/tmp → /private/tmp on macOS) and trailing separators would silently break
101
+ // the byte-equality the queue matching relies on. Best-effort realpath both sides.
102
+ function normalizeCwd(cwd) {
103
+ if (!cwd || typeof cwd !== 'string') return '';
104
+ let out = cwd;
105
+ try { out = realpathSync.native(out); } catch { /* path may not exist yet — keep raw */ }
106
+ return out.length > 1 ? out.replace(/[\\/]+$/, '') : out;
107
+ }
108
+
109
+ function storeDirForKey(projectKey, logDir = LOG_DIR) {
110
+ return projectKey ? join(logDir, projectKey, STORE_DIR_NAME) : '';
111
+ }
112
+
113
+ /**
114
+ * Validate + normalize a raw entries array → [{flag, basename, content}] (drops junk).
115
+ * Beyond shape: flags are whitelisted and basenames must be bare filenames — a corrupted
116
+ * or planted record must never become a path escape (materialize joins it) or an
117
+ * arbitrary claude argv flag (materialize pushes it).
118
+ */
119
+ function sanitizeEntries(raw) {
120
+ if (!Array.isArray(raw)) return [];
121
+ const out = [];
122
+ for (const e of raw) {
123
+ if (!e || typeof e !== 'object') continue;
124
+ if (!KNOWN_FLAGS.has(e.flag)) continue;
125
+ if (typeof e.basename !== 'string' || !e.basename) continue;
126
+ if (basename(e.basename) !== e.basename || e.basename.startsWith('.')) continue;
127
+ if (typeof e.content !== 'string') continue; // unavailable entries never reach the store
128
+ if (Buffer.byteLength(e.content, 'utf-8') > MAX_ENTRY_CONTENT_BYTES) continue;
129
+ out.push({ flag: e.flag, basename: e.basename, content: e.content });
130
+ }
131
+ return out;
132
+ }
133
+
134
+ function entriesEqual(a, b) {
135
+ if (a.length !== b.length) return false;
136
+ return a.every((e, i) => e.flag === b[i].flag && e.basename === b[i].basename && e.content === b[i].content);
137
+ }
138
+
139
+ function readJsonFileCapped(file) {
140
+ try {
141
+ if (statSync(file).size > MAX_STORE_FILE_BYTES) return null;
142
+ return JSON.parse(readFileSync(file, 'utf-8'));
143
+ } catch { return null; }
144
+ }
145
+
146
+ function atomicWriteJson(file, obj) {
147
+ mkdirSync(dirname(file), { recursive: true });
148
+ const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
149
+ try {
150
+ writeFileSync(tmp, JSON.stringify(obj));
151
+ renameSyncWithRetry(tmp, file);
152
+ } catch (err) {
153
+ try { unlinkSync(tmp); } catch { /* already absent */ }
154
+ throw err;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Read a conversation's snapshot by project key. Missing / corrupt / wrong shape → null.
160
+ * @returns {{entries:Array, model:string|null, createdAt:number, boundVia:string}|null}
161
+ */
162
+ export function readSnapshotByKey(projectKey, uuid, logDir) {
163
+ try {
164
+ if (!UUID_RE.test(String(uuid || ''))) return null;
165
+ const dir = storeDirForKey(projectKey, logDir);
166
+ if (!dir) return null;
167
+ const obj = readJsonFileCapped(join(dir, `${uuid}.json`));
168
+ if (!obj || typeof obj !== 'object' || !Array.isArray(obj.entries)) return null;
169
+ return {
170
+ entries: sanitizeEntries(obj.entries),
171
+ model: typeof obj.model === 'string' ? obj.model : null,
172
+ createdAt: typeof obj.createdAt === 'number' ? obj.createdAt : 0,
173
+ boundVia: obj.boundVia === 'hook' ? 'hook' : 'wire',
174
+ };
175
+ } catch (err) {
176
+ reportSwallowed('sys-prompt-snap.read', err);
177
+ return null;
178
+ }
179
+ }
180
+
181
+ export function readSnapshot(cwd, uuid) {
182
+ return readSnapshotByKey(projectKeyForCwd(cwd), uuid);
183
+ }
184
+
185
+ /**
186
+ * Persist a conversation's snapshot by project key. Empty-entries records are refused
187
+ * ('empty'): a resume target with no injection history is indistinguishable from an
188
+ * unrecorded one by design — both take the no-record path, and no stored record can
189
+ * later mislabel a session. Default skipIfPresent: an existing record whose entries
190
+ * DIFFER is kept (first bind wins — a later conflicting bind means a resolution
191
+ * mistake, and the warning is the diagnostic); identical content rewrites the file
192
+ * just to refresh createdAt (otherwise gc would age out actively-resumed conversations
193
+ * 30 days after their FIRST bind).
194
+ * @returns {{written:boolean, reason?:'identical'|'exists'|'invalid'|'empty'}}
195
+ */
196
+ export function writeSnapshotByKey(projectKey, uuid, rec, { skipIfPresent = true, logDir } = {}) {
197
+ try {
198
+ if (!UUID_RE.test(String(uuid || ''))) return { written: false, reason: 'invalid' };
199
+ const dir = storeDirForKey(projectKey, logDir);
200
+ if (!dir) return { written: false, reason: 'invalid' };
201
+ const entries = sanitizeEntries(rec && rec.entries);
202
+ if (entries.length === 0) return { written: false, reason: 'empty' };
203
+ const next = {
204
+ v: 1,
205
+ entries,
206
+ model: rec && typeof rec.model === 'string' ? rec.model : null,
207
+ createdAt: _now(),
208
+ boundVia: rec && rec.boundVia === 'hook' ? 'hook' : 'wire',
209
+ };
210
+ const prev = readSnapshotByKey(projectKey, uuid, logDir);
211
+ if (prev) {
212
+ if (entriesEqual(prev.entries, next.entries)) {
213
+ // Refresh the clock so active conversations survive age-based gc.
214
+ atomicWriteJson(join(dir, `${uuid}.json`), next);
215
+ return { written: false, reason: 'identical' };
216
+ }
217
+ if (skipIfPresent) {
218
+ console.warn(`[CC Viewer] system-prompt snapshot for ${uuid} already exists with different content — keeping the first bind`);
219
+ return { written: false, reason: 'exists' };
220
+ }
221
+ }
222
+ atomicWriteJson(join(dir, `${uuid}.json`), next);
223
+ // No gc() here: binding fires at session-start time when claude's transcript may
224
+ // not be visible to us yet (bind-before-transcript would self-collect the record
225
+ // we just wrote). Hygiene runs on the spawn path (appendPending) instead, where
226
+ // every stored record refers to a long-settled conversation.
227
+ return { written: true };
228
+ } catch (err) {
229
+ reportSwallowed('sys-prompt-snap.write', err);
230
+ return { written: false, reason: 'invalid' };
231
+ }
232
+ }
233
+
234
+ export function writeSnapshot(cwd, uuid, rec, opts) {
235
+ return writeSnapshotByKey(projectKeyForCwd(cwd), uuid, rec, opts);
236
+ }
237
+
238
+ // ─── pending queue ────────────────────────────────────────────────────────────
239
+
240
+ function readPendingsByKey(projectKey, logDir) {
241
+ try {
242
+ const dir = storeDirForKey(projectKey, logDir);
243
+ if (!dir) return [];
244
+ const obj = readJsonFileCapped(join(dir, PENDING_FILE));
245
+ const list = obj && Array.isArray(obj.pendings) ? obj.pendings : [];
246
+ return list.filter(p => p && typeof p === 'object' && typeof p.cwd === 'string');
247
+ } catch (err) {
248
+ reportSwallowed('sys-prompt-snap.pending-read', err);
249
+ return [];
250
+ }
251
+ }
252
+
253
+ function writePendingsByKey(projectKey, list, logDir) {
254
+ const dir = storeDirForKey(projectKey, logDir);
255
+ if (!dir) return;
256
+ atomicWriteJson(join(dir, PENDING_FILE), { v: 1, pendings: list });
257
+ }
258
+
259
+ /**
260
+ * Enqueue a launch's effective injection content for later binding.
261
+ * rec: { entries, model, resumeExpected, resolvedUuid, fork } — cwd/createdAt/pid stamped here.
262
+ */
263
+ export function appendPending(cwd, rec = {}, logDir) {
264
+ try {
265
+ const projectKey = projectKeyForCwd(cwd);
266
+ if (!projectKey) return false;
267
+ const list = readPendingsByKey(projectKey, logDir);
268
+ const cutoff = _now() - QUEUE_GC_AGE_MS;
269
+ const kept = list.filter(p => (typeof p.createdAt === 'number' ? p.createdAt : 0) >= cutoff);
270
+ kept.push({
271
+ entries: sanitizeEntries(rec.entries),
272
+ model: typeof rec.model === 'string' ? rec.model : null,
273
+ cwd: normalizeCwd(cwd),
274
+ createdAt: _now(),
275
+ pid: process.pid,
276
+ resumeExpected: rec.resumeExpected === true,
277
+ resolvedUuid: typeof rec.resolvedUuid === 'string' ? rec.resolvedUuid : null,
278
+ fork: rec.fork === true,
279
+ });
280
+ while (kept.length > PENDING_CAP) kept.shift();
281
+ writePendingsByKey(projectKey, kept, logDir);
282
+ gc(cwd, logDir);
283
+ return true;
284
+ } catch (err) {
285
+ reportSwallowed('sys-prompt-snap.pending-append', err);
286
+ return false;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Bind A (wire): match a session's first main request's system text against the queue.
292
+ * Non-empty pendings require EVERY entry content to appear in systemText (content is
293
+ * the capability — only the process we injected can present it); among matches the
294
+ * LONGEST total content wins, so a superset launch can never be claimed by a subset
295
+ * pending. When no content match exists, the oldest EMPTY pending is consumed (empty
296
+ * records are interchangeable — a mismatch binds "no injection" to a session that had
297
+ * none). resumeExpected pendings are reserved for Bind B and never consumed here.
298
+ * @returns {object|null} the consumed pending
299
+ */
300
+ export function consumePendingForWireByKey(projectKey, systemText, logDir) {
301
+ try {
302
+ if (!projectKey || typeof systemText !== 'string') return null;
303
+ const list = readPendingsByKey(projectKey, logDir);
304
+ if (list.length === 0) return null;
305
+ const eligible = p => p.resumeExpected !== true;
306
+ let hitIdx = -1;
307
+ let hitLen = -1;
308
+ for (let i = 0; i < list.length; i++) {
309
+ const p = list[i];
310
+ if (!eligible(p) || p.entries.length === 0) continue;
311
+ if (!p.entries.every(e => e.content && systemText.includes(e.content))) continue;
312
+ const total = p.entries.reduce((n, e) => n + e.content.length, 0);
313
+ if (total > hitLen) { hitLen = total; hitIdx = i; }
314
+ }
315
+ if (hitIdx === -1) hitIdx = list.findIndex(p => eligible(p) && p.entries.length === 0);
316
+ if (hitIdx === -1) return null;
317
+ const [hit] = list.splice(hitIdx, 1);
318
+ writePendingsByKey(projectKey, list, logDir);
319
+ return hit;
320
+ } catch (err) {
321
+ reportSwallowed('sys-prompt-snap.wire-consume', err);
322
+ return null;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Bind B (hook): consume the pending that belongs to THIS hook's conversation.
328
+ * source 'resume' → exact resolvedUuid match only;
329
+ * source 'fork' → the oldest fork pending (fork hooks carry the fork's NEW
330
+ * transcript uuid, which the spawn could not have known).
331
+ * Anything else consumes NOTHING: a stolen in-terminal /resume hook or an out-of-order
332
+ * racing hook finds no exact match and leaves the queue intact — no cascade of
333
+ * guard-refusals, no destroyed pendings (strays age out via RESUME_PENDING_TTL_MS).
334
+ * @returns {object|null} { hit } | { mismatch: resolvedUuid } | null
335
+ */
336
+ export function consumePendingForResume(cwd, transcriptUuid, source, logDir) {
337
+ try {
338
+ const normCwd = normalizeCwd(cwd);
339
+ if (!normCwd) return null;
340
+ const projectKey = projectKeyForCwd(cwd);
341
+ const list = readPendingsByKey(projectKey, logDir);
342
+ if (list.length === 0) return null;
343
+ const cutoff = _now() - RESUME_PENDING_TTL_MS;
344
+ const kept = list.filter(p => {
345
+ if (p.resumeExpected !== true) return true;
346
+ return (typeof p.createdAt === 'number' ? p.createdAt : 0) >= cutoff; // stale strays drop out
347
+ });
348
+ const changed = kept.length !== list.length;
349
+ let idx = -1;
350
+ if (source === 'fork') {
351
+ idx = kept.findIndex(p => p.resumeExpected === true && p.fork === true && p.cwd === normCwd);
352
+ } else {
353
+ idx = kept.findIndex(p => p.resumeExpected === true && p.fork !== true
354
+ && p.cwd === normCwd && p.resolvedUuid === transcriptUuid);
355
+ }
356
+ if (idx === -1) {
357
+ if (changed) writePendingsByKey(projectKey, kept, logDir);
358
+ // Diagnostic surface for a -c mis-resolution: a RESUME hook arrived but the
359
+ // only resume pendings for this cwd name a DIFFERENT conversation than the
360
+ // one claude actually resumed. Fork hooks never warn (their new uuid differs
361
+ // from resolvedUuid by design); equal-uuid leftovers aren't mismatches either.
362
+ const other = source !== 'fork' && kept.find(p => p.resumeExpected === true && p.fork !== true
363
+ && p.cwd === normCwd && p.resolvedUuid && p.resolvedUuid !== transcriptUuid);
364
+ return other ? { mismatch: other.resolvedUuid } : null;
365
+ }
366
+ const [hit] = kept.splice(idx, 1);
367
+ writePendingsByKey(projectKey, kept, logDir);
368
+ return { hit };
369
+ } catch (err) {
370
+ reportSwallowed('sys-prompt-snap.resume-consume', err);
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /** Bounded head read (transcripts can reach GB scale — never whole-file them here). */
376
+ function readHead(file, maxBytes) {
377
+ let fd;
378
+ try {
379
+ fd = openSync(file, 'r');
380
+ const buf = Buffer.alloc(maxBytes);
381
+ const n = readSync(fd, buf, 0, maxBytes, 0);
382
+ return buf.toString('utf-8', 0, n);
383
+ } finally {
384
+ if (fd !== undefined) try { closeSync(fd); } catch { /* best-effort */ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Resolve which conversation `claude -c` would continue in cwd: the newest-mtime
390
+ * uuid-named transcript in <projects>/<slug>/, skipping teammate/sidechain transcripts
391
+ * (they share the dir but are never continue targets). Missing dir / no candidates /
392
+ * fs error → null (caller treats as "target unidentifiable" → fresh-launch behavior).
393
+ */
394
+ export function resolveContinueTargetUuid(cwd) {
395
+ try {
396
+ const dir = transcriptDirForCwd(cwd);
397
+ if (!dir || !existsSync(dir)) return null;
398
+ let best = null;
399
+ for (const name of readdirSync(dir)) {
400
+ if (!name.endsWith('.jsonl')) continue;
401
+ const uuid = basename(name, '.jsonl');
402
+ if (!UUID_RE.test(uuid)) continue;
403
+ const file = join(dir, name);
404
+ let stat;
405
+ try { stat = statSync(file); } catch { continue; }
406
+ if (best && stat.mtimeMs <= best.mtimeMs) continue;
407
+ try {
408
+ const head = readHead(file, TRANSCRIPT_HEAD_BYTES);
409
+ if (TRANSCRIPT_SKIP_MARKERS.some(m => head.includes(m))) continue;
410
+ } catch { continue; }
411
+ best = { uuid, mtimeMs: stat.mtimeMs };
412
+ }
413
+ return best ? best.uuid : null;
414
+ } catch (err) {
415
+ reportSwallowed('sys-prompt-snap.resolve-continue', err);
416
+ return null;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Lazy hygiene, invoked from the spawn path (appendPending): drop snapshot files whose
422
+ * transcript is gone (past the grace window) or older than SNAPSHOT_GC_AGE_MS. Queue
423
+ * aging is handled inside the queue readers/writers themselves.
424
+ */
425
+ export function gc(cwd, logDir) {
426
+ try {
427
+ const projectKey = projectKeyForCwd(cwd);
428
+ const dir = storeDirForKey(projectKey, logDir);
429
+ if (!dir || !existsSync(dir)) return 0;
430
+ const tDir = transcriptDirForCwd(cwd);
431
+ const cutoff = _now() - SNAPSHOT_GC_AGE_MS;
432
+ const graceCutoff = _now() - GC_TRANSCRIPT_GRACE_MS;
433
+ let removed = 0;
434
+ for (const name of readdirSync(dir)) {
435
+ if (!name.endsWith('.json') || name === PENDING_FILE) continue;
436
+ const uuid = basename(name, '.json');
437
+ if (!UUID_RE.test(uuid)) continue;
438
+ const rec = readSnapshotByKey(projectKey, uuid, logDir);
439
+ if (!rec) continue; // corrupt → leave it for a human, don't churn
440
+ const stale = rec.createdAt > 0 && rec.createdAt < cutoff;
441
+ const transcriptGone = rec.createdAt < graceCutoff
442
+ && tDir && !existsSync(join(tDir, `${uuid}.jsonl`));
443
+ if (stale || transcriptGone) {
444
+ try { unlinkSync(join(dir, name)); removed++; } catch { /* best-effort */ }
445
+ }
446
+ }
447
+ return removed;
448
+ } catch (err) {
449
+ reportSwallowed('sys-prompt-snap.gc', err);
450
+ return 0;
451
+ }
452
+ }
@@ -42,6 +42,51 @@ export function detectHomebrewInstall(dirOverride, realpathImpl = realpathSync)
42
42
  return normalized.slice(0, idx); // 返回 brew prefix
43
43
  }
44
44
 
45
+ // Detect whether cc-viewer was installed via a pnpm global install (`pnpm add -g`).
46
+ // pnpm's global layout: <root>/global/<id>/node_modules/.pnpm/cc-viewer@<version>/node_modules/cc-viewer/
47
+ // - macOS default root: ~/Library/pnpm (PNPM_HOME)
48
+ // - Linux default root: ~/.local/share/pnpm
49
+ // - Windows default root: %LOCALAPPDATA%\pnpm
50
+ // - custom PNPM_HOME / global-dir: any path, but the `.pnpm` virtual-store segment stays fixed.
51
+ // Detection key: the `/.pnpm/cc-viewer@` virtual-store segment. npm never creates a `.pnpm`
52
+ // directory and the `pkg@<version>`-style segment only occurs inside pnpm-managed trees, so this
53
+ // is reliable regardless of where PNPM_HOME lives — a plain `/pnpm/` segment check would misfire
54
+ // on `npm i -g --prefix ~/Library/pnpm` installs. The marker covers `pnpm add -g` installs,
55
+ // `pnpm dlx` caches, and pnpm projects running cc-viewer from their own node_modules — all of
56
+ // them must NOT be upgraded via `npm install -g` (dual-channel pollution). The module's own
57
+ // realpath always flows through the virtual store (bin-shim symlinks resolve there too), so no
58
+ // PNPM_HOME env read is needed.
59
+ // dirOverride is for unit-test injection; realpathImpl likewise.
60
+ // Fail-safe: realpath throwing / path mismatch -> null (upstream falls back to the default npm
61
+ // path, never an unintended upgrade).
62
+ export function detectPnpmGlobalInstall(dirOverride, realpathImpl = realpathSync) {
63
+ const dir = dirOverride || __dirname;
64
+ let real;
65
+ try {
66
+ real = realpathImpl(dir);
67
+ } catch {
68
+ real = dir;
69
+ }
70
+ // Normalize Windows backslashes (forward slashes keep indexOf portable)
71
+ const normalized = real.replace(/\\/g, '/');
72
+ const marker = '/.pnpm/cc-viewer@';
73
+ const idx = normalized.indexOf(marker);
74
+ if (idx === -1) return null;
75
+ // The segment after `cc-viewer@` must be version-shaped (same regex as the brew detector):
76
+ // rejects dev-clone / backup paths that merely contain the literal marker text, and tag-style
77
+ // segments like `cc-viewer@nightly` (pnpm tag installs — same caveat as the brew HEAD comment).
78
+ const after = normalized.slice(idx + marker.length);
79
+ if (!/^\d[\w.\-+]*\//.test(after)) return null;
80
+ // Return the pnpm root (PNPM_HOME / custom global-dir): the path up to the first `/global/`
81
+ // segment; without one (e.g. a dlx cache) strip the trailing `/node_modules` and return that.
82
+ const globalIdx = normalized.indexOf('/global/');
83
+ if (globalIdx !== -1) return normalized.slice(0, globalIdx);
84
+ const preMarker = normalized.slice(0, idx);
85
+ return preMarker.endsWith('/node_modules')
86
+ ? preMarker.slice(0, -'/node_modules'.length)
87
+ : preMarker;
88
+ }
89
+
45
90
  const CHECK_INTERVAL = 4 * 60 * 60 * 1000; // 4 hours
46
91
  const CACHE_DIR = join(getClaudeConfigDir(), 'cc-viewer');
47
92
  const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
@@ -138,10 +183,12 @@ export function isAnyCcvBusy({ currentPid, busy, portRange, lsofImpl } = {}) {
138
183
  // undefined 或不传 = 走真探测。改用 !== undefined 比 hasOwnProperty 更稳,
139
184
  // 避免 caller 用 `{ ...opts, brewPrefix: maybe }` 时 maybe 为 undefined
140
185
  // 却被当显式传值,从而绕过自动检测,让 brew 用户被错误地走 npm 路径升级。
186
+ // pnpmRoot -- 测试注入。语义与 brewPrefix 完全相同:非 undefined = 显式覆盖(含 null/''),
187
+ // undefined = 走真探测 detectPnpmGlobalInstall()。
141
188
  //
142
189
  // 返回 status:
143
190
  // disabled | skipped | skipped_test_context | latest | major_available | deferred_busy
144
- // | brew_managed | upgrading_in_background | error
191
+ // | brew_managed | pnpm_managed | upgrading_in_background | error
145
192
  export async function checkAndUpdate(options = {}) {
146
193
  const fetchImpl = options.fetchImpl || fetch;
147
194
  const spawnImpl = options.spawnImpl || realSpawn;
@@ -153,6 +200,11 @@ export async function checkAndUpdate(options = {}) {
153
200
  const brewPrefix = options.brewPrefix !== undefined
154
201
  ? options.brewPrefix
155
202
  : detectHomebrewInstall();
203
+ // pnpm 全局安装(pnpm add -g)→ 同样不能 npm install -g:会在 npm 全局前缀残留第二份
204
+ // cc-viewer(双渠道污染),改建议 pnpm add -g cc-viewer@<version>
205
+ const pnpmRoot = options.pnpmRoot !== undefined
206
+ ? options.pnpmRoot
207
+ : detectPnpmGlobalInstall();
156
208
  const currentVersion = getCurrentVersion();
157
209
 
158
210
  // 跟随 Claude Code 全局配置
@@ -188,8 +240,8 @@ export async function checkAndUpdate(options = {}) {
188
240
  const remoteVersion = data['dist-tags']?.latest;
189
241
 
190
242
  // saveCheckTime 在所有分支前统一记录:4h 节流既避免 npm fetch 频繁,
191
- // 也覆盖 brew_managed banner 的提示频率(stderr 一行 + SSE 一次足够,4h 后再提)。
192
- // 故意不为 brew_managed 单独提速:banner 刷屏比慢提示更扰人,stderr 仍是侧通道。
243
+ // 也覆盖 brew_managed / pnpm_managed banner 的提示频率(stderr 一行 + SSE 一次足够,4h 后再提)。
244
+ // 故意不为 brew_managed / pnpm_managed 单独提速:banner 刷屏比慢提示更扰人,stderr 仍是侧通道。
193
245
  saveCheckTime();
194
246
 
195
247
  if (!remoteVersion) {
@@ -212,6 +264,14 @@ export async function checkAndUpdate(options = {}) {
212
264
  return { status: 'brew_managed', currentVersion, remoteVersion, brewPrefix };
213
265
  }
214
266
 
267
+ // pnpm 全局安装:不能 npm install -g(会在 npm 全局前缀造出第二份,双渠道污染)
268
+ // → 提示 pnpm add -g cc-viewer@<version>。同样必须放在 major_available 之前,
269
+ // 否则 pnpm 用户跨大版本时会被引导跑 npm i -g,触发我们正想杜绝的双渠道污染。
270
+ if (pnpmRoot) {
271
+ console.error(`[CC Viewer] ${t('update.pnpmManaged', { version: remoteVersion })}`);
272
+ return { status: 'pnpm_managed', currentVersion, remoteVersion, pnpmRoot };
273
+ }
274
+
215
275
  // 跨大版本:仅提示
216
276
  if (remote.major !== current.major) {
217
277
  console.error(`[CC Viewer] ${t('update.majorAvailable', { version: remoteVersion })}`);
@@ -6,17 +6,17 @@
6
6
  //
7
7
  // NOTE: this is a deliberate server-side SUBSET of the frontend classifier.
8
8
  // The full classification (synthetic prompts, secondary recycling, …) lives in
9
- // src/utils/contentFilter.js:isSystemText — frontend (dist) and server are two
9
+ // @ccv/core/contentFilter:isSystemText — frontend (dist) and server are two
10
10
  // separate bundles that cannot share a module, so only the rules that would
11
11
  // LEAK into previews are mirrored here: system tags + cross-session teammate
12
12
  // notifications.
13
13
  import { openSync, readSync, closeSync } from 'node:fs';
14
14
 
15
15
  // Cross-session / teammate protocol notification `type` whitelist (must match
16
- // src/utils/contentFilter.js INTER_SESSION_NOTIFICATION_TYPES; add new types in
16
+ // @ccv/core/contentFilter INTER_SESSION_NOTIFICATION_TYPES; add new types in
17
17
  // BOTH places). One array derives the Set (brace scan) + the RegExp
18
18
  // (isSystemText) so this file cannot drift internally.
19
- // test/stats-worker-notification-filter.test.js guards frontend↔server sync.
19
+ // packages/app/test/stats-worker-notification-filter.test.js guards frontend↔server sync.
20
20
  export const INTER_SESSION_TYPES = [
21
21
  'idle_notification', 'shutdown_request', 'shutdown_response', 'shutdown_approved',
22
22
  'teammate_terminated', 'plan_approval_request', 'plan_approval_response',
@@ -25,7 +25,7 @@ const INTER_SESSION_TYPES_SET = new Set(INTER_SESSION_TYPES);
25
25
  const INTER_SESSION_TYPES_RE = new RegExp(`"type"\\s*:\\s*"(?:${INTER_SESSION_TYPES.join('|')})"`);
26
26
 
27
27
  // CLI-synthesized prompts (HTTP role=user but not typed by a human): Recap /
28
- // Title / Compact / Topic / Summary. Must match src/utils/contentFilter.js
28
+ // Title / Compact / Topic / Summary. Must match @ccv/core/contentFilter
29
29
  // SYNTHETIC_PROMPTS (KEEP IN SYNC — same rule set, `^`-anchored on trimmed
30
30
  // text so quoted user text is never swallowed). Without this filter they leak
31
31
  // into the "all user prompts" preview (observed on real data: "The user
@@ -112,7 +112,7 @@ export function stripSystemTags(text) {
112
112
 
113
113
  /**
114
114
  * Extract the list of user prompt texts from a wire `messages` array.
115
- * (Kept in sync with src/utils/contentFilter.js:classifyUserContent.)
115
+ * (Kept in sync with @ccv/core/contentFilter:classifyUserContent.)
116
116
  */
117
117
  export function extractUserTexts(messages) {
118
118
  const texts = [];
@@ -29,12 +29,12 @@
29
29
 
30
30
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
31
31
  import { join, dirname, basename } from 'node:path';
32
- import { reportSwallowed } from '../error-report.js';
32
+ import { reportSwallowed } from '@ccv/core/error-report';
33
33
  import { isMainAgentRequest } from '../interceptor-core.js';
34
34
  import { readSession } from './replay.js';
35
35
  import { iterateJsonlLines } from './jsonl-read.js';
36
36
  import { isDiscardableSession } from './session-select.js';
37
- import { LIVE_SESSION_MTIME_MS } from '../log-management.js';
37
+ import { LIVE_SESSION_MTIME_MS } from '../log-file-utils.js';
38
38
  import { blobPath, isSupportedWireFormat } from './layout.js';
39
39
  import { SingleFlight } from './singleflight.js';
40
40
  import { listV2Sessions } from './session-list.js';
@@ -834,7 +834,7 @@ export async function scanV2Descriptors(sessionDir) {
834
834
  // the old substring filter (`"mainAgent":true` && !`"teammate"`) also
835
835
  // admitted sub/misc entries whose backfilled body LOOKED main-agent; the
836
836
  // isMain ring drops those — intentional, more-correct behavior (pinned in
837
- // test/v2-window-two-pass.test.js). Teammate-session main entries are
837
+ // packages/app/test/v2-window-two-pass.test.js). Teammate-session main entries are
838
838
  // excluded by both (they carry the teammate tag / isMain=false).
839
839
  if (d.isMain) {
840
840
  ring.push(d);
@@ -17,7 +17,7 @@
17
17
  // append order is completion order, semantic order is seq.
18
18
 
19
19
  import { readdirSync } from 'node:fs';
20
- import { isPostClearCheckpoint, isCompactContinuation, normalizeMsgForEquality } from '../session-boundary.js';
20
+ import { isPostClearCheckpoint, isCompactContinuation, normalizeMsgForEquality } from '@ccv/core/session-boundary';
21
21
  import { fingerprintMsg } from '../interceptor-core.js';
22
22
  import { ensureConvDirSync, convEpochPath, convDir } from './layout.js';
23
23
 
@@ -26,9 +26,9 @@
26
26
  import { readdirSync, existsSync, statSync, statfsSync, renameSync, rmSync, readFileSync, mkdirSync } from 'node:fs';
27
27
  import { join, basename } from 'node:path';
28
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';
29
+ import { createIncrementalReconstructor } from '@ccv/core/delta-reconstructor';
30
+ import { isLogFileName, parseLogTs } from '../log-file-utils.js';
31
+ import { reportSwallowed } from '@ccv/core/error-report';
32
32
  import { V2Writer } from './v2-writer.js';
33
33
  import { verifyV1File } from './verify.js';
34
34
  import { parseUserId } from './identity.js';
@@ -14,7 +14,7 @@
14
14
  // mirroring readJsonlTolerant's torn-tail tolerance (spec §14).
15
15
 
16
16
  import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
17
- import { reportSwallowed } from '../error-report.js';
17
+ import { reportSwallowed } from '@ccv/core/error-report';
18
18
 
19
19
  const DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
20
20
  // Node's max string length is 0x1fffffe8 (~512MiB) — a line this long can