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
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// System 文本随主模型热切换 + (sessionId, model) 静态化(live 层)。
|
|
2
|
+
//
|
|
3
|
+
// 背景:启动期注入(--system-prompt-file / --append-system-prompt-file)只在 spawn 时
|
|
4
|
+
// 生效;代理热切换主模型后 system 文本无法跟随。本模块在代理层提供运行时改写:
|
|
5
|
+
// - 切换模型时按新模型重选 system 文本(override 与 append 可并存);
|
|
6
|
+
// - 按 (projectKey, sessionId, model) 固化:同一 session 内同一模型下文本字节级
|
|
7
|
+
// 不变,KV-cache 破坏仅限切换那一次;
|
|
8
|
+
// - 启动模型的条目直接 seed 自启动期注入的 entries 字节(launch-config 发布),
|
|
9
|
+
// 绝不重新渲染 —— 消除 spawn→首请求的 ${git.*}/${time.*} 漂移窗口,也保住
|
|
10
|
+
// Bind A 内容匹配与 resume pin。
|
|
11
|
+
//
|
|
12
|
+
// 失败哲学(对齐 system-prompt-snapshots.js):所有公开函数 total(出错返回
|
|
13
|
+
// null/false,绝不 throw)——缓存丢失只意味着退化为不改写,绝不阻断请求。
|
|
14
|
+
//
|
|
15
|
+
// 磁盘布局:<LOG_DIR>/<projectKey>/system-prompt-snapshots/live/<sessionId>.json
|
|
16
|
+
// { v:1, byModel: { [model]: { override, append, createdAt } } }
|
|
17
|
+
// system-prompt-snapshots.gc() 非递归、只看 <uuid>.json,与 live/ 子目录互不干扰;
|
|
18
|
+
// live 的 GC 由本模块自带(惰性,seed/生成路径触发)。
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
23
|
+
import { LOG_DIR } from '../../findcc.js';
|
|
24
|
+
import { renameSyncWithRetry } from './file-api.js';
|
|
25
|
+
import { matchModelPrompt, MODEL_PROMPT_DIR, readModelPrompt } from './model-system-prompts.js';
|
|
26
|
+
import { matchBuiltinModelPrompt, isBuiltinDisabled } from './builtin-model-prompts.js';
|
|
27
|
+
import { SYSTEM_PROMPT_FILE, APPEND_SYSTEM_PROMPT_FILE, isNonEmptyFile } from './system-prompt-files.js';
|
|
28
|
+
import { createSystemPrompt, toSystemPromptVariableSnapshot, fromSystemPromptVariableSnapshot } from './create_system_prompt.js';
|
|
29
|
+
|
|
30
|
+
const LIVE_DIR_NAME = 'live';
|
|
31
|
+
const LIVE_GC_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
32
|
+
const MAX_LIVE_FILE_BYTES = 2 * 1024 * 1024;
|
|
33
|
+
const MAX_TEXT_BYTES = 256 * 1024; // 对齐 snapshots 的 MAX_ENTRY_CONTENT_BYTES
|
|
34
|
+
const TEMPLATE_VARIABLE_RE = /\$\{[^}]+\}/;
|
|
35
|
+
// Anthropic billing-header 前缀块标记:override 整段替换时保留的非注入 block 之一
|
|
36
|
+
// (CLI 把它放在 system 数组首块承载 cc_version/cc_entrypoint 等计费/分类元数据;
|
|
37
|
+
// 保留它还连带保住 cc_is_subagent=true 标记 → 日志侧分类不受改写影响)。
|
|
38
|
+
const BILLING_HEADER_PREFIX = 'x-anthropic-billing-header:';
|
|
39
|
+
// CLI 官方身份行(整块独立、带 ephemeral 断点,实测 wire 3 块形态 [billing, identity,
|
|
40
|
+
// persona] 的第 2 块)。override 整段替换保留它(D1:与启动形态一致,override 会话
|
|
41
|
+
// 首请求零改写、KV-cache 复用)。整块精确匹配 —— 实测真实 wire 身份块恒为整块精确
|
|
42
|
+
// (CLI 37/37、SDK 52/52 blob),而 subagent persona("You are a file search specialist…")
|
|
43
|
+
// 只含片段、整块不等,自定义 persona("You are k3…")更不会整块等于。两块文案均为实测值:
|
|
44
|
+
const CLI_IDENTITY_TEXTS = [
|
|
45
|
+
"You are Claude Code, Anthropic's official CLI for Claude.", // CLI 主会话(37 blob 实测整块)
|
|
46
|
+
"You are a Claude agent, built on Anthropic's Claude Agent SDK.", // SDK 主会话(52 blob 实测整块)
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// 整体开关:CCV_DISABLE_LIVE_SYSTEM_PROMPT=1 关闭 live 改写(CCV_DISABLE_AUTO_SYSTEM_PROMPT
|
|
50
|
+
// 在启用门处另查 —— 用户显式关掉自动注入时也不应在请求时回补)。
|
|
51
|
+
export const DISABLE_LIVE_ENV = 'CCV_DISABLE_LIVE_SYSTEM_PROMPT';
|
|
52
|
+
|
|
53
|
+
// ─── 启动判定发布(launch-config push,interceptor 消费)─────────────────────
|
|
54
|
+
// 拦截进程 = 生成进程(ccv run / PTY / SDK 的 parent hook),模块内直接传递。
|
|
55
|
+
// 这是启用门的唯一事实源:只有「ccv 启动时确实注入了内容且未被手动 flag/env 抑制」
|
|
56
|
+
// 的会话才启用 live 改写 —— 手动内联 --system-prompt 的用户绝不被覆盖。
|
|
57
|
+
let _launchInfo = null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 发布本次启动的 system 注入判定。launch-config 在 resolveLaunchSystemPrompt 末尾调用。
|
|
61
|
+
* @param {{workspaceDir:string, resolvedModelId:string|null,
|
|
62
|
+
* entries:Array<{flag:string,content:string}>, pinned:boolean,
|
|
63
|
+
* suppressed:string|undefined, manualSystemPrompt:boolean}} info
|
|
64
|
+
* manualSystemPrompt:用户**手动**传了 --system-prompt / --system-prompt-file
|
|
65
|
+
* (值非 ccv 启动注入路径)——此类会话绝不被热切换覆盖(手动优先)。
|
|
66
|
+
*/
|
|
67
|
+
export function setLaunchSystemPromptInfo(info) {
|
|
68
|
+
if (!info || typeof info !== 'object') { _launchInfo = null; return; }
|
|
69
|
+
_launchInfo = {
|
|
70
|
+
workspaceDir: typeof info.workspaceDir === 'string' ? info.workspaceDir : '',
|
|
71
|
+
resolvedModelId: typeof info.resolvedModelId === 'string' ? info.resolvedModelId : null,
|
|
72
|
+
entries: Array.isArray(info.entries)
|
|
73
|
+
? info.entries.filter(e => e && typeof e.content === 'string' && e.content.length > 0)
|
|
74
|
+
: [],
|
|
75
|
+
pinned: info.pinned === true,
|
|
76
|
+
suppressed: typeof info.suppressed === 'string' ? info.suppressed : null,
|
|
77
|
+
manualSystemPrompt: info.manualSystemPrompt === true,
|
|
78
|
+
// IM worker(insideLogDir)等明确不允许 live 覆盖的场景 → allowLive:false。
|
|
79
|
+
allowLive: info.allowLive !== false,
|
|
80
|
+
// Per-launch cacheable variable snapshot (git/os/env/memory… minus time.date &
|
|
81
|
+
// model.name). Stored on the single launchInfo slot so it is replaced on every
|
|
82
|
+
// launch — a switched-model render uses THIS launch's workspaceDir and THIS
|
|
83
|
+
// launch's snapshot, keeping the two in lock-step (no cross-workspace leak, no
|
|
84
|
+
// stale read from a previous launch of the same workspace). The publisher passes
|
|
85
|
+
// the raw collected set; we keep only the cacheable snapshot part. Null when the
|
|
86
|
+
// launch collected nothing (no injection, or injected text had no `${...}`).
|
|
87
|
+
variableSnapshot: (info.variableSnapshot && typeof info.variableSnapshot === 'object')
|
|
88
|
+
? toSystemPromptVariableSnapshot(info.variableSnapshot) : null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 当前启动判定(无 → null)。测试可用 setLaunchSystemPromptInfo(null) 复位。 */
|
|
93
|
+
export function getLaunchSystemPromptInfo() {
|
|
94
|
+
return _launchInfo;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* live 改写的启用门(强行覆盖模式):以热切换当前选中的模型为准,只要该模型有对应
|
|
99
|
+
* system 文本(模型条目/builtin/sentinel)就在代理层注入/替换——包括启动阶段没有
|
|
100
|
+
* 注入的情况(防止启动漏注入)。全部条件满足才启用:
|
|
101
|
+
* - CCV_DISABLE_LIVE_SYSTEM_PROMPT / CCV_DISABLE_AUTO_SYSTEM_PROMPT 均未设;
|
|
102
|
+
* - 启动判定存在且有 workspaceDir(选择/渲染需要工作区上下文);
|
|
103
|
+
* - 未 suppressed、且用户没有手动传 --system-prompt(手动优先,绝不被覆盖)。
|
|
104
|
+
* 注意:不再要求 entries 非空——启动没注入正是要强行覆盖的场景。
|
|
105
|
+
*/
|
|
106
|
+
export function liveSystemPromptEnabled(env = process.env) {
|
|
107
|
+
if (env[DISABLE_LIVE_ENV] === '1') return false;
|
|
108
|
+
if (env.CCV_DISABLE_AUTO_SYSTEM_PROMPT === '1') return false;
|
|
109
|
+
if (!_launchInfo || _launchInfo.suppressed) return false;
|
|
110
|
+
if (_launchInfo.manualSystemPrompt) return false;
|
|
111
|
+
if (_launchInfo.allowLive === false) return false; // IM worker 等:persona 不被覆盖
|
|
112
|
+
return !!_launchInfo.workspaceDir;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── 固化缓存(内存 + 磁盘)─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const _memCache = new Map(); // `${projectKey}${sessionId}` → { byModel: {...} }
|
|
118
|
+
const MAX_MEM_ENTRIES = 64; // 插入序淘汰,对齐 SingleFlight 的有界思路
|
|
119
|
+
|
|
120
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
121
|
+
|
|
122
|
+
let _now = Date.now;
|
|
123
|
+
export function _setLiveDepsForTests({ now } = {}) {
|
|
124
|
+
_now = typeof now === 'function' ? now : Date.now;
|
|
125
|
+
}
|
|
126
|
+
export function _resetLiveForTests() {
|
|
127
|
+
_memCache.clear();
|
|
128
|
+
_launchInfo = null;
|
|
129
|
+
_now = Date.now;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function memKey(projectKey, sessionId) { return `${projectKey}${sessionId}`; }
|
|
133
|
+
|
|
134
|
+
function liveDirForKey(projectKey, logDir = LOG_DIR) {
|
|
135
|
+
return projectKey ? join(logDir, projectKey, 'system-prompt-snapshots', LIVE_DIR_NAME) : '';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sanitizeModelMap(raw) {
|
|
139
|
+
const out = {};
|
|
140
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
141
|
+
for (const [model, rec] of Object.entries(raw)) {
|
|
142
|
+
if (!model || model.length > 256) continue;
|
|
143
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
144
|
+
const override = typeof rec.override === 'string' && rec.override.length > 0
|
|
145
|
+
&& Buffer.byteLength(rec.override, 'utf-8') <= MAX_TEXT_BYTES ? rec.override : null;
|
|
146
|
+
const append = typeof rec.append === 'string' && rec.append.length > 0
|
|
147
|
+
&& Buffer.byteLength(rec.append, 'utf-8') <= MAX_TEXT_BYTES ? rec.append : null;
|
|
148
|
+
if (!override && !append) continue;
|
|
149
|
+
out[model] = { override, append, createdAt: typeof rec.createdAt === 'number' ? rec.createdAt : 0 };
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readLiveRecord(projectKey, sessionId, logDir) {
|
|
155
|
+
try {
|
|
156
|
+
if (!UUID_RE.test(String(sessionId || ''))) return null;
|
|
157
|
+
const dir = liveDirForKey(projectKey, logDir);
|
|
158
|
+
if (!dir) return null;
|
|
159
|
+
const file = join(dir, `${sessionId}.json`);
|
|
160
|
+
if (!existsSync(file)) return null;
|
|
161
|
+
if (statSync(file).size > MAX_LIVE_FILE_BYTES) return null;
|
|
162
|
+
const obj = JSON.parse(readFileSync(file, 'utf-8'));
|
|
163
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
164
|
+
return { byModel: sanitizeModelMap(obj.byModel) };
|
|
165
|
+
} catch (err) {
|
|
166
|
+
reportSwallowed('sys-prompt-live.read', err);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function writeLiveRecord(projectKey, sessionId, byModel, logDir) {
|
|
172
|
+
try {
|
|
173
|
+
const dir = liveDirForKey(projectKey, logDir);
|
|
174
|
+
if (!dir) return false;
|
|
175
|
+
mkdirSync(dir, { recursive: true });
|
|
176
|
+
const file = join(dir, `${sessionId}.json`);
|
|
177
|
+
const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
178
|
+
try {
|
|
179
|
+
writeFileSync(tmp, JSON.stringify({ v: 1, byModel }));
|
|
180
|
+
renameSyncWithRetry(tmp, file);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
try { unlinkSync(tmp); } catch { /* already absent */ }
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
reportSwallowed('sys-prompt-live.write', err);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 取 (projectKey, sessionId, model) 的固化条目。内存 → 磁盘 → null。
|
|
194
|
+
* 返回 { override, append } | null(override/append 至少一个非空)。
|
|
195
|
+
*/
|
|
196
|
+
export function getLiveEntry(projectKey, sessionId, model, { logDir } = {}) {
|
|
197
|
+
try {
|
|
198
|
+
if (!projectKey || !UUID_RE.test(String(sessionId || '')) ||
|
|
199
|
+
typeof model !== 'string' || !model) return null;
|
|
200
|
+
const key = memKey(projectKey, sessionId);
|
|
201
|
+
let rec = _memCache.get(key);
|
|
202
|
+
if (!rec) {
|
|
203
|
+
rec = readLiveRecord(projectKey, sessionId, logDir);
|
|
204
|
+
if (rec) {
|
|
205
|
+
_memCache.set(key, rec);
|
|
206
|
+
if (_memCache.size > MAX_MEM_ENTRIES) {
|
|
207
|
+
const first = _memCache.keys().next().value;
|
|
208
|
+
_memCache.delete(first);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const entry = rec && rec.byModel[model];
|
|
213
|
+
if (!entry) return null;
|
|
214
|
+
return { override: entry.override, append: entry.append };
|
|
215
|
+
} catch (err) {
|
|
216
|
+
reportSwallowed('sys-prompt-live.get', err);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 写入/合并 (projectKey, sessionId, model) 的固化条目(内存 + 磁盘)。
|
|
223
|
+
*/
|
|
224
|
+
export function putLiveEntry(projectKey, sessionId, model, entry, { logDir } = {}) {
|
|
225
|
+
try {
|
|
226
|
+
if (!projectKey || !UUID_RE.test(String(sessionId || '')) ||
|
|
227
|
+
typeof model !== 'string' || !model) return false;
|
|
228
|
+
const clean = sanitizeModelMap({ [model]: entry });
|
|
229
|
+
if (!clean[model]) return false;
|
|
230
|
+
const key = memKey(projectKey, sessionId);
|
|
231
|
+
const rec = _memCache.get(key) || readLiveRecord(projectKey, sessionId, logDir) || { byModel: {} };
|
|
232
|
+
rec.byModel[model] = clean[model];
|
|
233
|
+
// 写路径也要淘汰:长驻 proxy 进程按 session 累积,MAX_MEM_ENTRIES 上界对 put 同样生效。
|
|
234
|
+
if (_memCache.has(key)) _memCache.delete(key); // 重插以刷新插入序(近似 LRU)
|
|
235
|
+
_memCache.set(key, rec);
|
|
236
|
+
if (_memCache.size > MAX_MEM_ENTRIES) {
|
|
237
|
+
const first = _memCache.keys().next().value;
|
|
238
|
+
_memCache.delete(first);
|
|
239
|
+
}
|
|
240
|
+
writeLiveRecord(projectKey, sessionId, rec.byModel, logDir);
|
|
241
|
+
gcLive(projectKey, logDir);
|
|
242
|
+
return true;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
reportSwallowed('sys-prompt-live.put', err);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 惰性 GC:只扫 live/ 子目录,30 天龄期。transcript 消失判断留给 snapshots 主 GC。 */
|
|
250
|
+
export function gcLive(projectKey, logDir) {
|
|
251
|
+
try {
|
|
252
|
+
const dir = liveDirForKey(projectKey, logDir);
|
|
253
|
+
if (!dir || !existsSync(dir)) return 0;
|
|
254
|
+
const cutoff = _now() - LIVE_GC_AGE_MS;
|
|
255
|
+
let removed = 0;
|
|
256
|
+
for (const name of readdirSync(dir)) {
|
|
257
|
+
if (!name.endsWith('.json')) continue;
|
|
258
|
+
const file = join(dir, name);
|
|
259
|
+
try {
|
|
260
|
+
if (statSync(file).mtimeMs < cutoff) { unlinkSync(file); removed++; }
|
|
261
|
+
} catch { /* best-effort */ }
|
|
262
|
+
}
|
|
263
|
+
return removed;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
reportSwallowed('sys-prompt-live.gc', err);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 本 session 已知的全部注入文本(剥离用):launch entries + 缓存里所有模型的条目。 */
|
|
271
|
+
export function knownInjectedTexts(projectKey, sessionId, { logDir } = {}) {
|
|
272
|
+
const texts = [];
|
|
273
|
+
if (_launchInfo) {
|
|
274
|
+
for (const e of _launchInfo.entries) texts.push(e.content);
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const key = memKey(projectKey, sessionId);
|
|
278
|
+
const rec = _memCache.get(key) || readLiveRecord(projectKey, sessionId, logDir);
|
|
279
|
+
if (rec) {
|
|
280
|
+
for (const m of Object.values(rec.byModel)) {
|
|
281
|
+
if (m.override) texts.push(m.override);
|
|
282
|
+
if (m.append) texts.push(m.append);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
reportSwallowed('sys-prompt-live.known', err);
|
|
287
|
+
}
|
|
288
|
+
return texts;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── 选择 + 渲染(仅非启动模型调用;同步小文件读 + 纯字符串渲染,无现场子进程)──
|
|
292
|
+
// 渲染用「启动期按 launchInfo 发布的变量快照」+ 实时 time/model —— 绝不现场跑 git。
|
|
293
|
+
|
|
294
|
+
function renderIfTemplated(text, modelId, cwd, variablesFactory) {
|
|
295
|
+
if (!TEMPLATE_VARIABLE_RE.test(text)) return text;
|
|
296
|
+
// 三级取数:
|
|
297
|
+
// ① variablesFactory 仅测试 seam(显式传入才生效)。生产默认是下方的快照解析器,
|
|
298
|
+
// 永不 spawn —— 切勿恢复 `|| createSystemPromptVariables` 兜底:那会让生产路径
|
|
299
|
+
// 在 fetch hook 同步段跑 git(单仓库最多 8 次 spawnSync × 15s),冻结整个 server。
|
|
300
|
+
// ② 启动期发布的 _launchInfo.variableSnapshot → 快照 + 实时 time/model。
|
|
301
|
+
// ③ 无快照(启动无注入 / 注入文本无 ${...} / pinned-resume)→ fromSnapshot(null),
|
|
302
|
+
// 合成空串骨架,${git.*} 渲染为空串(不是 keep 字面量)。
|
|
303
|
+
// cwd 参数只有 ① 级会用到;快照路径下快照已含 environment.cwd/memory.dir,忽略 cwd。
|
|
304
|
+
if (typeof variablesFactory === 'function') {
|
|
305
|
+
const overrides = {};
|
|
306
|
+
if (modelId) overrides.model = { name: String(modelId).replace(/\[1m\]$/, '') };
|
|
307
|
+
const variables = variablesFactory(overrides, { cwd });
|
|
308
|
+
return createSystemPrompt(text, { variables, missingVariableMode: 'keep' });
|
|
309
|
+
}
|
|
310
|
+
const snapshot = _launchInfo && _launchInfo.variableSnapshot ? _launchInfo.variableSnapshot : null;
|
|
311
|
+
const variables = fromSystemPromptVariableSnapshot(snapshot, { modelId });
|
|
312
|
+
return createSystemPrompt(text, { variables, missingVariableMode: 'keep' });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function readSentinel(dir, fileName) {
|
|
316
|
+
const p = join(dir, fileName);
|
|
317
|
+
if (!isNonEmptyFile(p)) return null;
|
|
318
|
+
try {
|
|
319
|
+
const t = readFileSync(p, 'utf-8');
|
|
320
|
+
return t.trim().length > 0 ? t : null;
|
|
321
|
+
} catch (err) {
|
|
322
|
+
// 文件非空但读失败(权限/竞态删除)→ 放弃该 sentinel;有诊断价值,不静默。
|
|
323
|
+
reportSwallowed('sys-prompt-live.sentinel', err);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 为 modelId 选择 { override, append } 文本(override/append 可并存,至少一个非空才有意义)。
|
|
330
|
+
* 优先级:workspace 模型条目 → global 模型条目 → builtin preset(墓碑感知)→
|
|
331
|
+
* 默认 sentinel(CC_SYSTEM.md / CC_APPEND_SYSTEM.md,各自独立存在判断)。
|
|
332
|
+
* 模型条目命中时「整体取代」sentinel(对齐 buildSystemPromptFileArgs:90 语义)。
|
|
333
|
+
* 全部失败/无配置 → null。
|
|
334
|
+
*
|
|
335
|
+
* @param {string} modelId
|
|
336
|
+
* @param {{ workspaceDir: string, globalModelDir?: string|null,
|
|
337
|
+
* variablesFactory?: Function }} opts
|
|
338
|
+
* @returns {{ override: string|null, append: string|null } | null}
|
|
339
|
+
*/
|
|
340
|
+
export function selectEntriesForModel(modelId, opts = {}) {
|
|
341
|
+
try {
|
|
342
|
+
const { workspaceDir, globalModelDir = null, variablesFactory } = opts;
|
|
343
|
+
if (typeof modelId !== 'string' || !modelId || !workspaceDir) return null;
|
|
344
|
+
const cwd = workspaceDir;
|
|
345
|
+
|
|
346
|
+
// 1. 用户模型条目(workspace → global)
|
|
347
|
+
const wsDir = join(workspaceDir, MODEL_PROMPT_DIR);
|
|
348
|
+
const candidates = [{ dir: wsDir, scope: 'workspace' }];
|
|
349
|
+
if (globalModelDir) candidates.push({ dir: globalModelDir, scope: 'global' });
|
|
350
|
+
const match = matchModelPrompt(modelId, candidates);
|
|
351
|
+
if (match) {
|
|
352
|
+
const dir = match.scope === 'workspace' ? wsDir : globalModelDir;
|
|
353
|
+
const rec = dir ? readModelPrompt(dir, match.name) : null;
|
|
354
|
+
const text = rec && typeof rec.text === 'string' ? rec.text : null;
|
|
355
|
+
if (text == null) return null; // 列出后读失败 → 安全降级
|
|
356
|
+
const rendered = renderIfTemplated(text, modelId, cwd, variablesFactory);
|
|
357
|
+
return match.mode === 'override' ? { override: rendered, append: null } : { override: null, append: rendered };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 2. builtin preset(墓碑感知)
|
|
361
|
+
try {
|
|
362
|
+
const builtin = matchBuiltinModelPrompt(modelId);
|
|
363
|
+
if (builtin && !isBuiltinDisabled(builtin.name, join(workspaceDir, MODEL_PROMPT_DIR), globalModelDir)) {
|
|
364
|
+
const rendered = renderIfTemplated(builtin.text, modelId, cwd, variablesFactory);
|
|
365
|
+
return builtin.mode === 'override' ? { override: rendered, append: null } : { override: null, append: rendered };
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
reportSwallowed('sys-prompt-live.builtin', err);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 3. 默认 sentinel(两份可并存)
|
|
372
|
+
const override = readSentinel(workspaceDir, SYSTEM_PROMPT_FILE);
|
|
373
|
+
const append = readSentinel(workspaceDir, APPEND_SYSTEM_PROMPT_FILE);
|
|
374
|
+
if (!override && !append) return null;
|
|
375
|
+
return {
|
|
376
|
+
override: override ? renderIfTemplated(override, modelId, cwd, variablesFactory) : null,
|
|
377
|
+
append: append ? renderIfTemplated(append, modelId, cwd, variablesFactory) : null,
|
|
378
|
+
};
|
|
379
|
+
} catch (err) {
|
|
380
|
+
reportSwallowed('sys-prompt-live.select', err);
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── 应用(保形态 + 幂等剥离)───────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
function blockText(b) { return (b && typeof b.text === 'string') ? b.text : ''; }
|
|
388
|
+
|
|
389
|
+
/** 该 block 文本是否为 Anthropic billing-header 前缀块(行首即标记,无前导空白)。 */
|
|
390
|
+
export function isBillingHeaderText(text) {
|
|
391
|
+
return typeof text === 'string' && text.startsWith(BILLING_HEADER_PREFIX);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** 该 block 文本是否为 CLI/SDK 官方身份行。整块精确等于常量才保留 —— 实测真实 wire
|
|
395
|
+
* 身份块恒为整块精确(37/37 blob),而 subagent persona("You are a file search
|
|
396
|
+
* specialist for Claude Code, Anthropic's official CLI…")只含身份行片段、整块不等。
|
|
397
|
+
* 不能用 startsWith:「身份行 + 追加内容」的块会被误留,让追加的 persona 逃过整段替换。
|
|
398
|
+
* SDK 身份行常量从不在块开头(实测 SDK persona 是 "You are Claude Code, built on…"),
|
|
399
|
+
* startsWith 本就识别不到,故精确匹配不损失 SDK 场景(其 persona 块本就该被替换)。
|
|
400
|
+
* 导出供 append 守卫复用:保留块(billing/身份行)在 append 路径同样「永不并入」。 */
|
|
401
|
+
export function isCliIdentityText(text) {
|
|
402
|
+
return typeof text === 'string' && CLI_IDENTITY_TEXTS.some(t => text === t);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 保留 billing 块但把 text 截到首行:CC ≥2.1.181 会把 persona 夹进同一 block
|
|
406
|
+
// (`x-anthropic-billing-header: …;\nYou are Claude Code…`),整块保留会让官方 persona
|
|
407
|
+
// 逃过整段替换。对本机实测 wire(0/55071 多行 billing 块)是 no-op。
|
|
408
|
+
function preservedBillingBlock(block) {
|
|
409
|
+
const t = blockText(block);
|
|
410
|
+
const nl = t.indexOf('\n');
|
|
411
|
+
return nl === -1 ? block : { ...block, text: t.slice(0, nl) };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 取 blocks 里最靠后的 cache_control(含 ttl):override 整段替换 / append 弹尾块时把断点
|
|
415
|
+
// 继承到新注入块,避免 ttl 被静默降级(P1-2)、值在下一轮读写一致(稳定不动点)。
|
|
416
|
+
function lastCacheControl(blocks) {
|
|
417
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
418
|
+
const cc = blocks[i] && blocks[i].cache_control;
|
|
419
|
+
if (cc && typeof cc === 'object') return cc;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 从尾部剥离已知注入文本,得到 base blocks。已知文本按长度降序逐个尝试:
|
|
426
|
+
* 尾部 text block 的文本以「\n + 注入文本」结尾、或整个 block 等于注入文本时剥除。
|
|
427
|
+
* 剥不出来(用户/CLI 改过 system)→ null(调用方跳过改写,安全降级)。
|
|
428
|
+
*
|
|
429
|
+
* @param {Array} blocks 原始 system 数组
|
|
430
|
+
* @param {string[]} knownTexts 已知注入文本(launch entries + 缓存条目)
|
|
431
|
+
* @returns {Array|null}
|
|
432
|
+
*/
|
|
433
|
+
export function deriveBaseBlocks(blocks, knownTexts) {
|
|
434
|
+
if (!Array.isArray(blocks)) return null;
|
|
435
|
+
const out = blocks.slice();
|
|
436
|
+
const known = (knownTexts || []).filter(t => typeof t === 'string' && t.length > 0)
|
|
437
|
+
.sort((a, b) => b.length - a.length);
|
|
438
|
+
// 迭代到不动点:override+append 并存时注入形态是「override + '\n' + append」——
|
|
439
|
+
// append 在尾部,剥掉它之后 override 才暴露为新尾。单趟扫描只会剥掉尾部那一条,
|
|
440
|
+
// 残留的 override 下一轮再被拼一次(逐轮累积、KV-cache 每请求击穿)。多趟直到
|
|
441
|
+
// 一轮内无任何剥离为止;每个已知文本全程最多剥一次(同一文本不会被注入两次)。
|
|
442
|
+
const used = new Set();
|
|
443
|
+
for (;;) {
|
|
444
|
+
let strippedAny = false;
|
|
445
|
+
for (let i = 0; i < known.length; i++) {
|
|
446
|
+
if (used.has(i) || out.length === 0) continue;
|
|
447
|
+
const last = out[out.length - 1];
|
|
448
|
+
const lastText = blockText(last);
|
|
449
|
+
if (!lastText) break;
|
|
450
|
+
const t = known[i];
|
|
451
|
+
if (lastText === t) {
|
|
452
|
+
// 整个尾 block 就是注入文本 → 移除该 block(保留其余 block 的 cache_control)
|
|
453
|
+
out.pop();
|
|
454
|
+
used.add(i);
|
|
455
|
+
strippedAny = true;
|
|
456
|
+
} else if (lastText.length > t.length && lastText.endsWith(t) &&
|
|
457
|
+
lastText[lastText.length - t.length - 1] === '\n') {
|
|
458
|
+
// 尾 block = base + '\n' + 注入文本 → 剥回 base(保留 block 其余字段如 cache_control)
|
|
459
|
+
const stripped = lastText.slice(0, lastText.length - t.length - 1);
|
|
460
|
+
out[out.length - 1] = { ...last, text: stripped };
|
|
461
|
+
used.add(i);
|
|
462
|
+
strippedAny = true;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (!strippedAny) break;
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 应用固化条目到 system 值。保形态:数组输入 → 数组输出,字符串输入 → 字符串输出。
|
|
472
|
+
* override 与 append 语义不同(对齐启动管线:--system-prompt-file 整段替换、
|
|
473
|
+
* --append-system-prompt-file 追加到默认之后):
|
|
474
|
+
* - override → 真整段替换:只保留 billing-header 前缀块,其余(CLI 官方 persona 等
|
|
475
|
+
* 非 ccv 注入 block)全部移除,override 文本放入带 cache_control 断点的新尾块。
|
|
476
|
+
* - append → 尾部拼接:deriveBase 剥离已知注入后并入尾 block(保留其 cache_control)。
|
|
477
|
+
* 幂等:apply(apply(x)) === apply(x)(序列化相等 → 返回 null)。
|
|
478
|
+
*
|
|
479
|
+
* @param {string|Array} systemValue 原始 body.system
|
|
480
|
+
* @param {{override:string|null, append:string|null}} entry 固化条目
|
|
481
|
+
* @param {string[]} knownTexts 已知注入文本(幂等剥离用)
|
|
482
|
+
* @returns {string|Array|null} 新 system 值;无变化/无法安全应用 → null
|
|
483
|
+
*/
|
|
484
|
+
export function applyLiveSystem(systemValue, entry, knownTexts = []) {
|
|
485
|
+
try {
|
|
486
|
+
if (!entry || (entry.override == null && entry.append == null)) return null;
|
|
487
|
+
if (entry.override != null) {
|
|
488
|
+
return _applyLiveOverride(systemValue, entry.override, entry.append);
|
|
489
|
+
}
|
|
490
|
+
return _applyLiveAppend(systemValue, entry.append, knownTexts);
|
|
491
|
+
} catch (err) {
|
|
492
|
+
reportSwallowed('sys-prompt-live.apply', err);
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* override 整段替换:只保留「CLI 自有的非注入块」——billing-header 前缀块(计费/分类
|
|
499
|
+
* 元数据)与 CLI 官方身份行块(D1:与启动 wire 形态 [billing, identity, persona] 一致,
|
|
500
|
+
* override 会话首请求零改写、KV-cache 复用)——其余 block(第三方 persona / 上一次
|
|
501
|
+
* override 注入)全部替换为 override 文本,放入继承 ephemeral 断点的新尾块(对齐 CLI
|
|
502
|
+
* 把动态段放入缓存断点的行为)。
|
|
503
|
+
* 幂等:输出只依赖(保留块, override文本, 断点),与待替换块无关 → 二次 apply 序列化
|
|
504
|
+
* 相等 → null。无需 knownTexts 剥离:旧 override 随其余块一起被丢,模型切换永不累积。
|
|
505
|
+
*/
|
|
506
|
+
function _applyLiveOverride(systemValue, override, append) {
|
|
507
|
+
// 目标注入文本:override 已含 append 时不重复拼接
|
|
508
|
+
const injected = (append != null && !override.includes(append))
|
|
509
|
+
? override + '\n' + append
|
|
510
|
+
: override;
|
|
511
|
+
if (typeof injected !== 'string' || injected.length === 0) return null;
|
|
512
|
+
|
|
513
|
+
if (typeof systemValue === 'string') {
|
|
514
|
+
// 字符串形态:保留首部的 billing 行与 CLI 官方身份行(与数组形态 D1 一致 —— 真实
|
|
515
|
+
// CLI 恒发数组、字符串形态 0 条,但接入方(SDK/第三方)若发字符串,两形态语义须
|
|
516
|
+
// 一致,避免身份行在字符串形态被误丢)。按 \n 逐行识别保留行,其余整段替换。
|
|
517
|
+
const lines = systemValue.split('\n');
|
|
518
|
+
const keptLines = [];
|
|
519
|
+
let i = 0;
|
|
520
|
+
if (i < lines.length && isBillingHeaderText(lines[i])) { keptLines.push(lines[i]); i++; }
|
|
521
|
+
if (i < lines.length && isCliIdentityText(lines[i])) { keptLines.push(lines[i]); i++; }
|
|
522
|
+
const target = [...keptLines, injected].join('\n');
|
|
523
|
+
return target === systemValue ? null : target;
|
|
524
|
+
}
|
|
525
|
+
if (!Array.isArray(systemValue)) return null;
|
|
526
|
+
|
|
527
|
+
// 保留 billing 前缀块(截首行防多行夹带)与 CLI 官方身份行块,其余替换。
|
|
528
|
+
// 撞车防护(review 第二轮 P1-3):注入文本 injected 若恰命中保留判据(override 文本 ==
|
|
529
|
+
// 官方身份常量、或以 billing 前缀开头),上一轮产出的注入尾块会被误判为「保留块」进
|
|
530
|
+
// kept,同时 injected 又拼一次 → 每请求 +1 块无限累积。判据:块文本恰等于 injected 时
|
|
531
|
+
// 优先视为「上一次注入」(进 dropped 被替换),不进 kept —— 真实的 billing/身份块文本
|
|
532
|
+
// 与 override 注入文本完全相同的概率本就极低(这正是触发累积的同一条件)。
|
|
533
|
+
const kept = [];
|
|
534
|
+
const dropped = [];
|
|
535
|
+
for (const b of systemValue) {
|
|
536
|
+
const t = blockText(b);
|
|
537
|
+
if (b && t === injected) { dropped.push(b); continue; } // 上一次注入 → 待替换,不保留
|
|
538
|
+
if (b && isBillingHeaderText(t)) kept.push(preservedBillingBlock(b));
|
|
539
|
+
else if (b && isCliIdentityText(t)) kept.push(b);
|
|
540
|
+
else dropped.push(b);
|
|
541
|
+
}
|
|
542
|
+
const cc = lastCacheControl(dropped) || lastCacheControl(systemValue) || { type: 'ephemeral' };
|
|
543
|
+
const target = [...kept, { type: 'text', text: injected, cache_control: cc }];
|
|
544
|
+
// 幂等短路:proxy 已整段替换 / 二次 apply → 序列化相等 → null
|
|
545
|
+
if (JSON.stringify(target) === JSON.stringify(systemValue)) return null;
|
|
546
|
+
return target;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* append 尾部拼接:deriveBase 剥离已知注入后并入尾 block(保留其 cache_control)。
|
|
551
|
+
* 与启动管线 --append-system-prompt-file 语义一致(追加到默认之后,不替换)。
|
|
552
|
+
*/
|
|
553
|
+
function _applyLiveAppend(systemValue, append, knownTexts) {
|
|
554
|
+
const injected = append;
|
|
555
|
+
if (typeof injected !== 'string' || injected.length === 0) return null;
|
|
556
|
+
|
|
557
|
+
if (typeof systemValue === 'string') {
|
|
558
|
+
// 字符串形态:deriveBase 剥离已知注入后拼接。deriveBase 对纯 base(无已知注入)
|
|
559
|
+
// 原样返回,二次应用时注入文本被剥掉 → 幂等。
|
|
560
|
+
const baseBlocks = deriveBaseBlocks([{ type: 'text', text: systemValue }], knownTexts);
|
|
561
|
+
if (!baseBlocks) return null;
|
|
562
|
+
const baseText = baseBlocks.map(blockText).join('');
|
|
563
|
+
const target = baseText ? baseText + '\n' + injected : injected;
|
|
564
|
+
return target === systemValue ? null : target;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!Array.isArray(systemValue)) return null;
|
|
568
|
+
const baseBlocks = deriveBaseBlocks(systemValue, knownTexts);
|
|
569
|
+
if (!baseBlocks) return null;
|
|
570
|
+
// 安全降级(对齐 deriveBaseBlocks JSDoc 承诺):system 里本有一条已知注入文本、但
|
|
571
|
+
// 剥离没能去掉它(如 append 不在尾部)→ 再拼接会产生重复注入且成稳定不动点。
|
|
572
|
+
// 判据:derive 后仍有 block 的文本以「\n + t + (\n|结尾)」或等于 t 的形态含某条已知
|
|
573
|
+
// 文本——即它作为**独立段**存在于 block 中。不能用裸「子串包含」——短注入文本可能是
|
|
574
|
+
// base 词的子串(如 'T0' 含于 'BASE'),会误伤。
|
|
575
|
+
if (knownTexts.length > 0) {
|
|
576
|
+
for (const t of knownTexts) {
|
|
577
|
+
if (typeof t !== 'string' || t.length === 0) continue;
|
|
578
|
+
for (const b of baseBlocks) {
|
|
579
|
+
const bt = blockText(b);
|
|
580
|
+
if (!bt) continue;
|
|
581
|
+
if (bt === t) return null; // 整段即注入
|
|
582
|
+
// 作为中间/尾部独立段存在:'\n'+t 且其后是 '\n' 或结尾
|
|
583
|
+
const needle = '\n' + t;
|
|
584
|
+
let from = 0;
|
|
585
|
+
for (;;) {
|
|
586
|
+
const idx = bt.indexOf(needle, from);
|
|
587
|
+
if (idx === -1) break;
|
|
588
|
+
const after = idx + needle.length;
|
|
589
|
+
if (after === bt.length || bt[after] === '\n') return null; // 剥不出 → 放弃改写
|
|
590
|
+
from = idx + 1;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 组装:尾 block 是可并入的 text 块(且非「保留块」——billing 前缀块 / CLI 身份行块)则
|
|
597
|
+
// 把注入文本并入(保留其 cache_control),否则新增尾部 block 并带 cache_control(对齐 CLI
|
|
598
|
+
// 把动态段放入缓存断点的行为)。
|
|
599
|
+
// 先读断点(deriveBaseBlocks 可能已把带断点的尾块弹掉),继承到新尾块而非永久丢失(P1-2)。
|
|
600
|
+
const inheritedCc = lastCacheControl(systemValue);
|
|
601
|
+
const out = baseBlocks.slice();
|
|
602
|
+
const last = out[out.length - 1];
|
|
603
|
+
let newSystem;
|
|
604
|
+
// 保留块(billing 前缀块 + CLI 身份行块)永不并入(review 第二轮 P1-2):
|
|
605
|
+
// - billing:override 后 wire 仅剩 [billing] 时切 append,若并进 billing 块,之后任何
|
|
606
|
+
// override 都会因「前缀匹配」把这段 append 历史当元数据保留 → 永久泄漏。
|
|
607
|
+
// - 身份行:[billing, identity, persona] 切 append 时 persona 被 deriveBaseBlocks 剥离、
|
|
608
|
+
// identity 成新尾块,若并进 identity,切回 override 时 === 精确匹配失配 → 身份块被当
|
|
609
|
+
// persona 丢弃,此后该会话永久失去 CLI 身份块(违背 D1 首请求零改写)。
|
|
610
|
+
if (last && last.type === 'text' && typeof last.text === 'string' &&
|
|
611
|
+
!isBillingHeaderText(last.text) && !isCliIdentityText(last.text)) {
|
|
612
|
+
const merged = { ...last, text: last.text ? last.text + '\n' + injected : injected };
|
|
613
|
+
if (merged.cache_control == null && inheritedCc != null) merged.cache_control = inheritedCc;
|
|
614
|
+
out[out.length - 1] = merged;
|
|
615
|
+
newSystem = out;
|
|
616
|
+
} else {
|
|
617
|
+
newSystem = [...out, { type: 'text', text: injected, cache_control: inheritedCc || { type: 'ephemeral' } }];
|
|
618
|
+
}
|
|
619
|
+
// 无变化检测(幂等短路):序列化比较
|
|
620
|
+
if (JSON.stringify(newSystem) === JSON.stringify(systemValue)) return null;
|
|
621
|
+
return newSystem;
|
|
622
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* "hooks": {
|
|
12
12
|
* "TaskCreated": [{ "hooks": [{ "type": "command", "command": "... task-bridge.js ..." }] }],
|
|
13
13
|
* "TaskCompleted":[{ "hooks": [{ "type": "command", "command": "... task-bridge.js ..." }] }],
|
|
14
|
+
* "UserPromptSubmit": [{ "hooks": [{ "type": "command", ... }] }],
|
|
14
15
|
* "PostToolUse": [{ "matcher": "TaskUpdate", "hooks": [{ "type": "command", ... }] }]
|
|
15
16
|
* }
|
|
16
17
|
*
|
|
@@ -20,13 +21,19 @@
|
|
|
20
21
|
* PostToolUse(TaskUpdate): { hook_event_name: "PostToolUse", session_id, ...,
|
|
21
22
|
* tool_name: "TaskUpdate", tool_input: { taskId, status?, owner?,
|
|
22
23
|
* subject?, description?, activeForm? } }
|
|
24
|
+
* UserPromptSubmit: { hook_event_name, session_id, transcript_path,
|
|
25
|
+
* cwd, prompt, prompt_id, permission_mode, session_title } — NO task_id
|
|
26
|
+
* and agent_id NOT reliably present. The server's task-state reducer uses
|
|
27
|
+
* it as the new-prompt signal to clear the previous turn's checklist
|
|
28
|
+
* (gated on session equality, see shouldResetTasksOnPrompt).
|
|
23
29
|
*
|
|
24
30
|
* Output contract (same as session-start-bridge.js): NOTHING on stdout —
|
|
25
31
|
* PostToolUse interprets stdout starting with "{" as decision JSON, and any
|
|
26
32
|
* stray bytes pollute the hook chain. Optional stderr only when
|
|
27
33
|
* CCVIEWER_DEBUG=1. ALWAYS exit 0: exit code 2 on TaskCreated/TaskCompleted
|
|
28
|
-
* rolls back task creation / prevents completion,
|
|
29
|
-
*
|
|
34
|
+
* rolls back task creation / prevents completion, and on UserPromptSubmit it
|
|
35
|
+
* ERASES the user's submitted prompt — so a failed notify must never surface
|
|
36
|
+
* as a non-zero exit.
|
|
30
37
|
*/
|
|
31
38
|
|
|
32
39
|
import { readFileSync } from 'node:fs';
|
|
@@ -83,7 +90,7 @@ const body = JSON.stringify({
|
|
|
83
90
|
activeForm: toolInput.activeForm ?? null,
|
|
84
91
|
ts: Date.now(),
|
|
85
92
|
});
|
|
86
|
-
debug(`payload event=${hookEventName} tool=${parsed?.tool_name} taskId=${parsed?.task_id ?? toolInput.taskId}`);
|
|
93
|
+
debug(`payload event=${hookEventName} tool=${parsed?.tool_name} taskId=${parsed?.task_id ?? toolInput.taskId} session=${parsed?.session_id} agent=${parsed?.agent_id}`);
|
|
87
94
|
|
|
88
95
|
const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
|
|
89
96
|
const reqOpts = {
|