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.
- package/README.md +6 -5
- package/cli.js +19 -0
- package/dist/assets/{App-R6odqLoE.js → App-DxDo_tCQ.js} +2 -2
- package/dist/assets/{MdxEditorPanel-CTqsETwc.js → MdxEditorPanel-Ddpu5ELR.js} +1 -1
- package/dist/assets/Mobile-DA0yMMgI.js +1 -0
- package/dist/assets/{ProxyStatsModal-CM2H8JYh.js → ProxyStatsModal-DKKfSpXx.js} +1 -1
- package/dist/assets/index-BCpkzaVk.js +2 -0
- package/dist/assets/index-Nvk8r3tO.css +1 -0
- package/dist/assets/{seqResourceLoaders-Xx6a8E03.css → seqResourceLoaders-B_FSQh3U.css} +1 -1
- package/dist/assets/seqResourceLoaders-BkzxH2jr.js +2 -0
- package/dist/index.html +2 -2
- package/node_modules/@ccv/core/src/context-rules.js +5 -0
- package/package.json +1 -1
- package/server/i18n.js +2 -2
- package/server/interceptor.js +176 -21
- package/server/lib/builtin-model-prompts.js +12 -1
- package/server/lib/create_system_prompt.js +94 -1
- package/server/lib/ensure-hooks.js +34 -3
- package/server/lib/interceptor-core.js +144 -0
- package/server/lib/launch-config.js +83 -3
- package/server/lib/model-system-prompts.js +6 -1
- package/server/lib/proxy/proxy-retry.js +57 -2
- package/server/lib/resume-watchdog.js +108 -0
- package/server/lib/session-id.js +33 -0
- package/server/lib/shell-hook-inspect.js +48 -0
- package/server/lib/system-prompt-files.js +14 -0
- package/server/lib/system-prompt-live.js +622 -0
- package/server/lib/task-bridge.js +10 -3
- package/server/lib/task-state.js +32 -0
- package/server/lib/v2/identity.js +3 -24
- package/server/lib/v2/session-select.js +11 -8
- package/server/lib/v2/v2-writer.js +13 -5
- package/server/proxy.js +32 -6
- package/server/routes/events.js +9 -3
- package/server/routes/preferences.js +6 -1
- package/server/routes/workspaces.js +3 -2
- package/server/server.js +35 -5
- package/server/system-prompt-templates/presets/GLM-5.2.md +2 -0
- package/server/system-prompt-templates/presets/GLM-5.3.md +2 -0
- package/server/system-prompt-templates/presets/{Qwen-3.7-Max.md → Qwen-3.md} +3 -1
- package/server/system-prompt-templates/presets/deepseek-v4-flash.md +2 -0
- package/server/system-prompt-templates/presets/deepseek-v4-pro.md +2 -0
- package/server/system-prompt-templates/presets/index.json +7 -7
- package/server/system-prompt-templates/presets/kimi-k2.7-code.md +3 -0
- package/server/system-prompt-templates/presets/kimi-k3.md +2 -0
- package/dist/assets/Mobile-o_QrQ_eI.js +0 -1
- package/dist/assets/index--yjDHxJD.js +0 -2
- package/dist/assets/index-D1yGak8I.css +0 -1
- 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
|
|
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-
|
|
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.
|
|
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",
|
package/server/interceptor.js
CHANGED
|
@@ -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"
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
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
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
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
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|