cc-viewer 1.8.13 → 1.8.15

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 (49) hide show
  1. package/README.md +6 -5
  2. package/cli.js +19 -0
  3. package/dist/assets/{App-R6odqLoE.js → App-DxDo_tCQ.js} +2 -2
  4. package/dist/assets/{MdxEditorPanel-CTqsETwc.js → MdxEditorPanel-Ddpu5ELR.js} +1 -1
  5. package/dist/assets/Mobile-DA0yMMgI.js +1 -0
  6. package/dist/assets/{ProxyStatsModal-CM2H8JYh.js → ProxyStatsModal-DKKfSpXx.js} +1 -1
  7. package/dist/assets/index-BCpkzaVk.js +2 -0
  8. package/dist/assets/index-Nvk8r3tO.css +1 -0
  9. package/dist/assets/{seqResourceLoaders-Xx6a8E03.css → seqResourceLoaders-B_FSQh3U.css} +1 -1
  10. package/dist/assets/seqResourceLoaders-BkzxH2jr.js +2 -0
  11. package/dist/index.html +2 -2
  12. package/node_modules/@ccv/core/src/context-rules.js +5 -0
  13. package/package.json +1 -1
  14. package/server/i18n.js +2 -2
  15. package/server/interceptor.js +176 -21
  16. package/server/lib/builtin-model-prompts.js +12 -1
  17. package/server/lib/create_system_prompt.js +94 -1
  18. package/server/lib/ensure-hooks.js +34 -3
  19. package/server/lib/interceptor-core.js +144 -0
  20. package/server/lib/launch-config.js +83 -3
  21. package/server/lib/model-system-prompts.js +6 -1
  22. package/server/lib/proxy/proxy-retry.js +57 -2
  23. package/server/lib/resume-watchdog.js +108 -0
  24. package/server/lib/session-id.js +33 -0
  25. package/server/lib/shell-hook-inspect.js +48 -0
  26. package/server/lib/system-prompt-files.js +14 -0
  27. package/server/lib/system-prompt-live.js +622 -0
  28. package/server/lib/task-bridge.js +10 -3
  29. package/server/lib/task-state.js +32 -0
  30. package/server/lib/v2/identity.js +3 -24
  31. package/server/lib/v2/session-select.js +11 -8
  32. package/server/lib/v2/v2-writer.js +13 -5
  33. package/server/proxy.js +32 -6
  34. package/server/routes/events.js +9 -3
  35. package/server/routes/preferences.js +6 -1
  36. package/server/routes/workspaces.js +3 -2
  37. package/server/server.js +35 -5
  38. package/server/system-prompt-templates/presets/GLM-5.2.md +2 -0
  39. package/server/system-prompt-templates/presets/GLM-5.3.md +2 -0
  40. package/server/system-prompt-templates/presets/{Qwen-3.7-Max.md → Qwen-3.md} +3 -1
  41. package/server/system-prompt-templates/presets/deepseek-v4-flash.md +2 -0
  42. package/server/system-prompt-templates/presets/deepseek-v4-pro.md +2 -0
  43. package/server/system-prompt-templates/presets/index.json +7 -7
  44. package/server/system-prompt-templates/presets/kimi-k2.7-code.md +3 -0
  45. package/server/system-prompt-templates/presets/kimi-k3.md +2 -0
  46. package/dist/assets/Mobile-o_QrQ_eI.js +0 -1
  47. package/dist/assets/index--yjDHxJD.js +0 -2
  48. package/dist/assets/index-D1yGak8I.css +0 -1
  49. package/dist/assets/seqResourceLoaders-BgeRQt8C.js +0 -2
package/dist/index.html CHANGED
@@ -21,11 +21,11 @@
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--yjDHxJD.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-BCpkzaVk.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">
28
- <link rel="stylesheet" crossorigin href="./assets/index-D1yGak8I.css">
28
+ <link rel="stylesheet" crossorigin href="./assets/index-Nvk8r3tO.css">
29
29
  </head>
30
30
  <body>
31
31
  <!-- Pre-hydration splash: no SPA CSS yet — keep font-family in sync with --font-ui in src/global.css -->
