cc-viewer 1.8.0 → 1.8.2

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 (51) hide show
  1. package/README.md +1 -0
  2. package/concepts/ar/ToolsFirst.md +1 -1
  3. package/concepts/da/ToolsFirst.md +1 -1
  4. package/concepts/de/ToolsFirst.md +1 -1
  5. package/concepts/en/ToolsFirst.md +1 -1
  6. package/concepts/es/ToolsFirst.md +1 -1
  7. package/concepts/fr/ToolsFirst.md +1 -1
  8. package/concepts/it/ToolsFirst.md +1 -1
  9. package/concepts/ja/ToolsFirst.md +1 -1
  10. package/concepts/ko/ToolsFirst.md +1 -1
  11. package/concepts/no/ToolsFirst.md +1 -1
  12. package/concepts/pl/ToolsFirst.md +1 -1
  13. package/concepts/pt-BR/ToolsFirst.md +1 -1
  14. package/concepts/ru/ToolsFirst.md +1 -1
  15. package/concepts/th/ToolsFirst.md +1 -1
  16. package/concepts/tr/ToolsFirst.md +1 -1
  17. package/concepts/uk/ToolsFirst.md +1 -1
  18. package/concepts/zh/ToolsFirst.md +1 -1
  19. package/concepts/zh-TW/ToolsFirst.md +1 -1
  20. package/dist/assets/App-C5AeiZ4W.js +2 -0
  21. package/dist/assets/App-M61pqQEx.css +1 -0
  22. package/dist/assets/{MdxEditorPanel-BBhgTmDk.js → MdxEditorPanel-DNvC8jj8.js} +1 -1
  23. package/dist/assets/{Mobile-vN4lyiaz.js → Mobile-CiLjCGpD.js} +1 -1
  24. package/dist/assets/{ProxyStatsModal-BOflqr0R.js → ProxyStatsModal-_xwISk06.js} +1 -1
  25. package/dist/assets/index-CmXTH-Hd.js +2 -0
  26. package/dist/assets/seqResourceLoaders-BJpAk70J.js +2 -0
  27. package/dist/index.html +1 -1
  28. package/node_modules/@ccv/core/src/context-rules.js +6 -2
  29. package/package.json +1 -1
  30. package/server/lib/builtin-model-prompts.js +198 -0
  31. package/server/lib/context-watcher.js +1 -1
  32. package/server/lib/git-diff.js +27 -20
  33. package/server/lib/launch-config.js +9 -1
  34. package/server/lib/model-system-prompts.js +17 -1
  35. package/server/lib/system-prompt-files.js +42 -5
  36. package/server/pty-manager.js +6 -3
  37. package/server/routes/expert.js +74 -13
  38. package/server/system-prompt-templates/presets/GLM-5.2.md +5 -0
  39. package/server/system-prompt-templates/presets/GLM-5.3.md +74 -0
  40. package/server/system-prompt-templates/presets/Qwen-3.7-Max.md +5 -0
  41. package/server/system-prompt-templates/presets/deepseek-v4-flash.md +4 -0
  42. package/server/system-prompt-templates/presets/deepseek-v4-pro.md +5 -0
  43. package/server/system-prompt-templates/presets/index.json +8 -0
  44. package/server/system-prompt-templates/presets/kimi-k2.7-code.md +5 -0
  45. package/server/system-prompt-templates/presets/kimi-k3.md +5 -0
  46. package/ultraAgents/README.md +3 -0
  47. package/ultraAgents/test-analysis-expert.json +45 -0
  48. package/dist/assets/App-BWajJyJh.css +0 -1
  49. package/dist/assets/App-CCu0y2Cq.js +0 -2
  50. package/dist/assets/index-C3BRHDVF.js +0 -2
  51. package/dist/assets/seqResourceLoaders-DpYqp6SP.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-C3BRHDVF.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-CmXTH-Hd.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-CSjy2pdD.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-Clv6kvI5.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CtujsSUV.js">
@@ -82,7 +82,9 @@ const MODEL_CONTEXT_SIZES = [
82
82
  ];
83
83
 
84
84
  /**
85
- * 模型名 → 上下文窗口 token 数。后缀优先,其次家族档位表,默认 200K。
85
+ * 模型名 → 上下文窗口 token 数。后缀优先,其次家族档位表;
86
+ * 无法识别的型号默认 1M(用户规约:宁可低估百分比,不让血条提前顶满)。
87
+ * 空/缺失名字不属于"无法识别的型号",维持 200K 静态兜底。
86
88
  * @param {string|null|undefined} modelName
87
89
  * @returns {number}
88
90
  */
