cc-viewer 1.8.14 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +6 -5
  2. package/cli.js +19 -0
  3. package/dist/assets/{App-ZWY7wiA3.js → App-_HfxMJvv.js} +2 -2
  4. package/dist/assets/{MdxEditorPanel-D89HBOrd.js → MdxEditorPanel-3j8rS3IT.js} +1 -1
  5. package/dist/assets/{Mobile-CitUOMlD.js → Mobile-DaRLB7Gh.js} +1 -1
  6. package/dist/assets/{ProxyStatsModal-B92bNhle.js → ProxyStatsModal-dlPIDlx-.js} +1 -1
  7. package/dist/assets/index-CjbBUDGP.js +2 -0
  8. package/dist/assets/seqResourceLoaders-hqLhsiGO.js +2 -0
  9. package/dist/index.html +1 -1
  10. package/node_modules/@ccv/core/src/context-rules.js +5 -0
  11. package/package.json +1 -1
  12. package/server/i18n.js +2 -2
  13. package/server/interceptor.js +176 -21
  14. package/server/lib/builtin-model-prompts.js +12 -1
  15. package/server/lib/create_system_prompt.js +94 -1
  16. package/server/lib/interceptor-core.js +144 -0
  17. package/server/lib/launch-config.js +83 -3
  18. package/server/lib/model-system-prompts.js +6 -1
  19. package/server/lib/proxy/proxy-retry.js +57 -2
  20. package/server/lib/resume-watchdog.js +108 -0
  21. package/server/lib/session-id.js +33 -0
  22. package/server/lib/shell-hook-inspect.js +48 -0
  23. package/server/lib/system-prompt-files.js +14 -0
  24. package/server/lib/system-prompt-live.js +622 -0
  25. package/server/lib/v2/identity.js +3 -24
  26. package/server/lib/v2/session-select.js +11 -8
  27. package/server/lib/v2/v2-writer.js +13 -5
  28. package/server/proxy.js +32 -6
  29. package/server/routes/events.js +9 -3
  30. package/server/routes/preferences.js +6 -1
  31. package/server/routes/workspaces.js +3 -2
  32. package/server/server.js +33 -5
  33. package/server/system-prompt-templates/presets/GLM-5.2.md +2 -0
  34. package/server/system-prompt-templates/presets/GLM-5.3.md +2 -0
  35. package/server/system-prompt-templates/presets/{Qwen-3.7-Max.md → Qwen-3.md} +3 -1
  36. package/server/system-prompt-templates/presets/deepseek-v4-flash.md +2 -0
  37. package/server/system-prompt-templates/presets/deepseek-v4-pro.md +2 -0
  38. package/server/system-prompt-templates/presets/index.json +7 -7
  39. package/server/system-prompt-templates/presets/kimi-k2.7-code.md +3 -0
  40. package/server/system-prompt-templates/presets/kimi-k3.md +2 -0
  41. package/dist/assets/index-OWc1WNdK.js +0 -2
  42. package/dist/assets/seqResourceLoaders-Bng7YSRv.js +0 -2
package/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-OWc1WNdK.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-CjbBUDGP.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-DKIytXho.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-4qgXq03k.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-D5Z5D9fq.js">
@@ -77,7 +77,10 @@ const MODEL_CONTEXT_SIZES = [
77
77
  { match: /kimi|moonshot|^k3$/i, tokens: 256000 },
78
78
  // deepseek-v4 defaults to 1M; placed before generic /deepseek/ so the
79
79
  // first-match-wins loop picks it up before falling through to 128K.
80
+ // The `deepseek-flash` shorthand (an alias of deepseek-v4-flash, see the prompt
81
+ // layer's MODEL_ID_ALIASES) must hit the same 1M tier — /deepseek/ would give 128K.
80
82
  { match: /deepseek-v4/i, tokens: 1000000 },
83
+ { match: /^deepseek-flash/i, tokens: 1000000 },
81
84
  { match: /deepseek/i, tokens: 128000 },
82
85
  ];
83
86
 
@@ -118,6 +121,8 @@ export function classifyContextWindow(modelName) {
118
121
  if (!modelName || typeof modelName !== 'string') return 200000;
119
122
  if (modelName.toLowerCase().includes('1m')) return 1000000;
120
123
  if (/kimi|moonshot|^k3$/i.test(modelName)) return 1000000;
124
+ // deepseek-flash 简写(deepseek-v4-flash 的别名)归 1M 桶,与请求侧判定对齐。
125
+ if (/^deepseek-flash/i.test(modelName)) return 1000000;
121
126
  return getModelMaxTokens(modelName) >= 1000000 ? 1000000 : 200000;
122
127
  }
123
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.8.14",
3
+ "version": "1.9.0",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/server/i18n.js CHANGED
@@ -263,8 +263,8 @@ const i18nData = {
263
263
  "uk": "⚠️ Не вдалося записати shell hook: {error}"
264
264
  },
