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/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-DxMkkprW.js"></script>
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.21",
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",
@@ -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
- // 兼容老数据:若文件里仍有 active 字段,读为"全局回退默认";但本模块不再写它。
79
- // <projectDir>/active-profile.json (每 workspace 独占): 仅存 { activeId };
80
- // 切换 active 只影响当前 ccv 进程的 workspace,不污染其他实例。
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 _readWorkspaceActiveId() {
129
+ function _readWorkspaceActive() {
116
130
  const p = _getActiveProfileFilePath();
117
- if (!p) return null;
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 typeof data?.activeId === 'string' ? data.activeId : null;
136
+ return {
137
+ activeId: typeof data?.activeId === 'string' ? data.activeId : null,
138
+ roles: normalizeRoles(data?.roles),
139
+ };
122
140
  }
123
- } catch { }
124
- return null;
141
+ } catch (err) {
142
+ // 损坏的工作区文件会把 active/roles 静默回落默认 —— 属"丢数据"级,必须记账(有节流)
143
+ reportSwallowed('proxy-profile.workspace-read', err);
144
+ }
145
+ return empty;
125
146
  }
126
147
 
127
- function _writeWorkspaceActiveId(activeId) {
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
- const payload = { activeId: (activeId && typeof activeId === 'string') ? activeId : 'max' };
138
- writeFileSync(p, JSON.stringify(payload, null, 2), { mode: 0o600 });
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 wsActive = _readWorkspaceActiveId();
151
- const activeId = wsActive || data.active;
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 —— 全局默认,watchFile 跨实例同步;用作
166
- // UI 在 workspace 文件读失败 / 不存在时的回落,避免"切换后立刻回切"的幽灵 revert
167
- // 回落一致性:其他 ccv 实例如果自己 workspace 文件已存在,_loadProxyProfile 会优先用自己
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 = _writeWorkspaceActiveId(normalizedId);
216
+ // (1) workspace override(合并写)
217
+ result.workspace = _writeWorkspaceActive(activeId, roles);
176
218
 
177
- // (2) profile.json.active —— 幂等更新,老数据兼容 + UI GET 回落兜底
178
- try {
179
- const data = existsSync(PROFILE_PATH)
180
- ? JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'))
181
- : { profiles: [{ id: 'max', name: 'Default' }] };
182
- if (data.active !== normalizedId) {
183
- data.active = normalizedId;
184
- mkdirSync(dirname(PROFILE_PATH), { recursive: true });
185
- writeFileSync(PROFILE_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
186
- }
187
- result.profile = true;
188
- } catch { /* 双失败场景下 result 全 false,由调用方自行兜底 */ }
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
- if (_activeProfile && _activeProfile.baseURL && requestEntry) {
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(_activeProfile.baseURL);
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 (_activeProfile.apiKey && _fetchOpts?.headers) {
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, _activeProfile.apiKey);
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: _activeProfile.name,
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, _activeProfile) : null;
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 (_activeProfile.effort && typeof _fetchOpts?.body === 'string' &&
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, _activeProfile.effort, _hasOutputConfig);
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 = _activeProfile.name;
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
+ }
@@ -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