@@ -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.13",
3
+ "version": "1.8.15",
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)) {
@@ -35,8 +35,11 @@ const HOOK_TIMEOUT_FIELD = HOOK_TIMEOUT_S > 0 ? { timeout: HOOK_TIMEOUT_S } : {}
35
35
  // 构造与对比两件事必须同源,否则升级路径会漏字段。
36
36
  // merge 而非 replace:用户/第三方给同一 hook 追加 if/shell/once/async/asyncRewake 等
37
37
  // schema 合法字段时,rewrite 不能整对象覆盖把它们吞掉。
38
- export function _buildHookObj(command) {
39
- return { type: 'command', command, ...HOOK_TIMEOUT_FIELD };
38
+ // opts.omitTimeout: true = 不写 timeout 字段(继承 Claude Code 的该事件默认值),
39
+ // CCV_HOOK_TIMEOUT_S=0 的既有语义一致但作用于单个 hook。省略 opts 时行为完全不变。
40
+ export function _buildHookObj(command, opts) {
41
+ const timeoutField = (opts && opts.omitTimeout) ? {} : HOOK_TIMEOUT_FIELD;
42
+ return { type: 'command', command, ...timeoutField };
40
43
  }
41
44
  export function _hookObjEqual(existing, desired) {
42
45
  if (!existing) return false;
@@ -79,7 +82,7 @@ function _looksStaleManagedCommand(cmd) {
79
82
  // All hook sections cc-viewer manages. _purgeStaleManagedHooks and
80
83
  // removeAllManagedHooks must iterate this exact set or uninstall/cleanup
81
84
  // leaves zombie entries behind (cli.js cleanup-hooks path).
82
- const MANAGED_SECTIONS = ['PreToolUse', 'Stop', 'SessionStart', 'TaskCreated', 'TaskCompleted', 'PostToolUse'];
85
+ const MANAGED_SECTIONS = ['PreToolUse', 'Stop', 'SessionStart', 'TaskCreated', 'TaskCompleted', 'UserPromptSubmit', 'PostToolUse'];
83
86
 
84
87
  function _purgeStaleManagedHooks(settings) {
85
88
  let removed = 0;
@@ -297,6 +300,34 @@ export function ensureHooks() {
297
300
  changed = true;
298
301
  }
299
302
 
303
+ // UserPromptSubmit hook → task-bridge.js (same bridge: its envelope
304
+ // normalizer already passes through task-less events). Fires on EVERY user
305
+ // prompt submission (queued-message drains included) and carries no
306
+ // task_id — it is the "new prompt" signal that clears the previous turn's
307
+ // checklist server-side (task-state.js shouldResetTasksOnPrompt). No
308
+ // matcher (unsupported by this event, silently ignored). Find-by-command
309
+ // so a user's own UserPromptSubmit entry is never clobbered in place.
310
+ // Deliberately NO timeout field: this is the only hook that blocks the
311
+ // user's own input (exit!=0 erases the submitted prompt), so a wedged
312
+ // bridge must cost Claude Code's event default (~30s), not our 86400s.
313
+ // The bridge itself always exits 0 with a 500ms POST timeout.
314
+ const userPromptDesired = _buildHookObj(taskCmd, { omitTimeout: true });
315
+ const userPromptExisting = settings.hooks.UserPromptSubmit.find(h => {
316
+ const cmd = h.hooks?.[0]?.command || '';
317
+ return cmd.includes('task-bridge.js');
318
+ });
319
+ if (userPromptExisting) {
320
+ if (!_hookObjEqual(userPromptExisting.hooks?.[0], userPromptDesired)) {
321
+ userPromptExisting.hooks = [_mergeHookObj(userPromptExisting.hooks?.[0], userPromptDesired)];
322
+ changed = true;
323
+ }
324
+ } else {
325
+ settings.hooks.UserPromptSubmit.push({
326
+ hooks: [userPromptDesired],
327
+ });
328
+ changed = true;
329
+ }
330
+
300
331
  if (changed) {
301
332
  mkdirSync(claudeDir, { recursive: true });
302
333
  // Atomic write(): write to a sibling temp file then rename. Concurrent