265
265
  "cli.usage.hint": {
266
- "zh": "\n如需卸载,请运行: ccv --uninstall",
267
- "en": "\nTo uninstall, run: ccv --uninstall",
266
+ "zh": "\nShell hook 需重开终端(或 source rc 文件)后生效;此后终端里的 `claude -c` 也会经 ccv 注入。\n如需卸载,请运行: ccv --uninstall",
267
+ "en": "\nThe shell hook takes effect in NEW terminals (or after sourcing your rc file); afterwards `claude -c` in a terminal is also injected via ccv.\nTo uninstall, run: ccv --uninstall",
268
268
  "zh-TW": "\n如需卸載,請執行: ccv --uninstall",
269
269
  "ko": "\n제거하려면 실행: ccv --uninstall",
270
270
  "ja": "\nアンインストールするには: ccv --uninstall",
@@ -14,14 +14,18 @@ import { homedir } from 'node:os';
14
14
  import { fileURLToPath, pathToFileURL } from 'node:url';
15
15
  import { dirname, join, basename } from 'node:path';
16
16
  import { LOG_DIR } from '../findcc.js';
17
- import { assembleStreamMessage, createStreamAssembler, isAnthropicApiPath, isMainAgentRequest, replaceTopLevelModel, injectOutputConfigEffort, resolveProfileModel, extractAgentSpawnPairs, classifyProxyRole, resolveRoleProfile, normalizeRoles, mergeActivePayload } from './lib/interceptor-core.js';
17
+ import { assembleStreamMessage, createStreamAssembler, isAnthropicApiPath, isMainAgentRequest, replaceTopLevelModel, replaceTopLevelSystem, injectOutputConfigEffort, resolveProfileModel, extractAgentSpawnPairs, classifyProxyRole, resolveRoleProfile, normalizeRoles, mergeActivePayload } from './lib/interceptor-core.js';
18
18
  import { V2Writer } from './lib/v2/v2-writer.js';
19
19
  import { reportSwallowed } from '@ccv/core/error-report';
20
- import { latestMainSessionDir, sessionHasCompletedMainTurn } from './lib/v2/session-select.js';
20
+ import { latestMainSessionDir, sessionHasMainTurn, sessionHasCompletedMainTurn } from './lib/v2/session-select.js';
21
21
  import { sanitizePathComponent } from './lib/v2/layout.js';
22
+ import { parseAgentId, findHeader } from './lib/v2/agent-id.js';
23
+ import { parseUserId } from './lib/session-id.js';
22
24
  import { setRetryConfigPath, loadRetryConfig, DEFAULT_RETRY_CONFIG } from './lib/proxy/proxy-retry.js';
23
25
  import { setProjectName } from './lib/project-state.js';
24
26
  import { consumePendingForResume, writeSnapshot, projectKeyForCwd } from './lib/system-prompt-snapshots.js';
27
+ import { liveSystemPromptEnabled, getLaunchSystemPromptInfo, getLiveEntry, putLiveEntry, selectEntriesForModel, applyLiveSystem, knownInjectedTexts } from './lib/system-prompt-live.js';
28
+ import { MODEL_PROMPT_DIR } from './lib/model-system-prompts.js';
25
29
 
26
30
 
27
31
 
@@ -437,21 +441,31 @@ export { _v2Writer };
437
441
  // bounded cold-load window (DEFAULT_EVENTS_LIMIT) as any session — never
438
442
  // limit=0 — so it stays memory-safe (S10). All three cold-load consumers
439
443
  // (/events, /api/requests, workspace reload) route through here.
440
- export function getLiveLogSource() {
444
+ export function getLiveLogSource({ serveInFlight = true } = {}) {
441
445
  const dir = _v2Writer.currentSessionDir();
442
- // "Activated" requires a COMPLETED main turn, not merely a written main
443
- // request line: a session with only an in-flight first request has nothing a
444
- // cold load can render yet, so keep falling back to the previous conversation
445
- // (which CAN render) until the current one has a done. Removes the blank flash
446
- // between "first `-c`/fresh main request written" and "its response emitted".
447
- if (dir && sessionHasCompletedMainTurn(dir)) return dir; // activated current session
446
+ // "Activated": a completed main turn, OR — only when the caller's wire can
447
+ // render it — a first main turn currently in flight (req line on disk, no
448
+ // done yet). The in-flight case became renderable once the client batch
449
+ // path stopped blocking known-full in-progress carriers (v3 wire: the req
450
+ // + its conv state are written at request initiation, 2026-09-13
451
+ // refresh-blank fix), so serve OUR OWN current dir instead of falling back
452
+ // to the previous conversation. Legacy-wire callers pass
453
+ // serveInFlight:false: their cold stream carries the placeholder, which the
454
+ // client batch gate still blocks — serving the in-flight dir there blanks
455
+ // the panel where the previous conversation rendered before (worst after
456
+ // an Esc-aborted first turn, whose placeholder is never replaced).
457
+ // sessionHasMainTurn stays the second arm (not a replacement): || short-
458
+ // circuits on true, so its narrow head-only scan (256KB) runs ONLY when
459
+ // the wide completed-turn scan (8MB) already returned false — precisely
460
+ // the in-flight case it exists to catch, at zero extra cost otherwise.
461
+ if (dir && (sessionHasCompletedMainTurn(dir) || (serveInFlight && sessionHasMainTurn(dir)))) return dir; // activated current session
448
462
  if (!_projectName) return ''; // no project bound yet (mirrors v2-writer's guard)
449
463
  try {
450
- // excludeDir: the current session just failed the completed-turn gate, but
451
- // the picker's weaker has-a-main-req gate would re-select it (it is the
452
- // newest dir once its first main req is written) — handing back exactly the
453
- // blank in-flight session the strict gate rejected. Excluding it makes the
454
- // fallback actually land on the previous, renderable conversation.
464
+ // excludeDir: the caller's activated gate and the picker now share the
465
+ // same has-a-main-req predicate, so exclusion is not selection logic — it
466
+ // closes the TOCTOU race where a main req line lands between the gate
467
+ // check above and the picker's scan (the current dir would otherwise be
468
+ // re-selected as the newest, nullifying the fallback).
455
469
  // skipForeignLive: a parallel ccv window's in-flight session must never be
456
470
  // served as THIS window's cold load (multi-window isolation); a crashed
457
471
  // window's claim expires with its pid, so its session stays selectable.
@@ -902,16 +916,39 @@ export function setupInterceptor() {
902
916
  // (_defaultConfig 首请求才捕获)。
903
917
  // 短路:无 main profile 且无任何角色分配时,角色不影响结果(_effProfile 恒 null),
904
918
  // 跳过分类的 system 文本提取开销 —— 未配置用户零成本。
919
+ // 但 live system 已启用时必须分类(review 第二轮 P0-1):短路硬编码 'main' 会让同进程
920
+ // native teammate(含团队标记)/ subagent(含 cc_is_subagent 标记)以 'main' 穿过 live
921
+ // 门被强注入主 persona —— 恰是分类要保护的标记被覆盖。与 proxy.js:142 的分流同构:
922
+ // 仅当「确实无需分类」(无 profile + 无角色分配 + live 未启用)才短路。
905
923
  let _fetchUrl = url;
906
924
  let _fetchOpts = options;
907
- const _proxyRole = (!_activeProfile && _roleIds.subagent === 'follow' && _roleIds.teammate === 'follow')
908
- ? 'main'
909
- : classifyProxyRole(requestEntry?.body, {
910
- isTeammate: _isTeammate,
911
- isCountTokens: !!requestEntry?.isCountTokens,
912
- isHeartbeat: !!requestEntry?.isHeartbeat,
913
- });
925
+ const _skipClassify = !_activeProfile && _roleIds.subagent === 'follow' && _roleIds.teammate === 'follow'
926
+ && !liveSystemPromptEnabled();
927
+ // 最高优先级硬判据(review 第二轮二次验证 P1-1 残余,与 contentFilter.js:158 同构):
928
+ // 请求头 x-claude-code-agent-id 是 SDK 命名队友(name@…)/ 匿名子代理(hex)的判别信号,
929
+ // 不依赖 system 文本 —— body 正则(TEAMMATE_SYSTEM_RE / cc_is_subagent)对「SDK 身份行 +
930
+ // 无 billing 标记」的形态失效,会把这类请求误判 'main' 而注入主 persona。named→teammate、
931
+ // anon→subagent,覆盖 body 分类。O(1) header 查键,但在短路口(live 未启用+无配置)
932
+ // 无需角色时不做 —— 保持未配置用户零成本语义。
933
+ let _proxyRole;
934
+ if (_skipClassify) {
935
+ _proxyRole = 'main';
936
+ } else {
937
+ const _agent = parseAgentId(findHeader(requestEntry?.headers, 'x-claude-code-agent-id'));
938
+ _proxyRole = _agent
939
+ ? (_agent.named ? 'teammate' : 'subagent')
940
+ : classifyProxyRole(requestEntry?.body, {
941
+ isTeammate: _isTeammate,
942
+ isCountTokens: !!requestEntry?.isCountTokens,
943
+ isHeartbeat: !!requestEntry?.isHeartbeat,
944
+ });
945
+ }
914
946
  const _effProfile = _effectiveRoleProfile(_proxyRole);
947
+ // 生效模型解析提到块外:live system 改写(步骤 3.5)需要在无 baseURL 的纯模型
948
+ // profile 下也能拿到目标模型;_targetModel 为 null 时回落到请求自身的 model。
949
+ const _rb0 = requestEntry?.body;
950
+ const _oldModel0 = (_rb0 && typeof _rb0 === 'object' && typeof _rb0.model === 'string') ? _rb0.model : undefined;
951
+ const _targetModel0 = (_effProfile && _oldModel0) ? resolveProfileModel(_oldModel0, _effProfile) : null;
915
952
  if (_effProfile && _effProfile.baseURL && requestEntry) {
916
953
  try {
917
954
  // 1. URL 重写: 用 baseURL 替换 origin,智能处理路径重叠
@@ -998,6 +1035,124 @@ export function setupInterceptor() {
998
1035
  } catch { }
999
1036
  }
1000
1037
 
1038
+ // 3.5. Live system 改写 —— system 文本随主模型热切换 + (sessionId, model) 静态化。
1039
+ // 独立于上面的 baseURL 块(纯模型 profile 无 baseURL 也要生效)。门:
1040
+ // liveSystemPromptEnabled(启动期 ccv 确实注入且未 suppressed/env 关闭)+
1041
+ // role=main(teammate/subagent 跳过,保护角色分类标记)+ 非 count_tokens/heartbeat
1042
+ // (utility 端点绝不能发明/改写 system,会污染 token 计数)。
1043
+ // 门判据用 _proxyRole(classifyProxyRole),不再用 requestEntry.mainAgent
1044
+ // (isMainAgentRequest):后者要求 system 含 "You are Claude Code" 官方文案,
1045
+ // 而 override 主场景(--system-prompt-file 整段替换)的 base 是自定义 persona,
1046
+ // 会被该判据误排 → live 特性在 override 主场景整体失效。_proxyRole 对 override
1047
+ // persona 正确返回 'main'。isMainAgentRequest 仍用于日志/v2 分类。
1048
+ // 判据收窄说明(review 第二轮):旧门 isMainAgentRequest 是「system 文案子串 + tools
1049
+ // 启发式」两重判据,新门 classifyProxyRole 仅「teammate/subagent 标记正则」一重 ——
1050
+ // 对「无标记 teammate / 无 billing 标记 subagent」由「不改写」变为「改写」,判据
1051
+ // 由两重降为一重。配套:tools 结构门(与 proxy-retry.js 对齐,挡 title-gen 等
1052
+ // tools=[] 的旁路调用)+ P0-1 短路口分类修复,把主要受害面收回。
1053
+ // 继承性风险(残余):同进程同 persona teammate 若不含 TEAMMATE_SYSTEM_RE 标记仍判
1054
+ // 'main'(见 interceptor-core.js classifyProxyRole 注释),依赖该正则分类。
1055
+ // tools 门阈值用「非空」(>=1) 与 proxy 完全一致 —— 不设数量下限:真实语料 1~5 工具
1056
+ // 的合法主会话/子代理变体存在(web_search 单工具、Bash+Read+WebFetch+WebSearch 4 工具),
1057
+ // 它们靠 cc_is_subagent 标记被 classifyProxyRole 正确排除,不能靠工具数误判。
1058
+ // 启动模型条目 seed 自启动期注入字节(零渲染);切到其它模型时按当前选中模型
1059
+ // 同步选择 + 渲染 —— ${...} 变量复用启动期按 launchInfo 发布的变量快照(git/os/env
1060
+ // 与启动文本一致),仅 time/model 实时,热切换首请求即生效。
1061
+ // 自带 try/catch + reportSwallowed:外层 baseURL 块的裸 catch 覆盖不到这里。
1062
+ try {
1063
+ const _hookTools = requestEntry?.body?.tools;
1064
+ if (requestEntry && _proxyRole === 'main' &&
1065
+ !requestEntry.isCountTokens && !requestEntry.isHeartbeat &&
1066
+ Array.isArray(_hookTools) && _hookTools.length > 0 &&
1067
+ liveSystemPromptEnabled()) {
1068
+ const _targetModel = _targetModel0 || _oldModel0;
1069
+ const _projectKey = _projectName; // 每请求快照一次:工作区切换中途不串写
1070
+ const _sid = parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null;
1071
+ if (_targetModel && _projectKey && _sid) {
1072
+ const _li = getLaunchSystemPromptInfo();
1073
+ let _liveEntry = getLiveEntry(_projectKey, _sid, _targetModel);
1074
+ // 启动模型 seed:
1075
+ // - pinned(resume -c/-r):无条件 seed 自 pin 字节(不比较模型 id)——resume 的
1076
+ // 启动模型就是 pin 的模型,seed 自 pin 字节即「pin 不被强行覆盖」(防模板漂移
1077
+ // 重选破坏被恢复上下文的 KV-cache)。
1078
+ // - fresh:仅当本次启动确实注入了内容且模型匹配时 seed(不重新渲染,保住 Bind A)。
1079
+ // 启动没注入(强行覆盖要补的场景)则 fallthrough 到下面的同步选择 + 渲染。
1080
+ // 模型 id 判据:剥 [1m] 后缀后比较(resolveProfileModel 同款,Claude Code 1M
1081
+ // context 标记不应影响匹配)。resolvedModelId 为 null(启动无模型信号)时不 seed
1082
+ // —— 无法确认请求模型即启动模型,保守走同步选择(避免把启动字节错配给切换后的
1083
+ // 不同家族模型)。该场景下同步选择用启动期变量快照渲染(${git.*} 等与启动一致)。
1084
+ const _strip1m = (s) => (typeof s === 'string' ? s.replace(/\[1m\]/gi, '').trim() : '');
1085
+ const _modelMatch = _li && (
1086
+ _li.pinned === true ||
1087
+ (_li.resolvedModelId != null && _strip1m(_targetModel) === _strip1m(_li.resolvedModelId))
1088
+ );
1089
+ const _seedable = _li && _li.entries.length > 0 && _modelMatch;
1090
+ if (!_liveEntry && _seedable) {
1091
+ const seeded = { override: null, append: null };
1092
+ for (const e of _li.entries) {
1093
+ if (e.flag === '--system-prompt-file') seeded.override = e.content;
1094
+ else if (e.flag === '--append-system-prompt-file') seeded.append = e.content;
1095
+ }
1096
+ if (seeded.override || seeded.append) {
1097
+ putLiveEntry(_projectKey, _sid, _targetModel, seeded);
1098
+ _liveEntry = seeded;
1099
+ }
1100
+ }
1101
+ // 强行覆盖:缓存未就绪(含「启动未注入」与「切换到非启动模型」两种)→ 同步按
1102
+ // 当前选中模型选择 + 渲染。渲染已无现场子进程:${...} 变量取自启动期按
1103
+ // launchInfo 发布的变量快照(git/os/env 与启动文本一致),仅 time/model 实时 ——
1104
+ // 故热切换**首请求即生效**。旧实现的 setImmediate 异步旁路让首请求沿用上一个
1105
+ // 模型的人格(人格错配),已拆除。选择本身是小文件同步读(模型条目 + sentinel),
1106
+ // 固化后不再重复。
1107
+ // pinned 双保险:F2 resume(pinned 且 entries 为空)语义是「绝不改动既有上下文
1108
+ // 的 system」——launch-config 已对 F2 关 allowLive,这里再挡一层(防御陈旧 launchInfo)。
1109
+ if (!_liveEntry && _li && _li.workspaceDir && !(_li.pinned === true && _li.entries.length === 0)) {
1110
+ try {
1111
+ const _selected = selectEntriesForModel(_targetModel, {
1112
+ workspaceDir: _li.workspaceDir,
1113
+ globalModelDir: join(LOG_DIR, MODEL_PROMPT_DIR),
1114
+ });
1115
+ if (_selected) {
1116
+ // putLiveEntry 落盘失败(如超 256KB 上限/磁盘不可写)不影响当次改写:
1117
+ // wire 仍用选中文本,只是下次请求重选一遍(与 seed 分支同一取舍)。
1118
+ putLiveEntry(_projectKey, _sid, _targetModel, _selected);
1119
+ _liveEntry = _selected;
1120
+ }
1121
+ // 无匹配条目 → _selected 为 null,当次请求不改写(selectEntriesForModel total)
1122
+ } catch (err) { reportSwallowed('interceptor.live-system-select', err); }
1123
+ }
1124
+ if (_liveEntry && requestEntry.body && _fetchOpts?.body) {
1125
+ const _known = knownInjectedTexts(_projectKey, _sid);
1126
+ const _hadSystem = 'system' in requestEntry.body;
1127
+ // 强行覆盖:body 无 system(启动未注入)时用空字符串合成目标形态
1128
+ const _newSystem = applyLiveSystem(_hadSystem ? requestEntry.body.system : '', _liveEntry, _known);
1129
+ if (_newSystem !== null) {
1130
+ const _sysJson = JSON.stringify(_newSystem);
1131
+ const _rawBody = typeof _fetchOpts.body === 'string'
1132
+ ? _fetchOpts.body
1133
+ : (Buffer.isBuffer(_fetchOpts.body) ? _fetchOpts.body.toString('utf-8') : null);
1134
+ if (_rawBody !== null) {
1135
+ // body 原本无 system → 允许前插(仅 main 非 utility 请求会走到这里)
1136
+ let _rewritten = replaceTopLevelSystem(_rawBody, _sysJson, { allowPrepend: !_hadSystem });
1137
+ if (_rewritten === null) {
1138
+ try {
1139
+ const _b = JSON.parse(_rawBody);
1140
+ _b.system = _newSystem;
1141
+ _rewritten = JSON.stringify(_b);
1142
+ } catch { _rewritten = null; }
1143
+ }
1144
+ if (_rewritten !== null) {
1145
+ // 保持 body 类型:进 Buffer 出 Buffer,进 string 出 string
1146
+ _fetchOpts = { ..._fetchOpts, body: Buffer.isBuffer(_fetchOpts.body) ? Buffer.from(_rewritten, 'utf-8') : _rewritten };
1147
+ requestEntry.body = { ...requestEntry.body, system: _newSystem };
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1154
+ } catch (err) { reportSwallowed('interceptor.live-system', err); }
1155
+
1001
1156
  if (requestEntry) {
1002
1157
  // v2 req-phase ingest: journal seq is allocated inside, still in the
1003
1158
  // fetch hook's synchronous segment (the proxy rewrite above is fully
@@ -25,6 +25,14 @@ import { reportSwallowed } from '@ccv/core/error-report';
25
25
  // Tombstone file inside a scope's model-prompt dir: a JSON array of canonical names.
26
26
  export const BUILTIN_DISABLED_FILE = '.builtin-disabled.json';
27
27
 
28
+ // 改名/族系合并的一次性兼容映射:墓碑按规范大写名比对,preset 改名(如 Qwen-3.7-Max
29
+ // 合并为族系条目 Qwen-3)会让旧墓碑(QWEN-3.7-MAX)静默失效 —— 用户显式 opt-out 被
30
+ // 逆转。读墓碑时把旧名归一到现名,保住 opt-out(本模块注释承诺的「不静默逆转」原则)。
31
+ // One-shot rename aliases: a preset rename must not silently void an existing tombstone.
32
+ const TOMBSTONE_RENAMES = {
33
+ 'QWEN-3.7-MAX': 'QWEN-3',
34
+ };
35
+
28
36
  // 物化目录:preset 文本(边界已剥离、${...} 保持字面量,spawn 渲染管线再替换变量)
29
37
  // 写成内容寻址的临时文件,供 --system-prompt-file 注入(文件对形式是快照钉扎的前提)。
30
38
  // 惰性读取 env 覆盖(测试用):node --test 多进程并行时共享目录会被彼此的 GC 竞态误删。
@@ -99,7 +107,10 @@ export function readBuiltinDisabled(modelPromptDir) {
99
107
  const raw = readFileSync(target, 'utf-8');
100
108
  const parsed = JSON.parse(raw);
101
109
  if (!Array.isArray(parsed)) throw new Error('tombstone file is not a JSON array');
102
- const names = parsed.map((n) => normalizeModelName(typeof n === 'string' ? n : '')).filter(Boolean);
110
+ const names = parsed.map((n) => {
111
+ const canonical = normalizeModelName(typeof n === 'string' ? n : '');
112
+ return TOMBSTONE_RENAMES[canonical] || canonical;
113
+ }).filter(Boolean);
103
114
  return [...new Set(names)].sort();
104
115
  } catch (err) {
105
116
  console.warn(`[CC Viewer] built-in prompt tombstone ${target} unreadable (${err.message}); treating as no disables`);
@@ -71,7 +71,9 @@ function firstNonEmpty(...values) {
71
71
  }
72
72
 
73
73
  function currentDate(timeZone, date) {
74
- if (timeZone.length > 0) {
74
+ // Guard: a non-string timeZone (e.g. undefined from a null snapshot) must not throw
75
+ // on `.length` — fall through to the ISO branch below.
76
+ if (typeof timeZone === 'string' && timeZone.length > 0) {
75
77
  const formatted = stringOrEmpty(() =>
76
78
  new Intl.DateTimeFormat('en-CA', {
77
79
  timeZone,
@@ -249,6 +251,97 @@ export function createSystemPromptVariables(overrides = {}, opts = {}) {
249
251
  return mergeSystemPromptVariables(variables, overrides)
250
252
  }
251
253
 
254
+ // ─── Cacheable variable snapshot / live re-composition ──────────────────────
255
+ // Hot model switching re-renders the injected system text on every switch. Collecting
256
+ // variables shells out to git (spawnSync, up to 8 calls × 15 s timeout in a repo),
257
+ // which must never run in the fetch hook's synchronous segment. So the collected set
258
+ // is split at the launch boundary:
259
+ // - snapshot: everything the launch collected EXCEPT `time.date` and `model.name` —
260
+ // env / os / runtime / cwd / git / memory index / timezone / knowledgeCutoff …
261
+ // Stable for the whole process lifetime, so a switched-model text agrees with the
262
+ // launch text outside the live keys.
263
+ // - live: `time.date` (re-derived at render time) and `model.name` (the model of
264
+ // THIS request). `time.timezone` stays in the snapshot on purpose: it is env/ICU
265
+ // derived and immutable, and reusing it keeps the Time section byte-identical to
266
+ // the launch text. `model.knowledgeCutoff` is env-derived too, so it stays.
267
+
268
+ // A full empty-variable skeleton used when there is no snapshot at all (launch with
269
+ // no injection / launch whose injected text had no `${...}` / pinned-resume). Rendering
270
+ // against this yields EMPTY strings for `${git.*}` etc. — never literal `${git.branch}`
271
+ // text in the prompt. `git.isRepository` reads as the string 'false' so the Git section
272
+ // reads "Is a git repository: false" rather than vanishing.
273
+ function emptySystemPromptVariableSkeleton() {
274
+ return {
275
+ environment: {
276
+ cwd: '', originalCwd: '', home: '', user: '', workspaceRoots: '', path: '', lang: '',
277
+ },
278
+ git: {
279
+ isRepository: 'false', root: '', branch: '', mainBranch: '', userName: '', recentCommits: '',
280
+ },
281
+ os: {
282
+ platform: '', type: '', arch: '', shell: '', version: '', release: '', hostname: '',
283
+ availableParallelism: '', totalMemory: '',
284
+ },
285
+ runtime: { nodeVersion: '', execPath: '', pid: '', ppid: '' },
286
+ permissions: { mode: '', approvalsReviewer: '' },
287
+ sandbox: { mode: '', networkAccess: '', writableRoots: '' },
288
+ terminal: { term: '', colorTerm: '' },
289
+ filesystem: { tmpdir: '', pathSeparator: '', pathDelimiter: '' },
290
+ model: { name: '', knowledgeCutoff: '' },
291
+ memory: { dir: '', index: '', enabled: 'false' },
292
+ scratchpad: { dir: '' },
293
+ }
294
+ }
295
+
296
+ // Guard: the cached git object must carry all 6 fields with `isRepository` as a string,
297
+ // otherwise fall back to an all-empty git block. A malformed/partial snapshot would
298
+ // otherwise leak a literal `${git.branch}` into the prompt under missingVariableMode
299
+ // 'keep' (user-visible corruption).
300
+ function sanitizeGitBlock(git) {
301
+ if (!git || typeof git !== 'object') return emptySystemPromptVariableSkeleton().git
302
+ const fields = ['isRepository', 'root', 'branch', 'mainBranch', 'userName', 'recentCommits']
303
+ const ok = fields.every((f) => typeof git[f] === 'string') && (git.isRepository === 'true' || git.isRepository === 'false')
304
+ return ok ? git : emptySystemPromptVariableSkeleton().git
305
+ }
306
+
307
+ /**
308
+ * Split a collected variable set into the cacheable snapshot. Removes ONLY `time.date`
309
+ * and `model.name` (the two live values); keeps `time.timezone` and the rest so the
310
+ * snapshot can be keyed by workspace and reused across every hot switch in the process.
311
+ * Total: unusable input → null (callers fall back to an empty skeleton).
312
+ */
313
+ export function toSystemPromptVariableSnapshot(variables) {
314
+ if (!variables || typeof variables !== 'object') return null
315
+ const { time, model, ...rest } = variables // eslint-disable-line no-unused-vars
316
+ return {
317
+ ...rest,
318
+ git: sanitizeGitBlock(rest.git),
319
+ time: { timezone: (time && typeof time.timezone === 'string') ? time.timezone : '' },
320
+ model: {
321
+ knowledgeCutoff: (model && typeof model.knowledgeCutoff === 'string') ? model.knowledgeCutoff : '',
322
+ },
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Rebuild a render-ready variable set from a snapshot + the live values.
328
+ * `now` is injectable for tests; production always uses the current clock.
329
+ * A null/partial snapshot still yields a usable set built on an empty skeleton —
330
+ * `${git.*}` renders as empty strings, never as literal `${git.branch}` text.
331
+ */
332
+ export function fromSystemPromptVariableSnapshot(snapshot, { modelId = null, now = new Date() } = {}) {
333
+ const base = (snapshot && typeof snapshot === 'object') ? snapshot : emptySystemPromptVariableSkeleton()
334
+ const tz = (base.time && typeof base.time.timezone === 'string' && base.time.timezone)
335
+ ? base.time.timezone
336
+ : stringOrEmpty(() => Intl.DateTimeFormat().resolvedOptions().timeZone)
337
+ const modelName = (typeof modelId === 'string' && modelId) ? modelId.replace(/\[1m\]$/i, '') : ''
338
+ return mergeSystemPromptVariables(base, {
339
+ git: sanitizeGitBlock(base.git),
340
+ time: { date: currentDate(tz, now), timezone: tz },
341
+ model: { name: modelName },
342
+ })
343
+ }
344
+
252
345
  function readDottedPath(path, variables) {
253
346
  const normalizedPath = path.trim()
254
347
  if (Object.prototype.hasOwnProperty.call(variables, normalizedPath)) {
@@ -399,6 +399,150 @@ export function replaceTopLevelModel(jsonStr, oldModel, newModel) {
399
399
  return jsonStr.slice(0, idx) + replaced + jsonStr.slice(idx + needle.length);
400
400
  }
401
401
 
402
+ // ─── replaceTopLevelSystem ────────────────────────────────────────────────────
403
+ // system 文本随主模型热切换(system-prompt-live.js)的 wire 层原语:在原始 JSON
404
+ // 字符串上定向替换顶层 "system" 成员的值,避免对巨型 wire body(-c checkpoint
405
+ // 可达数十 MB)做二次 JSON.parse + 全量 re-stringify。
406
+ //
407
+ // 与 replaceTopLevelModel 的三点差异:
408
+ // 1. needle 只有 key(值可能极大且未知),嵌套的同名成员(如工具 input_schema
409
+ // 里名为 "system" 的属性)会产生多候选 → 要求顶层候选唯一,否则返回 null
410
+ // 由调用方回退 parse/stringify。
411
+ // 2. 值的结束位置用「字符串/转义感知的有界扫描」定位(string 处理 \" 与 \\;
412
+ // array/object 做深度计数,跳过字符串内部)。扫描结束后校验下一个非空白字符
413
+ // 必须是 `,` 或 `}`(顶层对象内的合法后继),否则视为定位失败返回 null。
414
+ // 3. body 没有顶层 "system" 成员时(罕见,如某些 utility 调用),opts.allowPrepend
415
+ // 为 true 才前插;否则返回 null。前插的重复键风险与 injectOutputConfigEffort
416
+ // 同理——调用方必须先在解析后的 body 上确认 system 不存在。
417
+ //
418
+ // newSystemJson 是完整的新值 JSON(字符串或数组的序列化形态,调用方负责 JSON.stringify)。
419
+ // 返回替换后的字符串;无法唯一定位 / 入参非法 → null(调用方回退)。
420
+
421
+ // 从 jsonStr[i](值首字符,必须为 " [ { 或字面量)扫描到值结束,返回结束下标(exclusive),
422
+ // 无法定位 → -1。字符串处理 \" 与 \\ 转义;数组/对象做深度计数并跳过字符串内容;
423
+ // 字面量(true/false/null/number)直接扫到分隔符。system 值在真实 wire 上只会是
424
+ // string 或 array,其余形态防御性支持。
425
+ function _scanJsonValueEnd(jsonStr, i) {
426
+ const open = jsonStr[i];
427
+ if (open === '"') {
428
+ let p = i + 1;
429
+ while (p < jsonStr.length) {
430
+ const c = jsonStr[p];
431
+ if (c === '\\') { p += 2; continue; }
432
+ if (c === '"') return p + 1;
433
+ p++;
434
+ }
435
+ return -1;
436
+ }
437
+ if (open === '[' || open === '{') {
438
+ let depth = 0;
439
+ let p = i;
440
+ while (p < jsonStr.length) {
441
+ const c = jsonStr[p];
442
+ if (c === '"') {
443
+ const end = _scanJsonValueEnd(jsonStr, p);
444
+ if (end === -1) return -1;
445
+ p = end;
446
+ continue;
447
+ }
448
+ if (c === '[' || c === '{') depth++;
449
+ else if (c === ']' || c === '}') {
450
+ depth--;
451
+ if (depth === 0) return p + 1;
452
+ if (depth < 0) return -1;
453
+ }
454
+ p++;
455
+ }
456
+ return -1;
457
+ }
458
+ // literal: true / false / null / number → 扫到 , } ] 或空白
459
+ const m = /^-?\d+(\.\d+)?([eE][+-]?\d+)?|^true|^false|^null/.exec(jsonStr.slice(i, i + 32));
460
+ return m ? i + m[0].length : -1;
461
+ }
462
+
463
+ export function replaceTopLevelSystem(jsonStr, newSystemJson, opts = {}) {
464
+ if (typeof jsonStr !== 'string' || !jsonStr ||
465
+ typeof newSystemJson !== 'string' || !newSystemJson) return null;
466
+ // 防御:newSystemJson 必须是合法 JSON 值(调用方 Bug 直接把垃圾写上 wire 不可接受)。
467
+ try { JSON.parse(newSystemJson); } catch { return null; }
468
+
469
+ const needles = ['"system":', '"system" :'];
470
+ // 顶层必须是对象:顶层数组(如 [{...}])里的成员也可能通过 { 边界校验,
471
+ // 但那不是顶层 "system" —— 整体拒绝(调用方回退)。
472
+ const openIdx = jsonStr.search(/\S/);
473
+ if (openIdx === -1 || jsonStr[openIdx] !== '{') return null;
474
+ // 深度感知候选扫描(review 第二轮 P1-4):成员边界校验(前一非空白字符是 { 或 ,)
475
+ // 不足以区分「顶层 system」与「嵌套同名成员」—— tools[i].input_schema.properties.system
476
+ // 前面同样是 {。必须跟踪 JSON 容器深度,只接受 depth===1(顶层对象内)的候选,
477
+ // 否则前插/替换会把 persona 写进工具 schema(顶层 system 根本没设置)。
478
+ //
479
+ // 性能(review 第二轮二次验证 P1):逐字符深度扫描是 O(body),对巨型 -c checkpoint
480
+ // (可达数十 MB)比「回退 parse+stringify」还贵 —— 而绝大多数请求(无 system 的旁路
481
+ // 调用、或 system 在后的大 body)根本不需要全文扫描。先用 indexOf 预筛:body 完全
482
+ // 不含 "system" 字样(含字符串值内)时 candidates 恒为空,直接跳过扫描(O(needle)
483
+ // 原生加速);只有确实含 needle 才付出深度扫描成本。预筛命中不等于有候选(可能在
484
+ // 字符串值内),仍需扫描确认 —— 预筛只在「确定无 needle」时短路,语义不变。
485
+ const candidates = [];
486
+ const _hasNeedle = jsonStr.indexOf('"system"') !== -1; // 覆盖 "system": 与 "system" : 两 needle 的公共前缀
487
+ if (_hasNeedle) {
488
+ let depth = 0;
489
+ let p = openIdx;
490
+ while (p < jsonStr.length) {
491
+ const c = jsonStr[p];
492
+ if (c === '"') {
493
+ // 字符串内容整体跳过(含转义)—— 但先检查它是否是 depth===1 的 "system" 键
494
+ if (depth === 1) {
495
+ for (const needle of needles) {
496
+ if (jsonStr.startsWith(needle, p)) {
497
+ // 键边界:前一个非空白字符必须是 { 或 ,(顶层对象成员位置)
498
+ let q = p - 1;
499
+ while (q >= 0 && (jsonStr[q] === ' ' || jsonStr[q] === '\t' || jsonStr[q] === '\n' || jsonStr[q] === '\r')) q--;
500
+ if (q >= 0 && (jsonStr[q] === '{' || jsonStr[q] === ',')) {
501
+ candidates.push({ idx: p, needle });
502
+ }
503
+ break;
504
+ }
505
+ }
506
+ }
507
+ const end = _scanJsonValueEnd(jsonStr, p);
508
+ if (end === -1) return null;
509
+ p = end;
510
+ continue;
511
+ }
512
+ if (c === '{' || c === '[') depth++;
513
+ else if (c === '}' || c === ']') depth--;
514
+ p++;
515
+ }
516
+ }
517
+ if (candidates.length > 1) return null; // 嵌套同名成员 → 回退 parse/stringify
518
+
519
+ if (candidates.length === 0) {
520
+ // 无顶层 system 成员:只有显式允许才前插(utility 端点绝不能发明 system)。
521
+ if (opts.allowPrepend !== true) return null;
522
+ const i = jsonStr.indexOf('{');
523
+ if (i === -1) return null;
524
+ if (/\S/.test(jsonStr.slice(0, i))) return null; // 顶层必须是对象
525
+ const after = jsonStr.slice(i + 1);
526
+ const m = after.match(/^\s*(\S)/);
527
+ const needsComma = !!(m && m[1] !== '}');
528
+ const insert = `"system":${newSystemJson}` + (needsComma ? ',' : '');
529
+ return jsonStr.slice(0, i + 1) + insert + jsonStr.slice(i + 1);
530
+ }
531
+
532
+ const { idx, needle } = candidates[0];
533
+ // 值起始:跳过冒号后的空白
534
+ let vStart = idx + needle.length;
535
+ while (vStart < jsonStr.length && (jsonStr[vStart] === ' ' || jsonStr[vStart] === '\t' || jsonStr[vStart] === '\n' || jsonStr[vStart] === '\r')) vStart++;
536
+ if (vStart >= jsonStr.length) return null;
537
+ const vEnd = _scanJsonValueEnd(jsonStr, vStart);
538
+ if (vEnd === -1) return null;
539
+ // 值后边界:下一个非空白字符必须是 , 或 }(顶层对象内的合法后继)
540
+ let p = vEnd;
541
+ while (p < jsonStr.length && (jsonStr[p] === ' ' || jsonStr[p] === '\t' || jsonStr[p] === '\n' || jsonStr[p] === '\r')) p++;
542
+ if (p >= jsonStr.length || (jsonStr[p] !== ',' && jsonStr[p] !== '}')) return null;
543
+ return jsonStr.slice(0, vStart) + newSystemJson + jsonStr.slice(vEnd);
544
+ }
545
+
402
546
  // proxy profile hot-switch 模型解析:按 request body 里 model 的家族名映射到 profile 的对应字段。
403
547
  // 家族用**大小写不敏感子串**匹配(/opus/i 等),只认这几个已知家族单词——
404
548
  // 因此 claude-opus-4-8、未来的 claude-opus-5 等任何版本都命中同一家族,版本升级无需重配。