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.
- package/README.md +20 -0
- package/cli.js +92 -11
- package/concepts/ar/GlobalSettings.md +2 -2
- package/concepts/ar/ToolsFirst.md +1 -1
- package/concepts/da/GlobalSettings.md +2 -2
- package/concepts/da/ToolsFirst.md +1 -1
- package/concepts/de/GlobalSettings.md +2 -2
- package/concepts/de/ToolsFirst.md +1 -1
- package/concepts/en/GlobalSettings.md +2 -2
- package/concepts/en/ToolsFirst.md +1 -1
- package/concepts/es/GlobalSettings.md +2 -2
- package/concepts/es/ToolsFirst.md +1 -1
- package/concepts/fr/GlobalSettings.md +2 -2
- package/concepts/fr/ToolsFirst.md +1 -1
- package/concepts/it/GlobalSettings.md +2 -2
- package/concepts/it/ToolsFirst.md +1 -1
- package/concepts/ja/GlobalSettings.md +2 -2
- package/concepts/ja/ToolsFirst.md +1 -1
- package/concepts/ko/GlobalSettings.md +2 -2
- package/concepts/ko/ToolsFirst.md +1 -1
- package/concepts/no/GlobalSettings.md +2 -2
- package/concepts/no/ToolsFirst.md +1 -1
- package/concepts/pl/GlobalSettings.md +2 -2
- package/concepts/pl/ToolsFirst.md +1 -1
- package/concepts/pt-BR/GlobalSettings.md +2 -2
- package/concepts/pt-BR/ToolsFirst.md +1 -1
- package/concepts/ru/GlobalSettings.md +2 -2
- package/concepts/ru/ToolsFirst.md +1 -1
- package/concepts/th/GlobalSettings.md +2 -2
- package/concepts/th/ToolsFirst.md +1 -1
- package/concepts/tr/GlobalSettings.md +2 -2
- package/concepts/tr/ToolsFirst.md +1 -1
- package/concepts/uk/GlobalSettings.md +2 -2
- package/concepts/uk/ToolsFirst.md +1 -1
- package/concepts/zh/GlobalSettings.md +2 -2
- package/concepts/zh/ToolsFirst.md +1 -1
- package/concepts/zh-TW/GlobalSettings.md +2 -2
- package/concepts/zh-TW/ToolsFirst.md +1 -1
- package/dist/assets/App-CqQJDjfT.js +2 -0
- package/dist/assets/App-ssYfrjwF.css +1 -0
- package/dist/assets/{MdxEditorPanel-e5DlfcqJ.js → MdxEditorPanel-DXwb5tSD.js} +1 -1
- package/dist/assets/{Mobile-B8xwCLgr.js → Mobile-DOjKURO3.js} +1 -1
- package/dist/assets/{ProxyStatsModal-_w677xPM.js → ProxyStatsModal-BEnxiRXF.js} +1 -1
- package/dist/assets/index-CH_N7vvX.js +2 -0
- package/dist/assets/seqResourceLoaders-BJhO6zhW.js +2 -0
- package/dist/assets/seqResourceLoaders-DI0G5nu1.css +41 -0
- package/dist/index.html +1 -1
- package/findcc.js +17 -8
- package/node_modules/@ccv/core/package.json +13 -0
- package/{server/lib → node_modules/@ccv/core/src}/approval-modal-prefs.js +1 -1
- package/node_modules/@ccv/core/src/clearCheckpoint.js +7 -0
- package/{src/utils → node_modules/@ccv/core/src}/contentFilter.js +207 -141
- package/{server/lib → node_modules/@ccv/core/src}/context-rules.js +2 -2
- package/{server/lib → node_modules/@ccv/core/src}/delta-reconstructor.js +60 -4
- package/{server/lib → node_modules/@ccv/core/src}/error-report.js +5 -4
- package/{server/lib → node_modules/@ccv/core/src}/session-boundary.js +3 -3
- package/{src/utils → node_modules/@ccv/core/src}/teammateDetector.js +9 -0
- package/{server/lib → node_modules/@ccv/core/src}/tools-xml-formatter.js +3 -3
- package/{server/lib → node_modules/@ccv/core/src}/v2-transcript-normalizer.js +1 -1
- package/{server/lib → node_modules/@ccv/core/src}/voice-pack-events.js +2 -2
- package/package.json +11 -97
- package/server/_paths.js +28 -7
- package/server/i18n.js +60 -0
- package/server/interceptor.js +36 -11
- package/server/lib/adapters/dingtalk-adapter.js +2 -2
- package/server/lib/adapters/discord-adapter.js +2 -2
- package/server/lib/adapters/feishu-adapter.js +2 -2
- package/server/lib/adapters/wecom-adapter.js +2 -2
- package/server/lib/approval-policy.js +29 -0
- package/server/lib/{ask-store.js → ask/ask-store.js} +3 -3
- package/server/lib/{perm-bridge.js → ask/perm-bridge.js} +5 -5
- package/server/lib/builtin-model-prompts.js +198 -0
- package/server/lib/chat-queue.js +341 -0
- package/server/lib/cli-inject.js +1 -1
- package/server/lib/context-watcher.js +3 -3
- package/server/lib/create_system_prompt.js +0 -9
- package/server/lib/enrich-plan-input.js +1 -1
- package/server/lib/ensure-hooks.js +2 -2
- package/server/lib/file-access-policy.js +6 -1
- package/server/lib/file-api.js +17 -1
- package/server/lib/{dingtalk-bridge.js → im/dingtalk-bridge.js} +1 -1
- package/server/lib/{im-append-system.js → im/im-append-system.js} +5 -13
- package/server/lib/{im-bridge-core.js → im/im-bridge-core.js} +12 -6
- package/server/lib/{im-config.js → im/im-config.js} +1 -1
- package/server/lib/im/im-deny.js +4 -0
- package/server/lib/{im-lang.js → im/im-lang.js} +1 -1
- package/server/lib/{im-lock.js → im/im-lock.js} +2 -2
- package/server/lib/{im-process-manager.js → im/im-process-manager.js} +1 -1
- package/server/lib/{im-senders.js → im/im-senders.js} +1 -1
- package/server/lib/{im-skills.js → im/im-skills.js} +2 -2
- package/server/lib/interceptor-core.js +8 -5
- package/server/lib/kv-cache-analyzer.js +6 -5
- package/server/lib/launch-config.js +329 -0
- package/server/lib/log-file-utils.js +21 -0
- package/server/lib/log-management.js +6 -17
- package/server/lib/log-stream.js +1 -1
- package/server/lib/log-watcher.js +1 -1
- package/server/lib/model-system-prompts.js +18 -2
- package/server/lib/prefs-store.js +2 -2
- package/server/lib/project-prefs.js +6 -6
- package/server/lib/project-state.js +15 -0
- package/server/lib/{proxy-retry.js → proxy/proxy-retry.js} +2 -2
- package/server/lib/{proxy-stats.js → proxy/proxy-stats.js} +3 -3
- package/server/lib/sdk-adapter.js +6 -74
- package/server/lib/sdk-manager.js +383 -278
- package/server/lib/skills-api.js +1 -1
- package/server/lib/spawn-model-resolver.js +21 -6
- package/server/lib/stats-worker.js +2 -2
- package/server/lib/system-prompt-files.js +53 -15
- package/server/lib/system-prompt-render.js +23 -7
- package/server/lib/system-prompt-snapshots.js +452 -0
- package/server/lib/updater.js +63 -3
- package/server/lib/user-prompt-extract.js +5 -5
- package/server/lib/v2/adapter.js +3 -3
- package/server/lib/v2/conversation-store.js +1 -1
- package/server/lib/v2/convert.js +3 -3
- package/server/lib/v2/jsonl-read.js +1 -1
- package/server/lib/v2/live-feed.js +3 -3
- package/server/lib/v2/meta-rows.js +3 -3
- package/server/lib/v2/replay.js +1 -1
- package/server/lib/v2/session-list.js +1 -1
- package/server/lib/v2/session-owner.js +1 -1
- package/server/lib/v2/v2-writer.js +26 -1
- package/server/lib/v2/verify.js +1 -1
- package/server/lib/voice-pack-manager.js +4 -5
- package/server/lib/wire-compress.js +1 -1
- package/server/proxy.js +5 -5
- package/server/pty-manager.js +285 -184
- package/server/routes/ask-perm.js +1 -1
- package/server/routes/dingtalk.js +3 -3
- package/server/routes/events.js +2 -2
- package/server/routes/expert.js +74 -13
- package/server/routes/im.js +5 -5
- package/server/routes/preferences.js +3 -3
- package/server/routes/workspaces.js +1 -1
- package/server/server.js +207 -58
- package/server/system-prompt-templates/systemPromptModel.md +0 -8
- package/server/system-prompt-templates/systemPromptVariables.ar.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.da.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.de.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.es.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.fr.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.it.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.ja.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.ko.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.no.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.pl.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.pt-BR.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.ru.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.th.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.tr.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.uk.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.zh-TW.md +0 -7
- package/server/system-prompt-templates/systemPromptVariables.zh.md +0 -7
- package/ultraAgents/README.md +3 -0
- package/ultraAgents/test-analysis-expert.json +45 -0
- package/dist/assets/App-BWajJyJh.css +0 -1
- package/dist/assets/App-CusqoEAB.js +0 -2
- package/dist/assets/index-D6FmFsIz.js +0 -2
- package/dist/assets/seqResourceLoaders-3K77Znll.js +0 -2
- package/dist/assets/seqResourceLoaders-BgD_Rwvc.css +0 -41
- package/src/utils/apiUrl.js +0 -26
- package/src/utils/askFallback.js +0 -21
- package/src/utils/askOptionDesc.js +0 -55
- package/src/utils/askPortalMatcher.js +0 -54
- package/src/utils/autoApproveOptions.js +0 -27
- package/src/utils/avatarAnimationPostPass.js +0 -49
- package/src/utils/builtinPresets.js +0 -18
- package/src/utils/clearCheckpoint.js +0 -7
- package/src/utils/commandValidator.js +0 -45
- package/src/utils/contextRaw.js +0 -18
- package/src/utils/contextSidebarNavigation.js +0 -16
- package/src/utils/contextTurns.js +0 -117
- package/src/utils/displayScaleHelper.js +0 -47
- package/src/utils/effectiveModel.js +0 -17
- package/src/utils/entry-slim.js +0 -563
- package/src/utils/entryCache.js +0 -144
- package/src/utils/errorReport.js +0 -6
- package/src/utils/fileExpandedPathsStorage.js +0 -70
- package/src/utils/fileIcons.jsx +0 -36
- package/src/utils/fileOpen.js +0 -37
- package/src/utils/formatters.js +0 -48
- package/src/utils/gitApi.js +0 -50
- package/src/utils/gitTreeBuilder.js +0 -19
- package/src/utils/helpers.js +0 -854
- package/src/utils/identityHeal.js +0 -108
- package/src/utils/imConnState.js +0 -59
- package/src/utils/imOrigin.js +0 -24
- package/src/utils/imTr.js +0 -7
- package/src/utils/imageCompress.js +0 -83
- package/src/utils/imageDownscale.js +0 -28
- package/src/utils/imageResize.js +0 -112
- package/src/utils/ingestPipeline.js +0 -47
- package/src/utils/isProxyMode.js +0 -11
- package/src/utils/lazyWithReload.js +0 -53
- package/src/utils/markdown.js +0 -30
- package/src/utils/markdownIncremental.js +0 -39
- package/src/utils/markdownProfiler.js +0 -113
- package/src/utils/mdExtensionDetect.js +0 -64
- package/src/utils/memoryLinkParser.js +0 -44
- package/src/utils/modalMask.js +0 -15
- package/src/utils/modelSuggestions.js +0 -48
- package/src/utils/monotime.js +0 -6
- package/src/utils/pinnedMenu.js +0 -55
- package/src/utils/presetShortcuts.js +0 -14
- package/src/utils/projectAlias.js +0 -135
- package/src/utils/promptClassifier.js +0 -112
- package/src/utils/promptDetect.js +0 -196
- package/src/utils/promptNav.js +0 -60
- package/src/utils/ptyChunkBuilder.js +0 -251
- package/src/utils/quickMenuHoverIntent.js +0 -35
- package/src/utils/rateLimitParser.js +0 -119
- package/src/utils/readResultPool.js +0 -106
- package/src/utils/refreshCachedItemProp.js +0 -30
- package/src/utils/resizeCalc.js +0 -21
- package/src/utils/resolveLocalized.js +0 -17
- package/src/utils/searchApi.js +0 -47
- package/src/utils/searchReplace.js +0 -51
- package/src/utils/seqResourceLoaders.js +0 -79
- package/src/utils/sessionManager.js +0 -482
- package/src/utils/sessionMerge.js +0 -239
- package/src/utils/skillModalController.js +0 -111
- package/src/utils/skillsParser.js +0 -116
- package/src/utils/slashCommandLabels.js +0 -80
- package/src/utils/splitDragCalc.js +0 -73
- package/src/utils/stickyBottomController.js +0 -643
- package/src/utils/svgSanitize.js +0 -48
- package/src/utils/systemTags.js +0 -47
- package/src/utils/tClaude.js +0 -20
- package/src/utils/teamModalBuilder.js +0 -351
- package/src/utils/teamSessionParser.js +0 -190
- package/src/utils/teammateAvatars.js +0 -194
- package/src/utils/termDiag.js +0 -114
- package/src/utils/terminalClipboard.js +0 -117
- package/src/utils/terminalWriteQueue.js +0 -398
- package/src/utils/thinkingMerge.js +0 -49
- package/src/utils/toolCatalog.js +0 -30
- package/src/utils/toolResultBuilder.js +0 -411
- package/src/utils/toolResultClassifier.js +0 -16
- package/src/utils/toolResultCore.js +0 -194
- package/src/utils/toolsDiff.js +0 -44
- package/src/utils/toolsXmlFormatter.js +0 -3
- package/src/utils/ultraplanController.js +0 -109
- package/src/utils/ultraplanExperts.js +0 -86
- package/src/utils/ultraplanTemplates.js +0 -154
- package/src/utils/userImageRefs.js +0 -49
- package/src/utils/v3Assembler.js +0 -125
- package/src/utils/v3Rows.js +0 -49
- package/src/utils/voicePackPlayer.js +0 -233
- package/src/utils/webSearchGrouping.js +0 -111
- package/src/utils/workflowFormat.js +0 -52
- package/src/utils/workflowRuns.js +0 -118
- package/src/utils/workflowStore.js +0 -94
- /package/{src/utils → node_modules/@ccv/core/src}/requestType.js +0 -0
- /package/server/lib/{ask-bridge.js → ask/ask-bridge.js} +0 -0
- /package/server/lib/{ask-constants.js → ask/ask-constants.js} +0 -0
- /package/server/lib/{ask-reaper.js → ask/ask-reaper.js} +0 -0
- /package/server/lib/{dingtalk-config.js → im/dingtalk-config.js} +0 -0
- /package/server/lib/{im-log-watcher.js → im/im-log-watcher.js} +0 -0
- /package/server/lib/{proxy-env.js → proxy/proxy-env.js} +0 -0
- /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
|
+
}
|
package/server/lib/updater.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
115
|
+
* (Kept in sync with @ccv/core/contentFilter:classifyUserContent.)
|
|
116
116
|
*/
|
|
117
117
|
export function extractUserTexts(messages) {
|
|
118
118
|
const texts = [];
|
package/server/lib/v2/adapter.js
CHANGED
|
@@ -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 '
|
|
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-
|
|
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 '
|
|
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
|
|
package/server/lib/v2/convert.js
CHANGED
|
@@ -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 '
|
|
30
|
-
import { isLogFileName, parseLogTs } from '../log-
|
|
31
|
-
import { reportSwallowed } from '
|
|
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 '
|
|
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
|