@@ -93,7 +95,8 @@ export function getModelMaxTokens(modelName) {
93
95
  for (const entry of MODEL_CONTEXT_SIZES) {
94
96
  if (entry.match.test(modelName)) return entry.tokens;
95
97
  }
96
- return 200000;
98
+ // Unrecognized model family → assume 1M (user convention).
99
+ return 1000000;
97
100
  }
98
101
 
99
102
  /**
@@ -107,6 +110,7 @@ export function getModelMaxTokens(modelName) {
107
110
  * 'k3[1m]' 时上游会把响应 model 归一化成裸 'k3'(剥掉 [1m] 后缀),
108
111
  * response-first 解析读到裸 'k3' 若归 200K 桶会与请求侧 1M 判定分裂,
109
112
  * 血条分母错成 200K;且裸 'k3' 本就是 k3[1m] 的 1M 形态被剥后缀的产物。
113
+ * 无法识别的型号经 getModelMaxTokens 落底 1M → 归 1M 桶(见该函数注释)。
110
114
  * @param {string} modelName
111
115
  * @returns {1000000|200000}
112
116
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
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",
@@ -0,0 +1,198 @@
1
+ // 内置模型提示词层:让 packages/app/server/system-prompt-templates/presets/ 的 6 个预设
2
+ // 成为「默认生效」的模型 system prompt——spawn 时模型匹配且用户无对应文件(workspace >
3
+ // global 两级用户文件优先)时自动注入;用户可通过墓碑文件禁用某个内置条目。
4
+ //
5
+ // 数据源复用 system-prompt-presets.js 的 listSystemPromptPresets()(manifest 读取 +
6
+ // renderPresetTemplate 边界剥离都已在里面,且注释承诺不触发 createSystemPromptVariables/
7
+ // git 子进程)。本模块函数对非法入参会 throw(setBuiltinDisabled/materializeBuiltinPrompt),
8
+ // spawn 注入链路的安全由调用点整层 try-catch 保证(system-prompt-files.js 失败回落
9
+ // sentinel,注入链路永不 throw)。
10
+ //
11
+ // Built-in model prompt layer: the shipped presets act as default-effective model
12
+ // system prompts. User files (workspace > global) always win; a per-scope tombstone
13
+ // file disables individual built-in entries.
14
+ import { createHash } from 'node:crypto';
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { expandModelIdVariants, normalizeModelName } from './model-system-prompts.js';
19
+ import { listSystemPromptPresets } from './system-prompt-presets.js';
20
+ import { renameSyncWithRetry } from './file-api.js';
21
+ import { reportSwallowed } from '@ccv/core/error-report';
22
+
23
+ // 墓碑文件名:放在对应 scope 的 modelPromptDir 里,内容是规范大写名的 JSON 数组。
24
+ // parseModelPromptFileName 对非 *_SYSTEM.md 文件名返回 null,天然不干扰条目列表。
25
+ // Tombstone file inside a scope's model-prompt dir: a JSON array of canonical names.
26
+ export const BUILTIN_DISABLED_FILE = '.builtin-disabled.json';
27
+
28
+ // 物化目录:preset 文本(边界已剥离、${...} 保持字面量,spawn 渲染管线再替换变量)
29
+ // 写成内容寻址的临时文件,供 --system-prompt-file 注入(文件对形式是快照钉扎的前提)。
30
+ // 惰性读取 env 覆盖(测试用):node --test 多进程并行时共享目录会被彼此的 GC 竞态误删。
31
+ // Materialized temp dir for boundary-stripped preset texts (content-addressed).
32
+ const materializeDir = () => process.env.CCV_BUILTIN_PROMPT_MATERIALIZE_DIR || join(tmpdir(), 'cc-viewer-builtin-prompts');
33
+
34
+ /**
35
+ * 列出全部内置模型条目。name = manifest match 的大写规范化(与用户条目同名体系,
36
+ * 用户同名文件天然形成覆盖);text = renderPresetTemplate 输出(无边界标记)。
37
+ * List all built-in model entries derived from the preset manifest.
38
+ *
39
+ * @returns {Array<{ id: string, title: string, name: string, mode: 'override'|'append', matchLower: string, text: string }>}
40
+ */
41
+ export function listBuiltinModelPrompts() {
42
+ let presets;
43
+ try {
44
+ presets = listSystemPromptPresets();
45
+ } catch {
46
+ return []; // manifest 损坏等:内置层整体缺席,调用方回落 sentinel
47
+ }
48
+ const out = [];
49
+ for (const p of presets) {
50
+ const name = normalizeModelName(typeof p?.match === 'string' ? p.match : '');
51
+ if (!name || typeof p.text !== 'string' || p.text.trim().length === 0) continue;
52
+ out.push({
53
+ id: p.id,
54
+ title: p.title || p.id,
55
+ name,
56
+ mode: p.defaultMode === 'override' ? 'override' : 'append',
57
+ matchLower: p.match.toLowerCase(),
58
+ text: p.text,
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * 按 modelId 匹配内置条目:别名展开(expandModelIdVariants,先 lowercase)后任一变体
66
+ * 包含 preset 的 match 即命中;多命中取 match 最长者、等长字典序(对齐 matchModelPrompt
67
+ * 的消歧规则)。
68
+ * Match a model id against built-in entries (alias-expanded substring, longest wins).
69
+ *
70
+ * @param {string|null} modelId
71
+ * @returns {{ id: string, name: string, mode: 'override'|'append', text: string } | null}
72
+ */
73
+ export function matchBuiltinModelPrompt(modelId) {
74
+ const variants = expandModelIdVariants(modelId);
75
+ if (!variants.length) return null;
76
+ const hits = listBuiltinModelPrompts().filter((e) => variants.some((v) => v.includes(e.matchLower)));
77
+ if (!hits.length) return null;
78
+ hits.sort((a, b) => b.matchLower.length - a.matchLower.length || a.name.localeCompare(b.name));
79
+ const e = hits[0];
80
+ return { id: e.id, name: e.name, mode: e.mode, text: e.text };
81
+ }
82
+
83
+ /**
84
+ * 读某 scope 目录的墓碑名单。目录/文件缺失 → [](良性静默);文件存在但 JSON 损坏或
85
+ * 形状非法 → console.warn + reportSwallowed 后仍宽容返回 [](fail-open 是刻意的:
86
+ * 墓碑是辅助状态,宁可恢复注入也不因损坏阻断功能——但用户显式 opt-out 被静默逆转
87
+ * 必须有诊断痕迹,三方审查一致要求)。
88
+ * Read a scope dir's tombstone list. Missing dir/file → []; corrupt file → warn +
89
+ * reportSwallowed, still [] (deliberate fail-open, but never silently).
90
+ *
91
+ * @param {string|null|undefined} modelPromptDir
92
+ * @returns {string[]} 规范大写名数组(已排序)
93
+ */
94
+ export function readBuiltinDisabled(modelPromptDir) {
95
+ if (!modelPromptDir) return [];
96
+ const target = join(modelPromptDir, BUILTIN_DISABLED_FILE);
97
+ if (!existsSync(target)) return [];
98
+ try {
99
+ const raw = readFileSync(target, 'utf-8');
100
+ const parsed = JSON.parse(raw);
101
+ 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);
103
+ return [...new Set(names)].sort();
104
+ } catch (err) {
105
+ console.warn(`[CC Viewer] built-in prompt tombstone ${target} unreadable (${err.message}); treating as no disables`);
106
+ reportSwallowed('builtin-model-prompts.readDisabled', err);
107
+ return [];
108
+ }
109
+ }
110
+
111
+ /**
112
+ * 写墓碑:disabled=true 把 name 加入名单,false 移出。tmp+rename 原子写,数组去重排序。
113
+ * Add/remove a name in a scope dir's tombstone list (atomic tmp+rename write).
114
+ *
115
+ * @param {string} modelPromptDir 目标 scope 的 modelPromptDir(自动 mkdir -p)
116
+ * @param {string} name 条目名(经 normalizeModelName 规范化,非法 throw)
117
+ * @param {boolean} disabled
118
+ * @returns {{ name: string, disabled: boolean, list: string[] }}
119
+ */
120
+ export function setBuiltinDisabled(modelPromptDir, name, disabled) {
121
+ if (!modelPromptDir) throw new Error('no target directory');
122
+ const canonical = normalizeModelName(name);
123
+ if (!canonical) throw new Error('invalid model prompt name');
124
+ const list = readBuiltinDisabled(modelPromptDir);
125
+ const next = disabled ? [...new Set([...list, canonical])].sort() : list.filter((n) => n !== canonical);
126
+ mkdirSync(modelPromptDir, { recursive: true });
127
+ const target = join(modelPromptDir, BUILTIN_DISABLED_FILE);
128
+ const tmp = `${target}.${process.pid}.tmp`;
129
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
130
+ renameSyncWithRetry(tmp, target); // Windows 杀软/索引瞬时 EPERM 重试(与仓库写路径一致)
131
+ return { name: canonical, disabled: !!disabled, list: next };
132
+ }
133
+
134
+ /**
135
+ * 多 scope 合成判断:任一目录的墓碑名单含 name 即视为禁用(workspace 墓碑禁本工作区、
136
+ * global 墓碑全局禁用;对单次启动而言两者都是「该禁」)。
137
+ * Combine tombstones across scopes: disabled when ANY dir's list contains the name.
138
+ *
139
+ * @param {string} name 规范大写名
140
+ * @param {...(string|null|undefined)} modelPromptDirs
141
+ * @returns {boolean}
142
+ */
143
+ export function isBuiltinDisabled(name, ...modelPromptDirs) {
144
+ const canonical = normalizeModelName(name);
145
+ if (!canonical) return false;
146
+ return modelPromptDirs.some((dir) => readBuiltinDisabled(dir).includes(canonical));
147
+ }
148
+
149
+ /**
150
+ * 把内置条目文本物化为内容寻址的临时文件(不存在才写,避并发截断;顺手清同 id
151
+ * 旧 hash 文件)。返回文件路径。
152
+ * Materialize a built-in entry's text into a content-addressed temp file.
153
+ *
154
+ * @param {string} id preset id(文件名安全字符校验)
155
+ * @param {string} text renderPresetTemplate 输出(边界已剥离、${...} 字面量保留)
156
+ * @returns {string} 物化文件绝对路径
157
+ */
158
+ export function materializeBuiltinPrompt(id, text) {
159
+ if (typeof id !== 'string' || !/^[A-Za-z0-9._-]+$/.test(id)) throw new Error('invalid preset id');
160
+ if (typeof text !== 'string' || text.trim().length === 0) throw new Error('empty preset text');
161
+ const hash = createHash('sha256').update(text).digest('hex').slice(0, 8);
162
+ const fileName = `${id}-${hash}.md`;
163
+ const dir = materializeDir();
164
+ mkdirSync(dir, { recursive: true, mode: 0o700 }); // 仅属主可读写:Linux 多用户 /tmp 下防跨用户预植
165
+ const target = join(dir, fileName);
166
+ // write-if-absent 必须回读校验内容 hash:可预测路径 + 预置恶意文件(hash 可算,
167
+ // preset 文本随包公开)会把注入内容掉包——不一致则覆盖。写入用 tmp+rename 原子落盘,
168
+ // 进程在写中途崩溃留下截断文件时 write-if-absent 永不愈合。
169
+ const writeAtomic = () => {
170
+ const tmp = `${target}.${process.pid}.tmp`;
171
+ writeFileSync(tmp, text, 'utf-8');
172
+ renameSyncWithRetry(tmp, target);
173
+ };
174
+ if (!existsSync(target)) {
175
+ writeAtomic();
176
+ } else {
177
+ try {
178
+ const existing = readFileSync(target, 'utf-8');
179
+ if (createHash('sha256').update(existing).digest('hex').slice(0, 8) !== hash) writeAtomic();
180
+ } catch {
181
+ writeAtomic(); // 读失败(截断/权限)→ 覆盖重写
182
+ }
183
+ }
184
+ // best-effort GC:同 id 的旧 hash 文件(版本升级后残留)。精确匹配 8 位 hex hash 防
185
+ // id 前缀误删(如未来 kimi-k3-turbo);只清 mtime 足够旧的文件,避免跨版本并发
186
+ // (新旧实例同跑)时删掉旧实例正要读的文件。失败无碍。
187
+ try {
188
+ const idRe = new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-[0-9a-f]{8}\\.md$`);
189
+ for (const f of readdirSync(dir)) {
190
+ if (!idRe.test(f) || f === fileName) continue;
191
+ const p = join(dir, f);
192
+ try {
193
+ if (Date.now() - statSync(p).mtimeMs > 10 * 60_000) rmSync(p, { force: true });
194
+ } catch { /* ignore */ }
195
+ }
196
+ } catch { /* ignore */ }
197
+ return target;
198
+ }
@@ -71,7 +71,7 @@ export function getContextSizeForModel(modelOrEntry) {
71
71
  return _startupContextSize;
72
72
  }
73
73
  // 完整档位表见 @ccv/core/context-rules(与前端同源;含 haiku/旧 opus/3-opus 200K、
74
- // deepseek-v4 1M、kimi/moonshot 256K、gpt/deepseek 等三方档位,默认 200K)
74
+ // deepseek-v4 1M、kimi/moonshot 256K、gpt/deepseek 等三方档位,未识别型号默认 1M)
75
75
  return getModelMaxTokens(apiModelName);
76
76
  }
77
77
 
@@ -64,11 +64,12 @@ export function countUntrackedLines(cwd, file) {
64
64
  }
65
65
 
66
66
  /**
67
- * Get commits between upstream and HEAD (i.e. local commits not yet pushed).
68
- * Returns an empty list when:
69
- * - HEAD is detached (rev-parse --abbrev-ref HEAD prints "HEAD")
70
- * - Branch has no upstream (@{u} resolution fails)
71
- * - Working tree is at upstream (no commits ahead)
67
+ * Get local commits not yet pushed, between upstream and HEAD when an upstream
68
+ * is configured (`<upstream>..HEAD`), otherwise every HEAD commit absent from
69
+ * all remote-tracking refs (`git log HEAD --not --remotes`). The fallback keeps
70
+ * commits on branches without an upstream visible instead of silently dropping
71
+ * them. Returns an empty list when HEAD cannot be resolved (unborn branch) or
72
+ * the log command fails.
72
73
  *
73
74
  * Each commit includes its changed files via a single `git log --name-status` call,
74
75
  * to avoid one git invocation per commit.
@@ -86,21 +87,27 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
86
87
  } catch {
87
88
  return { commits: [], hasUpstream: false, branch: null, upstream: null };
88
89
  }
89
- if (!branch || branch === 'HEAD') {
90
- return { commits: [], hasUpstream: false, branch, upstream: null };
91
- }
90
+ // Detached HEAD prints "HEAD" — treat like a branch without upstream below.
91
+ const detached = !branch || branch === 'HEAD';
92
92
 
93
93
  let upstream = null;
94
- try {
95
- const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd, encoding: 'utf-8', timeout: 3000 });
96
- upstream = stdout.trim();
97
- } catch {
98
- return { commits: [], hasUpstream: false, branch, upstream: null };
99
- }
100
- if (!upstream || !SAFE_REF.test(upstream)) {
101
- return { commits: [], hasUpstream: false, branch, upstream: null };
94
+ if (!detached) {
95
+ try {
96
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd, encoding: 'utf-8', timeout: 3000 });
97
+ const name = stdout.trim();
98
+ if (name && SAFE_REF.test(name)) upstream = name;
99
+ } catch { /* no upstream configured — fall through to the remote-refs fallback */ }
102
100
  }
103
101
 
102
+ // With an upstream: commits ahead of it. Otherwise (no upstream, invalid
103
+ // upstream name, detached HEAD): commits on HEAD not reachable from any
104
+ // remote-tracking ref. Argument order matters — `--not` flips every revision
105
+ // listed after it, including HEAD itself, so it must be
106
+ // ['HEAD', '--not', '--remotes'], never ['--not', '--remotes', 'HEAD'].
107
+ const rangeArgs = upstream ? [`${upstream}..HEAD`] : ['HEAD', '--not', '--remotes'];
108
+ const hasUpstream = !!upstream;
109
+ const resultBranch = detached ? null : branch;
110
+
104
111
  // Use NUL separators between fields and a sentinel between commits to avoid
105
112
  // getting fooled by tabs/newlines inside commit subjects.
106
113
  // Format: <hash>\x1f<author>\x1f<date>\x1f<subject>\n
@@ -117,13 +124,13 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
117
124
  `--max-count=${maxCommits}`,
118
125
  `--pretty=format:${COMMIT_SEP}%H${FIELD_SEP}%an${FIELD_SEP}%aI${FIELD_SEP}%s`,
119
126
  '--name-status',
120
- `${upstream}..HEAD`,
127
+ ...rangeArgs,
121
128
  ],
122
129
  { cwd, encoding: 'utf-8', timeout: 8000, maxBuffer: 10 * 1024 * 1024 }
123
130
  );
124
131
  stdout = r.stdout;
125
132
  } catch {
126
- return { commits: [], hasUpstream: true, branch, upstream };
133
+ return { commits: [], hasUpstream, branch: resultBranch, upstream };
127
134
  }
128
135
 
129
136
  const commits = [];
@@ -163,7 +170,7 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
163
170
  let truncated = commits.length === maxCommits;
164
171
  if (truncated) {
165
172
  try {
166
- const r = await execFileAsync('git', ['rev-list', '--count', `${upstream}..HEAD`], { cwd, encoding: 'utf-8', timeout: 3000 });
173
+ const r = await execFileAsync('git', ['rev-list', '--count', ...rangeArgs], { cwd, encoding: 'utf-8', timeout: 3000 });
167
174
  const parsed = parseInt(r.stdout.trim(), 10);
168
175
  if (Number.isFinite(parsed) && parsed > 0) {
169
176
  totalCount = parsed;
@@ -172,7 +179,7 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
172
179
  } catch {}
173
180
  }
174
181
 
175
- return { commits, hasUpstream: true, branch, upstream, truncated, totalCount };
182
+ return { commits, hasUpstream, branch: resultBranch, upstream, truncated, totalCount };
176
183
  }
177
184
 
178
185
  /**
@@ -108,6 +108,10 @@ export function suppressManuallyFlaggedPinned(entries, userArgs) {
108
108
  // The F2 no-record notice only matters to users who HAVE injection configured right
109
109
  // now (sentinel files or a model-prompt dir) — for everyone else a resume silently
110
110
  // injecting nothing is exactly the status quo, and the line would be pure noise.
111
+ // Fidelity note (accepted gap): this check does NOT see the built-in preset layer —
112
+ // users whose only injection is a default-effective built-in (no dirs, no sentinels)
113
+ // get no F2 notice on resume either. Deliberate: making built-in hits count would turn
114
+ // the notice into near-constant noise for every built-in user resuming old sessions.
111
115
  export function injectionConfigured(spawnDir, logDir = LOG_DIR) {
112
116
  try {
113
117
  return isNonEmptyFile(join(spawnDir, SYSTEM_PROMPT_FILE))
@@ -152,7 +156,7 @@ function _defaultModelReader(spawnDir, env, opts) {
152
156
  * sysPrompt: {args: string[], loaded: string[], model: string|null, entries: object[], suppressed?: string, pinned?: boolean, noRecord?: boolean, noRecordNotice?: boolean},
153
157
  * resume: object|null,
154
158
  * resolvedModelId: string|null,
155
- * diagnostic: null|'no-match'|'no-model',
159
+ * diagnostic: null|'no-match'|'no-model'|'builtin-disabled',
156
160
  * }}
157
161
  */
158
162
  export function resolveLaunchSystemPrompt(p) {
@@ -222,6 +226,10 @@ export function resolveLaunchSystemPrompt(p) {
222
226
  });
223
227
  if (suppressInjection) {
224
228
  sysPrompt = { args: [], loaded: [], model: null, entries: [] };
229
+ } else if (sysPrompt.builtinDisabled) {
230
+ // The resolved model hit a built-in preset that the user tombstone-disabled —
231
+ // distinct from 'no-match' (a likely misnamed file): this is an intentional opt-out.
232
+ out.diagnostic = 'builtin-disabled';
225
233
  } else if (resolvedModelId && !sysPrompt.model && !sysPrompt.suppressed
226
234
  && (existsSync(join(spawnDir, MODEL_PROMPT_DIR)) || existsSync(join(logDir, MODEL_PROMPT_DIR)))) {
227
235
  // A system_prompt dir is configured but the resolved model matched no entry
@@ -261,10 +261,26 @@ function modelIdVariants(id) {
261
261
  return MODEL_ID_ALIASES[id] || [id];
262
262
  }
263
263
 
264
+ // 供内置预设匹配复用的导出包装:入参任意大小写,先剥 `[1m]` 类方括号后缀
265
+ // (spawn 渲染管线对 model.name 做同样的剥离;上游 resolver 正常会剥,这里兜底),
266
+ // 再 toLowerCase 展开别名——避免大写 env(如 K3)或带后缀的裸名(k3[1m])绕过别名表。
267
+ // 单一别名源,勿在别处复制 MODEL_ID_ALIASES。
268
+ // Exported wrapper for the built-in preset matcher: strips a trailing bracket suffix
269
+ // and lowercases before expanding, so uppercase ids (K3) or suffixed shorthands
270
+ // (k3[1m]) cannot bypass the alias table.
271
+ export function expandModelIdVariants(modelId) {
272
+ if (!modelId || typeof modelId !== 'string') return [];
273
+ const stripped = modelId.replace(/\s*\[[^\]]*\]$/, '').toLowerCase();
274
+ return modelIdVariants(stripped);
275
+ }
276
+
264
277
  export function matchModelPrompt(modelId, candidates) {
265
278
  if (!modelId || typeof modelId !== 'string') return null;
266
279
  if (!Array.isArray(candidates)) return null;
267
- const variants = modelIdVariants(modelId.toLowerCase());
280
+ // 与内置层(builtin-model-prompts.js)同一展开:剥 `[1m]` 类后缀 + lowercase + 别名。
281
+ // 若用户层不剥后缀而内置层剥(k3[1m] + 用户有 KIMI-K3_SYSTEM.md),用户文件会
282
+ // 静默 miss、内置反客为主 —— 违反「用户文件永远优先」,两层必须同语义。
283
+ const variants = expandModelIdVariants(modelId);
268
284
  for (const cand of candidates) {
269
285
  if (!cand || !cand.dir) continue;
270
286
  const hits = listModelPrompts(cand.dir).filter((e) => {
@@ -1,7 +1,9 @@
1
1
  import { readFileSync, writeFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { MODEL_PROMPT_DIR, matchModelPrompt } from './model-system-prompts.js';
4
+ import { isBuiltinDisabled, matchBuiltinModelPrompt, materializeBuiltinPrompt } from './builtin-model-prompts.js';
4
5
  import { isNonEmptyFile } from './file-api.js';
6
+ import { reportSwallowed } from '@ccv/core/error-report';
5
7
 
6
8
  // isNonEmptyFile lives in file-api.js (shared leaf, cycle break:
7
9
  // model-system-prompts.js must not import this module). Re-exported here so
@@ -42,22 +44,28 @@ export function hasArg(args, ...names) {
42
44
  *
43
45
  * 模型定制(opts.modelId 提供时):先在 <projectDir>/system_prompt/(工作区)与
44
46
  * opts.globalModelDir(全局)里做模糊匹配;命中的条目「整体取代」上面两份默认 sentinel
45
- * ——即便手动 flag 抑制了注入也不再回看默认文件(条目已取而代之)。未命中/无 modelId
46
- * 则完全走旧逻辑。
47
+ * ——即便手动 flag 抑制了注入也不再回看默认文件(条目已取而代之)。未命中则进入内置层:
48
+ * 随包 presets(builtin-model-prompts.js)按同语义再匹配一次,命中且未被墓碑禁用时把
49
+ * preset 文本物化为临时文件注入;命中被禁用(builtinDisabled)或未命中才回落 sentinel。
47
50
  *
48
51
  * Decide whether to inject system-prompt file flags based on sentinel files in
49
52
  * the launch directory. When opts.modelId is given, model-specific entries in
50
53
  * the workspace/global system_prompt folders are matched first; a match fully
51
- * supersedes the Default sentinels. Pure function: reads fs/env only.
54
+ * supersedes the Default sentinels. Without a file match, shipped built-in presets
55
+ * act as the fallback layer (tombstone-disabled ones are skipped). Reads fs/env;
56
+ * writes materialized built-in temp files; the built-in layer never throws — any
57
+ * failure falls back to the sentinel logic via reportSwallowed.
52
58
  *
53
59
  * @param {string} projectDir 启动目录(绝对路径)
54
60
  * @param {string[]} [existingArgs] 已有的 claude 参数(用于「手动优先」判断)
55
61
  * @param {Object} [env] 环境变量(默认 process.env)
56
62
  * @param {{ modelId?: string|null, globalModelDir?: string|null }} [opts]
57
- * @returns {{ args: string[], loaded: string[], model: string|null, suppressed?: 'env'|'manual-flag' }}
63
+ * @returns {{ args: string[], loaded: string[], model: string|null, suppressed?: 'env'|'manual-flag',
64
+ * builtinDisabled?: string }}
58
65
  * args: 待追加参数;loaded: 实际加载的文件(终端提示);model: 命中的条目名(未命中为 null);
59
66
  * suppressed: 注入被有意跳过的原因(env 开关 / 手动同义 flag 抑制了已命中的模型条目)——
60
67
  * 调用方(pty-manager)据此不再打「no matching entry」误导性告警。
68
+ * builtinDisabled 仅在内置层命中但被墓碑禁用时出现(增量字段,否则不存在)。
61
69
  */
62
70
  export function buildSystemPromptFileArgs(projectDir, existingArgs = [], env = process.env, opts = {}) {
63
71
  const out = { args: [], loaded: [], model: null };
@@ -81,8 +89,37 @@ export function buildSystemPromptFileArgs(projectDir, existingArgs = [], env = p
81
89
  }
82
90
  return out; // 命中即返回:默认 sentinel 不再参与(含手动 flag 抑制注入的情况)。
83
91
  }
92
+ // 用户文件未命中 → 内置层(随包 presets):同语义匹配 + 墓碑检查;命中即物化注入并
93
+ // 提前返回(同样取代 sentinel)。整层 try-catch:任何失败(preset 缺失/tmp 不可写)经
94
+ // reportSwallowed 回落 sentinel——内置不可用绝不应连默认注入都拖垮。
95
+ // No file match → built-in fallback layer (shipped presets, tombstone-aware).
96
+ try {
97
+ const builtin = matchBuiltinModelPrompt(opts.modelId);
98
+ if (builtin) {
99
+ const flagPair = builtin.mode === 'override'
100
+ ? ['--system-prompt', '--system-prompt-file']
101
+ : ['--append-system-prompt', '--append-system-prompt-file'];
102
+ if (hasArg(existingArgs, ...flagPair)) {
103
+ out.suppressed = 'manual-flag'; // 与文件命中同语义:有意跳过,非「无条目」
104
+ return out; // 手动抑制也提前返回(不物化、不回看 sentinel)
105
+ }
106
+ const workspaceModelDir = join(projectDir, MODEL_PROMPT_DIR);
107
+ if (isBuiltinDisabled(builtin.name, workspaceModelDir, opts.globalModelDir)) {
108
+ out.builtinDisabled = builtin.name; // 供 launch-config 区分「被禁用」与「无条目」
109
+ // 落回 sentinel(不 return)
110
+ } else {
111
+ const path = materializeBuiltinPrompt(builtin.id, builtin.text);
112
+ out.args.push(flagPair[1], path);
113
+ out.loaded.push(`builtin:${builtin.name}`); // 稳定标签,不打印 tmp 丑路径
114
+ out.model = builtin.name;
115
+ return out;
116
+ }
117
+ }
118
+ } catch (err) {
119
+ reportSwallowed('system-prompt-files.builtin', err);
120
+ }
84
121
  // No matching entry: fall through to the default sentinels. Diagnostics for
85
- // this case live in the caller (pty-manager) — this stays a pure function.
122
+ // this case live in the caller (launch-config/pty-manager).
86
123
  }
87
124
 
88
125
  // 整段替换:CC_SYSTEM.md → --system-prompt-file (用户已传 --system-prompt[-file] 则跳过)
@@ -390,8 +390,9 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
390
390
  // When the launch dir has a non-empty CC_SYSTEM.md / CC_APPEND_SYSTEM.md, auto-append
391
391
  // --system-prompt-file / --append-system-prompt-file (each independent; skipped if the
392
392
  // user already passed the synonymous flag). Model customization: fuzzy-match against
393
- // <cwd>/system_prompt/ and <LOG_DIR>/system_prompt/ using "the model used on the last
394
- // launch"; a matched entry wholly replaces the two default sentinels above.
393
+ // <cwd>/system_prompt/ and <LOG_DIR>/system_prompt/ using the model id resolved from the
394
+ // ACTIVE configuration (proxy profile mapping > env > settings.json); a matched entry
395
+ // (user file first, then built-in presets) wholly replaces the two default sentinels above.
395
396
  // Note: currentWorkspacePath is only assigned below, so the cwd param decides the launch
396
397
  // dir here. Spawns inside LOG_DIR (IM worker working dir = <LOG_DIR>/IM_<id>/) skip model
397
398
  // matching: the IM persona relies on the default sentinel CC_APPEND_SYSTEM.md injection,
@@ -425,7 +426,9 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
425
426
  suppressInjection: _systemPromptFileRejectedPaths.has(claudePath) || skipOnce,
426
427
  });
427
428
  sysPrompt = r.sysPrompt;
428
- if (r.diagnostic === 'no-match') {
429
+ if (r.diagnostic === 'builtin-disabled') {
430
+ console.warn(`[CC Viewer] model-specific prompt: built-in prompt "${r.sysPrompt.builtinDisabled}" for modelId="${r.resolvedModelId}" is disabled via .builtin-disabled.json in the workspace or global ${MODEL_PROMPT_DIR}/ — falling back to defaults`);
431
+ } else if (r.diagnostic === 'no-match') {
429
432
  console.warn(`[CC Viewer] model-specific prompt: modelId="${r.resolvedModelId}" resolved from active config but no matching entry found in workspace or global ${MODEL_PROMPT_DIR}/`);
430
433
  } else if (r.diagnostic === 'no-model') {
431
434
  console.warn(`[CC Viewer] model-specific prompt: no model id resolved from active config (--settings / env / settings.json / proxy profile) — entries in ${MODEL_PROMPT_DIR}/ skipped for this launch`);