cc-viewer 1.7.21 → 1.7.22
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 +1 -1
- package/concepts/en/ProxySwitch.md +13 -0
- package/concepts/zh/ProxySwitch.md +13 -0
- package/dist/assets/App-BaKGIW0-.js +2 -0
- package/dist/assets/{MdxEditorPanel-Cd5dGdSB.js → MdxEditorPanel-DG-UE0PC.js} +1 -1
- package/dist/assets/{Mobile-CRQ3UK4l.js → Mobile-CQaUYdaf.js} +1 -1
- package/dist/assets/{ProxyStatsModal-yeAXJE6u.js → ProxyStatsModal-j-Aty9Lu.js} +1 -1
- package/dist/assets/{index-DxMkkprW.js → index-CyiyzBfV.js} +2 -2
- package/dist/assets/{seqResourceLoaders-pADDTbV6.css → seqResourceLoaders-BgD_Rwvc.css} +1 -1
- package/dist/assets/seqResourceLoaders-XMiEIVyV.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +192 -51
- package/server/lib/interceptor-core.js +91 -0
- package/server/lib/v2/adapter.js +1 -1
- package/server/lib/v2/live-feed.js +2 -0
- package/server/lib/v2/meta-rows.js +3 -0
- package/server/lib/v2/v2-writer.js +1 -1
- package/server/proxy.js +39 -8
- package/server/pty-manager.js +6 -0
- package/server/routes/expert.js +47 -2
- package/server/routes/preferences.js +24 -7
- package/src/utils/v3Assembler.js +1 -0
- package/src/utils/v3Rows.js +1 -0
- package/dist/assets/App-DKk75JCV.js +0 -2
- package/dist/assets/seqResourceLoaders-CPq3Zxu0.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-
|
|
24
|
+
<script type="module" crossorigin src="./assets/index-CyiyzBfV.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">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.22",
|
|
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/interceptor.js
CHANGED
|
@@ -7,14 +7,14 @@ const _ccvSkipArgs = ['--version', '-v', '--v', '--help', '-h', 'doctor', 'insta
|
|
|
7
7
|
const _ccvSkip = _ccvSkipArgs.includes(process.argv[2]);
|
|
8
8
|
|
|
9
9
|
import './lib/proxy-env.js';
|
|
10
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, watchFile } from 'node:fs';
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, renameSync, rmSync } from 'node:fs';
|
|
11
11
|
import http from 'node:http';
|
|
12
12
|
import https from 'node:https';
|
|
13
13
|
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 } from './lib/interceptor-core.js';
|
|
17
|
+
import { assembleStreamMessage, createStreamAssembler, isAnthropicApiPath, isMainAgentRequest, replaceTopLevelModel, 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 './lib/error-report.js';
|
|
20
20
|
import { latestMainSessionDir, sessionHasCompletedMainTurn } from './lib/v2/session-select.js';
|
|
@@ -75,12 +75,26 @@ export let _cachedHaikuModel = null;
|
|
|
75
75
|
// Proxy profile hot-switch support
|
|
76
76
|
// 数据模型:
|
|
77
77
|
// profile.json (全局共享): 仅存 profiles 列表,watchFile 跨 ccv 进程同步 CRUD。
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
78
|
+
// active 字段 = main 角色的全局回退默认(setActiveProfileForWorkspace 幂等双写,
|
|
79
|
+
// 供 UI 回落/老数据兼容)。
|
|
80
|
+
// <projectDir>/active-profile.json (每 workspace 独占): { activeId, roles };
|
|
81
|
+
// activeId = main 角色(旧版 ccv 只认这个字段,向后兼容);
|
|
82
|
+
// roles = { subagent, teammate },取值 'follow'(默认) | 'max' | profile id。
|
|
83
|
+
// 旧文件无 roles 字段 → normalizeRoles 归 follow/follow;旧版 ccv 整文件覆盖会丢
|
|
84
|
+
// roles(可接受;新版下次写只把 roles 键以默认 follow 带回,原分配不恢复)。
|
|
85
|
+
// 跨进程并发写是 read-modify-write 无 CAS(同 workspace 双实例同时写不同字段会
|
|
86
|
+
// 丢先写方)——概率极低,已知限制。切换 active/roles 只影响当前 workspace。
|
|
81
87
|
// profile.json 存放在 LOG_DIR 下,受 --log-dir / CCV_LOG_DIR 影响
|
|
82
88
|
const PROFILE_PATH = join(LOG_DIR, 'profile.json');
|
|
83
89
|
let _activeProfile = null; // { id, name, baseURL?, apiKey?, effort?, ANTHROPIC_MODEL?, ANTHROPIC_DEFAULT_OPUS_MODEL?, ANTHROPIC_DEFAULT_SONNET_MODEL?, ANTHROPIC_DEFAULT_HAIKU_MODEL?, activeModel?(legacy) }
|
|
90
|
+
// Per-role state (STORED values, never dormancy-adjusted — dormancy is enforced per request):
|
|
91
|
+
let _roleIds = normalizeRoles(undefined); // { subagent, teammate }
|
|
92
|
+
let _profilesById = new Map(); // id → profile object from profile.json
|
|
93
|
+
|
|
94
|
+
// proxy 属主进程(ccv run / runCliMode / Electron mgmt+tab-worker)同样参与角色分流:
|
|
95
|
+
// proxy.js 在 body 缓冲后按角色选上游 + 供重试引擎做模型替换,本 hook 对 trace 请求
|
|
96
|
+
// 再跑一次同角色改写(URL 幂等、auth 注入是承重的——proxy 链路不碰 apiKey、model 已替换
|
|
97
|
+
// 时 resolveProfileModel 返回 null 即 no-op),两层输入相同所以结果一致,不会双重改写。
|
|
84
98
|
|
|
85
99
|
// ── 代理重试配置(运行时热切换,对齐 profile 模式)──
|
|
86
100
|
// retry-config.json 存全局共享的重试配置(mode/interval/maxRetries/maxConcurrent 等)。
|
|
@@ -112,19 +126,30 @@ function _getActiveProfileFilePath() {
|
|
|
112
126
|
return join(_logDir, 'active-profile.json');
|
|
113
127
|
}
|
|
114
128
|
|
|
115
|
-
function
|
|
129
|
+
function _readWorkspaceActive() {
|
|
116
130
|
const p = _getActiveProfileFilePath();
|
|
117
|
-
|
|
131
|
+
const empty = { activeId: null, roles: normalizeRoles(undefined) };
|
|
132
|
+
if (!p) return empty;
|
|
118
133
|
try {
|
|
119
134
|
if (existsSync(p)) {
|
|
120
135
|
const data = JSON.parse(readFileSync(p, 'utf-8'));
|
|
121
|
-
return
|
|
136
|
+
return {
|
|
137
|
+
activeId: typeof data?.activeId === 'string' ? data.activeId : null,
|
|
138
|
+
roles: normalizeRoles(data?.roles),
|
|
139
|
+
};
|
|
122
140
|
}
|
|
123
|
-
} catch {
|
|
124
|
-
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// 损坏的工作区文件会把 active/roles 静默回落默认 —— 属"丢数据"级,必须记账(有节流)
|
|
143
|
+
reportSwallowed('proxy-profile.workspace-read', err);
|
|
144
|
+
}
|
|
145
|
+
return empty;
|
|
125
146
|
}
|
|
126
147
|
|
|
127
|
-
function
|
|
148
|
+
function _readWorkspaceActiveId() {
|
|
149
|
+
return _readWorkspaceActive().activeId;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _writeWorkspaceActive(activeId, roles) {
|
|
128
153
|
const p = _getActiveProfileFilePath();
|
|
129
154
|
if (!p) {
|
|
130
155
|
// 诊断用:能把"为什么 workspace 路径不可用"暴露到启动 ccv 的终端
|
|
@@ -134,8 +159,20 @@ function _writeWorkspaceActiveId(activeId) {
|
|
|
134
159
|
}
|
|
135
160
|
try {
|
|
136
161
|
mkdirSync(dirname(p), { recursive: true });
|
|
137
|
-
|
|
138
|
-
|
|
162
|
+
// 合并语义:调用方只改 activeId 时必须保留文件里的 roles(反之亦然)——roles 只存
|
|
163
|
+
// 在这个文件里,整文件覆盖会丢角色分配。tmp+rename 原子写:其他 ccv 进程(teammate /
|
|
164
|
+
// 多窗口)经 watchFile 轮询读此文件,不能读到写一半的撕裂内容。
|
|
165
|
+
let existing = null;
|
|
166
|
+
try { if (existsSync(p)) existing = JSON.parse(readFileSync(p, 'utf-8')); } catch { }
|
|
167
|
+
const payload = mergeActivePayload(existing, { activeId, roles });
|
|
168
|
+
const tmp = `${p}.tmp-${process.pid}`;
|
|
169
|
+
try {
|
|
170
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
171
|
+
renameSync(tmp, p);
|
|
172
|
+
} catch (writeErr) {
|
|
173
|
+
try { rmSync(tmp, { force: true }); } catch { } // 不留孤儿 tmp(磁盘满 / 中断场景)
|
|
174
|
+
throw writeErr;
|
|
175
|
+
}
|
|
139
176
|
return true;
|
|
140
177
|
} catch (err) {
|
|
141
178
|
console.error('[ccv proxy-profile] workspace write failed:', p, err && err.message);
|
|
@@ -146,48 +183,56 @@ function _writeWorkspaceActiveId(activeId) {
|
|
|
146
183
|
function _loadProxyProfile() {
|
|
147
184
|
try {
|
|
148
185
|
const data = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'));
|
|
186
|
+
_profilesById = new Map((Array.isArray(data.profiles) ? data.profiles : [])
|
|
187
|
+
.filter(p => p && typeof p.id === 'string').map(p => [p.id, p]));
|
|
149
188
|
// active 解析优先级:workspace override > profile.json.active (兼容老数据 / 全局回退) > null
|
|
150
|
-
const
|
|
151
|
-
|
|
189
|
+
const ws = _readWorkspaceActive();
|
|
190
|
+
_roleIds = ws.roles;
|
|
191
|
+
const activeId = ws.activeId || data.active;
|
|
152
192
|
const active = data.profiles?.find(p => p.id === activeId);
|
|
153
193
|
_activeProfile = (active && active.id !== 'max') ? active : null;
|
|
154
194
|
} catch (err) {
|
|
155
195
|
_activeProfile = null;
|
|
196
|
+
_roleIds = normalizeRoles(undefined);
|
|
197
|
+
_profilesById = new Map();
|
|
156
198
|
if (process.env.CCV_DEBUG_HOTSWITCH) {
|
|
157
199
|
console.error('[ccv hotswitch] _loadProxyProfile failed:', err && err.message);
|
|
158
200
|
}
|
|
159
201
|
}
|
|
160
202
|
}
|
|
161
203
|
|
|
162
|
-
// 为 server.js::POST /api/proxy-profiles 使用,切换当前 workspace 的 active。
|
|
163
|
-
//
|
|
204
|
+
// 为 server.js::POST /api/proxy-profiles 使用,切换当前 workspace 的 active / roles。
|
|
205
|
+
// 合并语义:activeId 或 roles 传 undefined = 保留文件现值(只改角色不动 main,反之亦然)。
|
|
206
|
+
// activeId 有参时同时写两个位置,彼此互为兜底:
|
|
164
207
|
// (1) <logDir>/active-profile.json —— 每 workspace 独占,读取优先级最高
|
|
165
|
-
// (2) profile.json.active ——
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// 返回 { workspace: bool, profile: bool }
|
|
170
|
-
function setActiveProfileForWorkspace(activeId) {
|
|
171
|
-
const normalizedId = (activeId && typeof activeId === 'string') ? activeId : 'max';
|
|
208
|
+
// (2) profile.json.active —— 全局默认;用作 UI 在 workspace 文件读失败 /
|
|
209
|
+
// 不存在时的回落,避免"切换后立刻回切"的幽灵 revert
|
|
210
|
+
// roles-only 调用只写 (1):profile.json 的 mtime bump 不再承担跨进程同步职责
|
|
211
|
+
// (active-profile.json 自己有 watchFile,见 _armWorkspaceActiveWatcher)。
|
|
212
|
+
// 返回 { workspace: bool, profile: bool } 指示两条路径的落盘结果(roles-only 时 profile 恒 false)。
|
|
213
|
+
function setActiveProfileForWorkspace(activeId, roles) {
|
|
172
214
|
const result = { workspace: false, profile: false };
|
|
173
215
|
|
|
174
|
-
// (1) workspace override
|
|
175
|
-
result.workspace =
|
|
216
|
+
// (1) workspace override(合并写)
|
|
217
|
+
result.workspace = _writeWorkspaceActive(activeId, roles);
|
|
176
218
|
|
|
177
|
-
// (2) profile.json.active ——
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
219
|
+
// (2) profile.json.active —— 仅在调用方显式设置 main active 时幂等更新
|
|
220
|
+
if (activeId !== undefined) {
|
|
221
|
+
const normalizedId = (activeId && typeof activeId === 'string') ? activeId : 'max';
|
|
222
|
+
try {
|
|
223
|
+
const data = existsSync(PROFILE_PATH)
|
|
224
|
+
? JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'))
|
|
225
|
+
: { profiles: [{ id: 'max', name: 'Default' }] };
|
|
226
|
+
if (data.active !== normalizedId) {
|
|
227
|
+
data.active = normalizedId;
|
|
228
|
+
mkdirSync(dirname(PROFILE_PATH), { recursive: true });
|
|
229
|
+
writeFileSync(PROFILE_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
230
|
+
}
|
|
231
|
+
result.profile = true;
|
|
232
|
+
} catch { /* 双失败场景下 result 全 false,由调用方自行兜底 */ }
|
|
233
|
+
}
|
|
189
234
|
|
|
190
|
-
_loadProxyProfile(); // 立刻刷新本进程 _activeProfile
|
|
235
|
+
_loadProxyProfile(); // 立刻刷新本进程 _activeProfile / _roleIds
|
|
191
236
|
return result;
|
|
192
237
|
}
|
|
193
238
|
|
|
@@ -201,6 +246,83 @@ function getActiveProfileId() {
|
|
|
201
246
|
} catch { return 'max'; }
|
|
202
247
|
}
|
|
203
248
|
|
|
249
|
+
// 当前 workspace 的**存储**角色分配(不做休眠调整;命名刻意带 Stored —— 与返回有效值的
|
|
250
|
+
// getActiveProfileId 区分,避免下游误以为拿到的是请求时生效值)。GET /api/proxy-profiles
|
|
251
|
+
// 用它回显:休眠(main=Default+官方端点)只是请求侧不生效,存储值必须原样返回,否则 UI
|
|
252
|
+
// 回写会把休眠中的分配清掉(违反"保留但休眠"语义)。
|
|
253
|
+
function getStoredRoles() {
|
|
254
|
+
return _readWorkspaceActive().roles;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 官方端点判定。直接注入模式:首个 API 请求捕获的 _defaultConfig.origin 即真实默认端点
|
|
258
|
+
// (实证优先),未捕获时回退模块加载期从 ANTHROPIC_BASE_URL 提取的 CUSTOM_API_HOST。
|
|
259
|
+
// proxy 属主进程(ccv run / CLI / Electron tab):_defaultConfig 捕获的是**出站** URL——
|
|
260
|
+
// 若首请求挂着第三方 profile,origin 被污染且永不刷新,切回 Default 后休眠将永久失效。
|
|
261
|
+
// 故属主进程由 proxy.js 注册解析器:返回 main=Default 时实际会走的上游(settings/env 默认链),
|
|
262
|
+
// 与 getOriginalBaseUrl(null) 同源。TDZ:CUSTOM_API_HOST 是加载后段才初始化的 const,
|
|
263
|
+
// 本函数只能在模块加载完成后被调用(fetch hook / 路由 / 测试均满足)。
|
|
264
|
+
let _defaultEndpointResolver = null;
|
|
265
|
+
export function setDefaultEndpointResolver(fn) {
|
|
266
|
+
_defaultEndpointResolver = typeof fn === 'function' ? fn : null;
|
|
267
|
+
}
|
|
268
|
+
export function isOfficialDefaultEndpoint() {
|
|
269
|
+
if (_defaultEndpointResolver) {
|
|
270
|
+
try {
|
|
271
|
+
const u = _defaultEndpointResolver();
|
|
272
|
+
if (u && typeof u === 'string') return /(^|\.)anthropic\.com$/i.test(new URL(u).hostname);
|
|
273
|
+
} catch { }
|
|
274
|
+
}
|
|
275
|
+
if (_defaultConfig && typeof _defaultConfig.origin === 'string' && _defaultConfig.origin) {
|
|
276
|
+
try { return /(^|\.)anthropic\.com$/i.test(new URL(_defaultConfig.origin).hostname); } catch { }
|
|
277
|
+
}
|
|
278
|
+
return !CUSTOM_API_HOST || /(^|\.)anthropic\.com$/i.test(CUSTOM_API_HOST);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 请求时的有效角色 profile(含休眠强制)。休眠规则:main=Default(无改写)且端点为官方时,
|
|
282
|
+
// 存储的 sub/teammate 分配不生效(Max 订阅保护;main 切走后原样恢复)。
|
|
283
|
+
function _effectiveRoleProfile(role) {
|
|
284
|
+
const p = resolveRoleProfile(role, _roleIds, _profilesById, _activeProfile);
|
|
285
|
+
if (role !== 'main' && !_activeProfile && isOfficialDefaultEndpoint()) return null;
|
|
286
|
+
return p;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// proxy.js 消费方:属主进程在 body 缓冲后按角色选上游,与 hook 的改写保持同一解析路径。
|
|
290
|
+
function getEffectiveRoleProfile(role) {
|
|
291
|
+
return _effectiveRoleProfile(role);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// proxy.js 分类短路用:存在显式角色分配(非 follow)才值得在属主进程解析 body 分类——
|
|
295
|
+
// 无分配时任意角色都解析到 main 活跃 profile,分类不影响选路。
|
|
296
|
+
function hasExplicitRoleAssignments() {
|
|
297
|
+
return _roleIds.subagent !== 'follow' || _roleIds.teammate !== 'follow';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 测试用:当前各角色的有效 profile(main 恒为 _activeProfile)。
|
|
301
|
+
function getProxyRoleProfiles() {
|
|
302
|
+
return {
|
|
303
|
+
main: _activeProfile,
|
|
304
|
+
subagent: _effectiveRoleProfile('subagent'),
|
|
305
|
+
teammate: _effectiveRoleProfile('teammate'),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// active-profile.json 的 watchFile:roles 只存这个文件,而 roles-only 写入不会 bump
|
|
310
|
+
// profile.json 的 mtime —— 没有本 watcher,teammate 进程与同项目多窗口永远看不到角色变更。
|
|
311
|
+
// watchFile 对不存在的文件也会在创建时触发,与 PROFILE_PATH watcher 行为一致。
|
|
312
|
+
// workspace 切换(initForWorkspace/resetWorkspace)时必须 re-arm,否则监听旧路径。
|
|
313
|
+
let _workspaceActiveWatcherPath = null;
|
|
314
|
+
function _armWorkspaceActiveWatcher() {
|
|
315
|
+
const p = _getActiveProfileFilePath();
|
|
316
|
+
if (p === _workspaceActiveWatcherPath) return;
|
|
317
|
+
if (_workspaceActiveWatcherPath) {
|
|
318
|
+
try { unwatchFile(_workspaceActiveWatcherPath, _loadProxyProfile); } catch { }
|
|
319
|
+
}
|
|
320
|
+
_workspaceActiveWatcherPath = p;
|
|
321
|
+
if (p) {
|
|
322
|
+
try { watchFile(p, { interval: 1500 }, _loadProxyProfile); } catch { }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
204
326
|
// _loadProxyProfile 的初始调用 + watchFile 挂载挪到 _projectName/_logDir 初始化之后
|
|
205
327
|
// (见 "初始化日志文件路径" 段后的 _loadProxyProfile() + watchFile(PROFILE_PATH, …) 挂载),避免 TDZ。
|
|
206
328
|
|
|
@@ -221,7 +343,7 @@ function _replaceProxyAuthHeaders(headers, apiKey) {
|
|
|
221
343
|
return { headers: newHeaders, matchedAuthKey, matchedXApiKey };
|
|
222
344
|
}
|
|
223
345
|
|
|
224
|
-
export { _activeProfile, _defaultConfig, _loadProxyProfile, PROFILE_PATH, setActiveProfileForWorkspace, getActiveProfileId, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState };
|
|
346
|
+
export { _activeProfile, _defaultConfig, _loadProxyProfile, PROFILE_PATH, setActiveProfileForWorkspace, getActiveProfileId, getStoredRoles, getProxyRoleProfiles, getEffectiveRoleProfile, hasExplicitRoleAssignments, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState };
|
|
225
347
|
|
|
226
348
|
// 1.7.0: the v1 single-file write path is retired — logs live in per-session
|
|
227
349
|
// v2 dirs owned by V2Writer. Only the project binding (name + dir) remains
|
|
@@ -440,6 +562,7 @@ _syncContinuationMode(); // seed from the CLI env at module load (`ccv -c`)
|
|
|
440
562
|
// 并挂载 watchFile 同步列表变化。
|
|
441
563
|
_loadProxyProfile();
|
|
442
564
|
try { watchFile(PROFILE_PATH, { interval: 1500 }, _loadProxyProfile); } catch { }
|
|
565
|
+
_armWorkspaceActiveWatcher();
|
|
443
566
|
|
|
444
567
|
// Retry config: initial load + watchFile cross-process sync (UI writes
|
|
445
568
|
// retry-config.json → hot-reloaded within 1.5s, mirroring PROFILE_PATH above).
|
|
@@ -476,6 +599,7 @@ export function initForWorkspace(projectPath, { forceNew = false } = {}) { // es
|
|
|
476
599
|
_forkLaunchMarked = false;
|
|
477
600
|
_resumeLaunchMarked = false;
|
|
478
601
|
_syncContinuationMode();
|
|
602
|
+
_armWorkspaceActiveWatcher(); // 工作区切换 → 监听新路径的 active-profile.json
|
|
479
603
|
_loadProxyProfile(); // 重读该 workspace 的 active-profile.json
|
|
480
604
|
|
|
481
605
|
return { filePath: '', dir, projectName, resumed: false };
|
|
@@ -491,6 +615,7 @@ export function resetWorkspace() {
|
|
|
491
615
|
// cross-workspace leakage into the route's teammateNames snapshot).
|
|
492
616
|
_agentSpawnRegistry.clear();
|
|
493
617
|
_v2Writer.resetSessions(); // wire-v2: empty project → ingest no-ops until re-init
|
|
618
|
+
_armWorkspaceActiveWatcher(); // _projectName 已空 → 摘掉旧路径 watcher
|
|
494
619
|
_loadProxyProfile(); // workspace 上下文消失,回落到 profile.json.active
|
|
495
620
|
}
|
|
496
621
|
|
|
@@ -745,17 +870,31 @@ export function setupInterceptor() {
|
|
|
745
870
|
streamingState.chunksReceived = 0;
|
|
746
871
|
}
|
|
747
872
|
|
|
748
|
-
// Proxy profile request rewriting
|
|
873
|
+
// Proxy profile request rewriting —— 按角色(main/subagent/teammate)解析有效 profile。
|
|
874
|
+
// 经 classifyProxyRole 正向分类(teammate 进程恒 teammate;utility 跟随主角色;
|
|
875
|
+
// subagent 只认 cc_is_subagent=true 阳性标记;同进程 team 成员认 system 团队标记)。
|
|
876
|
+
// 休眠:main=Default 且官方端点时 sub/teammate 分配不生效(存储保留),判定在请求时做
|
|
877
|
+
// (_defaultConfig 首请求才捕获)。
|
|
878
|
+
// 短路:无 main profile 且无任何角色分配时,角色不影响结果(_effProfile 恒 null),
|
|
879
|
+
// 跳过分类的 system 文本提取开销 —— 未配置用户零成本。
|
|
749
880
|
let _fetchUrl = url;
|
|
750
881
|
let _fetchOpts = options;
|
|
751
|
-
|
|
882
|
+
const _proxyRole = (!_activeProfile && _roleIds.subagent === 'follow' && _roleIds.teammate === 'follow')
|
|
883
|
+
? 'main'
|
|
884
|
+
: classifyProxyRole(requestEntry?.body, {
|
|
885
|
+
isTeammate: _isTeammate,
|
|
886
|
+
isCountTokens: !!requestEntry?.isCountTokens,
|
|
887
|
+
isHeartbeat: !!requestEntry?.isHeartbeat,
|
|
888
|
+
});
|
|
889
|
+
const _effProfile = _effectiveRoleProfile(_proxyRole);
|
|
890
|
+
if (_effProfile && _effProfile.baseURL && requestEntry) {
|
|
752
891
|
try {
|
|
753
892
|
// 1. URL 重写: 用 baseURL 替换 origin,智能处理路径重叠
|
|
754
893
|
// baseURL="https://proxy.com/v1" + pathname="/v1/messages" → "https://proxy.com/v1/messages"(去重 /v1)
|
|
755
894
|
// baseURL="https://proxy.com" + pathname="/v1/messages" → "https://proxy.com/v1/messages"(无重叠)
|
|
756
895
|
if (typeof _fetchUrl === 'string') {
|
|
757
896
|
const _origUrl = new URL(_fetchUrl);
|
|
758
|
-
const _baseUrl = new URL(
|
|
897
|
+
const _baseUrl = new URL(_effProfile.baseURL);
|
|
759
898
|
const _basePath = _baseUrl.pathname.replace(/\/+$/, '');
|
|
760
899
|
const _origPath = _origUrl.pathname;
|
|
761
900
|
// 如果原始路径以 baseURL 的路径开头(如都有 /v1/),去掉重叠部分
|
|
@@ -764,11 +903,11 @@ export function setupInterceptor() {
|
|
|
764
903
|
_fetchUrl = _baseUrl.origin + _finalPath + _origUrl.search;
|
|
765
904
|
}
|
|
766
905
|
// 2. Auth 替换 —— 兼容 lowercase / TitleCase,且 x-api-key / Authorization 同时替换以覆盖两种鉴权形式
|
|
767
|
-
if (
|
|
906
|
+
if (_effProfile.apiKey && _fetchOpts?.headers) {
|
|
768
907
|
const h = _fetchOpts.headers;
|
|
769
908
|
if (typeof h === 'object' && !(h instanceof Headers)) {
|
|
770
909
|
const { headers: newHeaders, matchedAuthKey, matchedXApiKey } =
|
|
771
|
-
_replaceProxyAuthHeaders(h,
|
|
910
|
+
_replaceProxyAuthHeaders(h, _effProfile.apiKey);
|
|
772
911
|
_fetchOpts = { ..._fetchOpts, headers: newHeaders };
|
|
773
912
|
|
|
774
913
|
// 诊断日志:让 stderr 能看到替换是否真的发生
|
|
@@ -776,7 +915,8 @@ export function setupInterceptor() {
|
|
|
776
915
|
// (日志聚合/审计规则会把尾 N 字符一并标记为敏感泄漏)
|
|
777
916
|
if (process.env.CCV_DEBUG_HOTSWITCH) {
|
|
778
917
|
console.error('[ccv hotswitch]', {
|
|
779
|
-
profile:
|
|
918
|
+
profile: _effProfile.name,
|
|
919
|
+
role: _proxyRole,
|
|
780
920
|
url: _fetchUrl,
|
|
781
921
|
matchedAuth: matchedAuthKey || '(none)',
|
|
782
922
|
matchedXApiKey: matchedXApiKey || '(none)',
|
|
@@ -795,7 +935,7 @@ export function setupInterceptor() {
|
|
|
795
935
|
// 不能整体复用 requestEntry.body 重建 wire。
|
|
796
936
|
const _rb = requestEntry.body;
|
|
797
937
|
const _oldModel = (_rb && typeof _rb === 'object' && typeof _rb.model === 'string') ? _rb.model : undefined;
|
|
798
|
-
const _targetModel = _oldModel ? resolveProfileModel(_oldModel,
|
|
938
|
+
const _targetModel = _oldModel ? resolveProfileModel(_oldModel, _effProfile) : null;
|
|
799
939
|
if (_targetModel && _fetchOpts?.body) {
|
|
800
940
|
const _replaced = (typeof _fetchOpts.body === 'string')
|
|
801
941
|
? replaceTopLevelModel(_fetchOpts.body, _oldModel, _targetModel)
|
|
@@ -818,17 +958,18 @@ export function setupInterceptor() {
|
|
|
818
958
|
// 且 output_config 对它们无意义。hasOutputConfig 优先用 requestEntry.body(函数级作用域,
|
|
819
959
|
// 行 606 的 body 在此已越界)判定;解析失败退化为截断字符串时,回退到对 wire 串扫描
|
|
820
960
|
// "output_config"(避免误走前插路径产生重复键 → JSON "后者胜" 把注入的 effort 丢掉)。
|
|
821
|
-
if (
|
|
961
|
+
if (_effProfile.effort && typeof _fetchOpts?.body === 'string' &&
|
|
822
962
|
!requestEntry.isCountTokens && !requestEntry.isHeartbeat) {
|
|
823
963
|
const _hasOutputConfig = (_rb && typeof _rb === 'object')
|
|
824
964
|
? (!!_rb.output_config && typeof _rb.output_config === 'object')
|
|
825
965
|
: _fetchOpts.body.includes('"output_config"');
|
|
826
|
-
const _injected = injectOutputConfigEffort(_fetchOpts.body,
|
|
966
|
+
const _injected = injectOutputConfigEffort(_fetchOpts.body, _effProfile.effort, _hasOutputConfig);
|
|
827
967
|
if (_injected !== null) _fetchOpts = { ..._fetchOpts, body: _injected };
|
|
828
968
|
}
|
|
829
|
-
// 记录 proxy
|
|
830
|
-
requestEntry.proxyProfile =
|
|
969
|
+
// 记录 proxy 信息到日志条目(proxyRole 仅在发生改写时打标,与该条目的 proxy 信息绑定)
|
|
970
|
+
requestEntry.proxyProfile = _effProfile.name;
|
|
831
971
|
requestEntry.proxyUrl = _fetchUrl;
|
|
972
|
+
requestEntry.proxyRole = _proxyRole;
|
|
832
973
|
} catch { }
|
|
833
974
|
}
|
|
834
975
|
|
|
@@ -513,3 +513,94 @@ export function injectOutputConfigEffort(jsonStr, effort, hasOutputConfig) {
|
|
|
513
513
|
const insert = `"output_config":{"effort":${JSON.stringify(effort)}}` + (needsComma ? ',' : '');
|
|
514
514
|
return jsonStr.slice(0, i + 1) + insert + jsonStr.slice(i + 1);
|
|
515
515
|
}
|
|
516
|
+
|
|
517
|
+
// ─── Per-role proxy assignment (main / subagent / teammate) ───
|
|
518
|
+
// Storage shape: <projectDir>/active-profile.json = { activeId, roles: { subagent, teammate } }.
|
|
519
|
+
// `activeId` stays the MAIN role id so older ccv versions reading the file keep working;
|
|
520
|
+
// a missing `roles` key (old files) normalizes to follow/follow. Role values:
|
|
521
|
+
// 'follow' (default; sub/teammate only) | 'max' (built-in Default = no rewrite) | profile id.
|
|
522
|
+
// A dangling id (profile deleted later) is kept in storage but resolves to follow at read time.
|
|
523
|
+
|
|
524
|
+
export const PROXY_ROLE_KEYS = ['subagent', 'teammate'];
|
|
525
|
+
|
|
526
|
+
// Positive role classification for the fetch hook. Teammates are detected two ways:
|
|
527
|
+
// separate OS processes carry --agent-name argv (isTeammate, passed by the caller); the
|
|
528
|
+
// newer IN-PROCESS native team members (CC 2.1.x agent teams, no argv) instead carry the
|
|
529
|
+
// team marker in the system prompt (TEAMMATE_SYSTEM_RE — the same KEEP-IN-SYNC trio
|
|
530
|
+
// isMainAgentRequest excludes by; verified against live session blobs). 'subagent' requires
|
|
531
|
+
// the explicit cc_is_subagent=true billing marker (CC ≥ 2.1.181) — older CC subagent
|
|
532
|
+
// requests, compaction and title-generation calls all fall into 'main' (today's behavior)
|
|
533
|
+
// rather than risk being rerouted to the subagent provider. Utility endpoints
|
|
534
|
+
// (count_tokens/heartbeat) follow the process-primary role: main in the leader process,
|
|
535
|
+
// teammate inside a teammate OS process (whose own conversation IS the primary one there).
|
|
536
|
+
export function classifyProxyRole(body, { isTeammate = false, isCountTokens = false, isHeartbeat = false } = {}) {
|
|
537
|
+
if (isTeammate) return 'teammate';
|
|
538
|
+
if (isCountTokens || isHeartbeat) return 'main';
|
|
539
|
+
const sysText = getSystemText(body);
|
|
540
|
+
if (TEAMMATE_SYSTEM_RE.test(sysText)) return 'teammate';
|
|
541
|
+
if (SUBAGENT_BILLING_RE.test(sysText)) return 'subagent';
|
|
542
|
+
return 'main';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Single resolution path from stored role ids to the effective profile object.
|
|
546
|
+
// 'follow' / missing / dangling id → mainProfile; 'max' → null (explicit Default = no rewrite);
|
|
547
|
+
// a listed id → the profile object (or mainProfile when absent from the map).
|
|
548
|
+
export function resolveRoleProfile(role, roleIds, profilesById, mainProfile) {
|
|
549
|
+
if (role === 'main') return mainProfile || null;
|
|
550
|
+
const raw = roleIds && typeof roleIds === 'object' ? roleIds[role] : undefined;
|
|
551
|
+
if (typeof raw !== 'string' || !raw || raw === 'follow') return mainProfile || null;
|
|
552
|
+
if (raw === 'max') return null;
|
|
553
|
+
const hit = profilesById && typeof profilesById.get === 'function' ? profilesById.get(raw) : undefined;
|
|
554
|
+
return hit || mainProfile || null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Lenient shaping for values READ from disk / request bodies: keeps string values for known
|
|
558
|
+
// role keys, drops unknown keys and non-string values, defaults missing keys to 'follow'.
|
|
559
|
+
// Existence of a profile id is NOT enforced here (dangling ids resolve to follow downstream).
|
|
560
|
+
export function normalizeRoles(raw) {
|
|
561
|
+
const out = { subagent: 'follow', teammate: 'follow' };
|
|
562
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
563
|
+
for (const k of PROXY_ROLE_KEYS) {
|
|
564
|
+
if (typeof raw[k] === 'string' && raw[k]) out[k] = raw[k];
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Merge semantics for workspace active-profile.json writes: a field left undefined preserves
|
|
570
|
+
// the stored value, so profile-list CRUD (active-only writes) can never clobber role
|
|
571
|
+
// assignments and role changes never clobber activeId. Per-key role merge: an incoming roles
|
|
572
|
+
// object missing a key preserves the stored one (NOT reset to follow).
|
|
573
|
+
// Note: only { activeId, roles } are carried — unknown future top-level keys are dropped
|
|
574
|
+
// (same blast radius as the old whole-file replace). When neither an incoming activeId nor
|
|
575
|
+
// a stored one exists, the key is OMITTED (not pinned to 'max') so the workspace keeps
|
|
576
|
+
// following the profile.json.active global fallback.
|
|
577
|
+
export function mergeActivePayload(existing, { activeId, roles } = {}) {
|
|
578
|
+
const base = (existing && typeof existing === 'object' && !Array.isArray(existing)) ? existing : {};
|
|
579
|
+
const mergedRoles = normalizeRoles(base.roles);
|
|
580
|
+
if (roles !== undefined) {
|
|
581
|
+
const inc = normalizeRoles(roles);
|
|
582
|
+
for (const k of PROXY_ROLE_KEYS) {
|
|
583
|
+
// normalizeRoles fills missing keys with 'follow'; distinguish "explicitly set to
|
|
584
|
+
// follow" from "absent" by probing the raw incoming object.
|
|
585
|
+
if (roles && typeof roles === 'object' && typeof roles[k] === 'string' && roles[k]) mergedRoles[k] = inc[k];
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const merged = {};
|
|
589
|
+
if (activeId === undefined) {
|
|
590
|
+
if (typeof base.activeId === 'string' && base.activeId) merged.activeId = base.activeId;
|
|
591
|
+
// else: omit → reader falls back to the profile.json.active global default
|
|
592
|
+
} else {
|
|
593
|
+
merged.activeId = (typeof activeId === 'string' && activeId) ? activeId : 'max';
|
|
594
|
+
}
|
|
595
|
+
merged.roles = mergedRoles;
|
|
596
|
+
return merged;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Strict validation for POST-supplied role values: follow/max always allowed; any other string
|
|
600
|
+
// must reference an id present in the INCOMING profiles list (ids are validated against what
|
|
601
|
+
// will be persisted, not the old file).
|
|
602
|
+
export function isValidRoleValue(v, profilesById) {
|
|
603
|
+
if (v === 'follow' || v === 'max') return true;
|
|
604
|
+
if (typeof v !== 'string' || !v) return false;
|
|
605
|
+
return !!(profilesById && typeof profilesById.get === 'function' && profilesById.get(v));
|
|
606
|
+
}
|
package/server/lib/v2/adapter.js
CHANGED
|
@@ -474,7 +474,7 @@ export class SessionSynthesizer {
|
|
|
474
474
|
// client uses req.agent.agentName as the display name when the heuristic
|
|
475
475
|
// registry has nothing (native teammate, cold load).
|
|
476
476
|
...(req.agent && { agent: req.agent }),
|
|
477
|
-
...(req.proxy && { proxyProfile: req.proxy.profile, ...(req.proxy.url && { proxyUrl: req.proxy.url }) }),
|
|
477
|
+
...(req.proxy && { proxyProfile: req.proxy.profile, ...(req.proxy.url && { proxyUrl: req.proxy.url }), ...(req.proxy.role && { proxyRole: req.proxy.role }) }),
|
|
478
478
|
};
|
|
479
479
|
|
|
480
480
|
// ---- messages + envelope ------------------------------------------------
|
|
@@ -684,6 +684,8 @@ export class V2LiveFeed {
|
|
|
684
684
|
agent: parsed.agent,
|
|
685
685
|
model: parsed.body?.model,
|
|
686
686
|
proxyUrl: parsed.proxyUrl || undefined,
|
|
687
|
+
// proxyRole rides the adapter-synthesized entry (same spread-gate as proxyUrl).
|
|
688
|
+
proxyRole: parsed.proxyRole || undefined,
|
|
687
689
|
status: parsed.response?.status,
|
|
688
690
|
duration: parsed.duration,
|
|
689
691
|
usage: usage ? {
|
|
@@ -118,6 +118,9 @@ function foldDir(dir) {
|
|
|
118
118
|
agent: r.agent,
|
|
119
119
|
model: r.model,
|
|
120
120
|
proxyUrl: r.proxy && r.proxy.url ? r.proxy.url : undefined,
|
|
121
|
+
// proxyRole: which role (main/subagent/teammate) the rewrite applied to; only present
|
|
122
|
+
// on rewritten requests (spread-gated, same as proxyUrl). Row-level consumers TBD.
|
|
123
|
+
proxyRole: r.proxy && r.proxy.role ? r.proxy.role : undefined,
|
|
121
124
|
status: d && typeof d.http === 'number' ? d.http : undefined,
|
|
122
125
|
duration: d && typeof d.dur === 'number' ? d.dur : undefined,
|
|
123
126
|
usage,
|
|
@@ -528,7 +528,7 @@ export class V2Writer {
|
|
|
528
528
|
...(convResult && { msgFrom: convResult.msgFrom, msgTo: convResult.msgTo }),
|
|
529
529
|
...(convResult && convResult.evt && { evt: convResult.evt }),
|
|
530
530
|
...(convResult && convResult.boundary && { boundary: convResult.boundary }),
|
|
531
|
-
...(entry.proxyProfile && { proxy: { profile: entry.proxyProfile, ...(entry.proxyUrl && { url: entry.proxyUrl }) } }),
|
|
531
|
+
...(entry.proxyProfile && { proxy: { profile: entry.proxyProfile, ...(entry.proxyUrl && { url: entry.proxyUrl }), ...(entry.proxyRole && { role: entry.proxyRole }) } }),
|
|
532
532
|
});
|
|
533
533
|
|
|
534
534
|
// 4. prompts.jsonl display cache — strictly AFTER the journal line (a
|