cc-viewer 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/cli.js +109 -20
  2. package/dist/assets/App-CKvCavmF.js +2 -0
  3. package/dist/assets/App-Dj37fluO.css +1 -0
  4. package/dist/assets/{MdxEditorPanel-BUubiE6Y.js → MdxEditorPanel-DeSJp_ct.js} +1 -1
  5. package/dist/assets/{Mobile-CKmfRRvo.js → Mobile-CWou-jtF.js} +1 -1
  6. package/dist/assets/{_baseUniq-D9_E5ZDp.js → _baseUniq-BK7Nhp_x.js} +1 -1
  7. package/dist/assets/{arc-D4iFrlhb.js → arc-Cm4HMXz6.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-f1neo7hg.js → architectureDiagram-Q4EWVU46-Co8QCowN.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-DWi9e9tG.js → blockDiagram-DXYQGD6D-Cudecf7x.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-DAdj49A2.js → c4Diagram-AHTNJAMY-4JeFoCHX.js} +1 -1
  11. package/dist/assets/{channel-B3VthT0m.js → channel-K7eT7Hjr.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-BrPcAHV3.js → chunk-4BX2VUAB-BBjl6EVW.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-Bvopc1V9.js → chunk-4TB4RGXK-DBP9YnwO.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-Juw-7Z3i.js → chunk-55IACEB6--A_i9gwR.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-DVYP9tpB.js → chunk-EDXVE4YY-UBsL-3qr.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-VQ7VOXl3.js → chunk-FMBD7UC4-DBO0asmI.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-F8Rj4ZUZ.js → chunk-OYMX7WX6-DOCcEaE6.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-Dn7K3yi9.js → chunk-QZHKN3VN-B-j8QbQh.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-UjO-KTsn.js → chunk-YZCP3GAM-B0WO1U-U.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-Ba3tOmih.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-Ba3tOmih.js +1 -0
  22. package/dist/assets/clone-DjURvTeN.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-BDhNj8rq.js → cose-bilkent-S5V4N54A-DQgpss_a.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-BzdcDuyt.js → dagre-KV5264BT-CCJBKmKv.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DP_SX65N.js → diagram-5BDNPKRD-DgUcnfkU.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-B-pBFw8Q.js → diagram-G4DWMVQ6-CRpVvexw.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-6XSfN1eN.js → diagram-MMDJMWI5-C3IhpsM-.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-DJvGxvpE.js → diagram-TYMM5635-BaVbsrwt.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-CpqdpGYj.js → erDiagram-SMLLAGMA-BxRoBUR8.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-C9m7wjwI.js → flowDiagram-DWJPFMVM-Du2lIzoM.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-CU0XOfY3.js → ganttDiagram-T4ZO3ILL-DR1pDqLK.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-ipyGULnP.js → gitGraphDiagram-UUTBAWPF-DHAmE20O.js} +1 -1
  33. package/dist/assets/{graph-BR2KmES9.js → graph-D8Yv0IU1.js} +1 -1
  34. package/dist/assets/{index-C-be29ey.js → index-AOmFG_I_.js} +1 -1
  35. package/dist/assets/{index-D5tSeMU7.js → index-B6ZWIxY0.js} +1 -1
  36. package/dist/assets/{index-D5YruOKD.js → index-Bq95HjYM.js} +1 -1
  37. package/dist/assets/{index--wgRW_OB.js → index-D0pvxpcl.js} +1 -1
  38. package/dist/assets/{index-DuZQ6FqR.js → index-D2c77__8.js} +1 -1
  39. package/dist/assets/{index-C16fiVhv.js → index-DgtUyP57.js} +1 -1
  40. package/dist/assets/{index-CjsQEQc2.js → index-Dpu92rw_.js} +2 -2
  41. package/dist/assets/{index-OWzSX2T8.js → index-cCB-lhEO.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-BQWQjfFX.js → infoDiagram-42DDH7IO-BAqRIm2G.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js → ishikawaDiagram-UXIWVN3A-CzH4tezb.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-CMbB_4G9.js → journeyDiagram-VCZTEJTY-Qs4eiyuI.js} +1 -1
  45. package/dist/assets/{jszip.min-B6k2t9Ch.js → jszip.min-C0OccDsk.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-CImdlKN3.js → kanban-definition-6JOO6SKY-BAjl0gpV.js} +1 -1
  47. package/dist/assets/{layout-DQl4aw09.js → layout-Cf6cd9xE.js} +1 -1
  48. package/dist/assets/{linear-CLD3aPlK.js → linear-PzdCkji6.js} +1 -1
  49. package/dist/assets/mermaid.core-DV9745Lq.js +7 -0
  50. package/dist/assets/{min-_D86henJ.js → min-CHgWxbzS.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-2JVcu1nk.js → mindmap-definition-QFDTVHPH-CHUjMdpi.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-Dik-m5u7.js → pieDiagram-DEJITSTG-D_r2QC0f.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-Bm5l40Jq.js → quadrantDiagram-34T5L4WZ-DPzGygdN.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-Ck9w5rTj.js → requirementDiagram-MS252O5E-BA2ynnGb.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-BbGVCFEf.js → sankeyDiagram-XADWPNL6-XHVvXuqU.js} +1 -1
  56. package/dist/assets/{seqResourceLoaders-DFrHmgnK.css → seqResourceLoaders-0RXZfUKp.css} +1 -1
  57. package/dist/assets/seqResourceLoaders-kBEzcKLh.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-vjvEdksk.js → sequenceDiagram-FGHM5R23-C47sA8YH.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DTqMoiB2.js → stateDiagram-FHFEXIEX-BOmer839.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js → stateDiagram-v2-QKLJ7IA2-D-kVvTHA.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-Cw8O-52d.js → timeline-definition-GMOUNBTQ-CTAKVZgs.js} +1 -1
  62. package/dist/assets/{vendor-antd-DI7JL-mE.js → vendor-antd-BuJ6oz45.js} +2 -2
  63. package/dist/assets/{vendor-codemirror-B9c49dtM.js → vendor-codemirror-D2KAW4ty.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-B6cpBtIE.js → vendor-mdxeditor-BsFVVUcj.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-Dn90-s-u.js → vendor-qrcode-BpxSq04x.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-CpdSKD_6.js → vendor-virtuoso-an4WvAvS.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-DCuqymb7.js → vennDiagram-DHZGUBPP-CuXzoroh.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-BJ20wJUU.js → wardley-RL74JXVD-CFCmxnck.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-Vz6US3qt.js → wardleyDiagram-NUSXRM2D-gfhDB9SR.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-CzkLKrmi.js → xychartDiagram-5P7HB3ND-X-pj66qI.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/package.json +1 -1
  73. package/server/i18n.js +120 -55
  74. package/server/interceptor.js +72 -2
  75. package/server/lib/ccswitch-import.js +251 -0
  76. package/server/lib/ensure-hooks.js +28 -2
  77. package/server/lib/log-watcher.js +8 -0
  78. package/server/lib/pid-alive.js +15 -0
  79. package/server/lib/proxy-retry.js +716 -0
  80. package/server/lib/session-start-bridge.js +121 -0
  81. package/server/lib/settings-merge.js +170 -0
  82. package/server/lib/v2/live-feed.js +189 -48
  83. package/server/lib/v2/session-owner.js +135 -0
  84. package/server/lib/v2/session-select.js +15 -2
  85. package/server/lib/v2/v2-writer.js +181 -6
  86. package/server/proxy.js +115 -11
  87. package/server/pty-manager.js +28 -4
  88. package/server/routes/events.js +44 -0
  89. package/server/routes/preferences.js +184 -1
  90. package/server/server.js +15 -1
  91. package/src/utils/contentFilter.js +17 -1
  92. package/dist/assets/App-B224r-8M.js +0 -2
  93. package/dist/assets/App-dYPa5-df.css +0 -1
  94. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +0 -1
  95. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +0 -1
  96. package/dist/assets/clone-BQtvy9Ae.js +0 -1
  97. package/dist/assets/mermaid.core-CN35dvKJ.js +0 -7
  98. package/dist/assets/seqResourceLoaders-C-T_EMiO.js +0 -2
@@ -52,6 +52,49 @@ function turnEndNotify(req, res, parsedUrl, isLocal, deps) {
52
52
  });
53
53
  }
54
54
 
55
+ // SessionStart hook notify (session-start-bridge.js): the conversation-switch
56
+ // signal for an in-terminal /resume. Same security shape as turnEndNotify
57
+ // (loopback-only + internal token + 16KB cap); the actual gating on
58
+ // payload.source and the V2Writer re-bind live behind deps.onSessionStartNotify
59
+ // (server.js → interceptor.markSessionStart).
60
+ function sessionStartNotify(req, res, parsedUrl, isLocal, deps) {
61
+ if (!isLocal) {
62
+ res.writeHead(403, { 'Content-Type': 'application/json' });
63
+ res.end(JSON.stringify({ error: 'Loopback only' }));
64
+ return;
65
+ }
66
+ if (req.headers['x-ccviewer-internal'] !== deps.INTERNAL_TOKEN) {
67
+ res.writeHead(403, { 'Content-Type': 'application/json' });
68
+ res.end(JSON.stringify({ error: 'Invalid bridge token' }));
69
+ return;
70
+ }
71
+ let body = '';
72
+ let truncated = false;
73
+ req.on('data', chunk => {
74
+ body += chunk;
75
+ if (body.length > 16384) { truncated = true; req.destroy(); }
76
+ });
77
+ req.on('end', () => {
78
+ if (truncated) {
79
+ console.warn('[session-start-notify] body exceeded 16KB cap — request destroyed');
80
+ return; // socket already closed by destroy()
81
+ }
82
+ let payload = {};
83
+ let badJson = false;
84
+ try { payload = body ? JSON.parse(body) : {}; }
85
+ catch { badJson = true; console.warn('[session-start-notify] malformed JSON body'); }
86
+ if (badJson) {
87
+ res.writeHead(400, { 'Content-Type': 'application/json' });
88
+ res.end(JSON.stringify({ error: 'malformed JSON body' }));
89
+ return;
90
+ }
91
+ try { deps.onSessionStartNotify(payload); }
92
+ catch (err) { reportSwallowed('session-start-notify.dispatch', err); }
93
+ res.writeHead(200, { 'Content-Type': 'application/json' });
94
+ res.end(JSON.stringify({ ok: true }));
95
+ });
96
+ }
97
+
55
98
  // SSE endpoint
56
99
  // 构造「有新版」徽标的 SSE 帧(pending = {version, source})。pending 为空返回 null。
57
100
  // 抽成纯函数便于单测帧格式;events() 在新连接上调用它补推,使版本徽标跨刷新持续显示。
@@ -358,6 +401,7 @@ async function requests(req, res) {
358
401
 
359
402
  export const eventsRoutes = [
360
403
  { method: 'POST', match: 'exact', path: '/api/turn-end-notify', handler: turnEndNotify },
404
+ { method: 'POST', match: 'exact', path: '/api/session-start-notify', handler: sessionStartNotify },
361
405
  { method: 'GET', match: 'exact', path: '/events', handler: events },
362
406
  { method: 'GET', match: 'exact', path: '/api/requests', handler: requests },
363
407
  ];
@@ -3,8 +3,11 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { LOG_DIR, setLogDir, getClaudeConfigDir } from '../../findcc.js';
6
- import { PROFILE_PATH, _defaultConfig, getActiveProfileId, setActiveProfileForWorkspace, _loadProxyProfile } from '../interceptor.js';
6
+ import { PROFILE_PATH, _defaultConfig, getActiveProfileId, setActiveProfileForWorkspace, _loadProxyProfile, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState } from '../interceptor.js';
7
7
  import { migrateProxyProfileList } from '../lib/interceptor-core.js';
8
+ import { DEFAULT_RETRY_CONFIG, validateRetryConfig, resolveRetryConfig } from '../lib/proxy-retry.js';
9
+ import { discoverCcSwitchProviders, mergeImportedProfiles } from '../lib/ccswitch-import.js';
10
+ import { reportSwallowed } from '../lib/error-report.js';
8
11
  import { setLang } from '../i18n.js';
9
12
  import { reconcileVoicePackPrefs as vpReconcile } from '../lib/voice-pack-manager.js';
10
13
  import { readClaudeProjectModel } from '../lib/context-watcher.js';
@@ -299,6 +302,182 @@ function proxyProfilesPost(req, res, parsedUrl, isLocal, deps) {
299
302
  });
300
303
  }
301
304
 
305
+ // ── 代理重试配置(GET/POST,仿 proxyProfilesGet/Post,但无密钥脱敏、无 workspace 隔离)──
306
+ // 返回 { config, defaults }:config = 当前生效(env 基础 + retry-config.json 覆盖后的 live binding);
307
+ // defaults = DEFAULT_RETRY_CONFIG,供 UI「恢复默认」按钮参照。
308
+
309
+ function retryConfigGet(req, res, _parsedUrl, _isLocal, _deps) {
310
+ try {
311
+ // _retryConfigState 是 live binding(watchFile 刷新),即当前生效配置。
312
+ // 缺省兜底:若拦截器尚未初始化(理论上不会,但防御性),即时解析一次。
313
+ const config = _retryConfigState || resolveRetryConfig();
314
+ res.writeHead(200, { 'Content-Type': 'application/json' });
315
+ res.end(JSON.stringify({ config, defaults: DEFAULT_RETRY_CONFIG }));
316
+ } catch {
317
+ res.writeHead(200, { 'Content-Type': 'application/json' });
318
+ res.end(JSON.stringify({ config: DEFAULT_RETRY_CONFIG, defaults: DEFAULT_RETRY_CONFIG }));
319
+ }
320
+ }
321
+
322
+ function retryConfigPost(req, res, _parsedUrl, isLocal, deps) {
323
+ // Local-only, mirroring ccswitchImportPost: retry config is written to a
324
+ // cross-process file every ccv instance hot-reloads, and race/stagger with
325
+ // high concurrency multiplies the HOST's paid upstream requests — a LAN
326
+ // client must not be able to amplify the host's API spend machine-wide.
327
+ if (!isLocal) {
328
+ res.writeHead(403, { 'Content-Type': 'application/json' });
329
+ res.end(JSON.stringify({ ok: false, error: 'retry config is local-only' }));
330
+ return;
331
+ }
332
+ let body = '';
333
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
334
+ req.on('end', () => {
335
+ try {
336
+ const incoming = JSON.parse(body);
337
+ if (!incoming || typeof incoming !== 'object' || !incoming.config || typeof incoming.config !== 'object') {
338
+ res.writeHead(400, { 'Content-Type': 'application/json' });
339
+ res.end(JSON.stringify({ error: 'Invalid retry config: expected { config: {...} }' }));
340
+ return;
341
+ }
342
+ // 校验 + 归一化(与 resolveRetryConfig 共用 validateRetryConfig,单一真源)
343
+ const validated = validateRetryConfig(incoming.config);
344
+ // 合并默认值,保证写盘的是完整配置(缺字段回落默认,避免读到半截配置)
345
+ const toWrite = { ...DEFAULT_RETRY_CONFIG, ...validated };
346
+ const dir = dirname(RETRY_CONFIG_PATH);
347
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
348
+ writeFileSync(RETRY_CONFIG_PATH, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
349
+ // 立即刷新本进程 live binding(不等 watchFile 1.5s 轮询)
350
+ _loadRetryConfigState();
351
+ // SSE 广播给本进程所有客户端(同 proxy_profile 模式)
352
+ sendEventToClients(deps.clients, 'retry_config', { config: _retryConfigState });
353
+ res.writeHead(200, { 'Content-Type': 'application/json' });
354
+ res.end(JSON.stringify({ ok: true }));
355
+ } catch {
356
+ res.writeHead(400, { 'Content-Type': 'application/json' });
357
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
358
+ }
359
+ });
360
+ }
361
+
362
+ // ── cc-switch 导入 ─────────────────────────────────────
363
+ // cc-switch 是 Tauri 桌面应用,把供应商凭证存 SQLite(cc-switch.db providers 表)。
364
+ // 本路由跨平台探测 cc-switch 数据目录,只读查 claude 类型供应商,映射成 cc-viewer profile。
365
+ // GET=预览(不落盘);POST=执行导入(merge 进 profile.json + SSE 广播刷新)。
366
+
367
+ async function ccswitchProvidersGet(req, res, _parsedUrl, isLocal, _deps) {
368
+ try {
369
+ const result = await discoverCcSwitchProviders();
370
+ // 脱敏:非本机不下发明文 apiKey(与 proxyProfilesGet 的 maskProfiles 策略一致)
371
+ const profiles = isLocal ? result.profiles : (result.profiles || []).map(p => ({
372
+ ...p,
373
+ apiKey: p.apiKey ? `${p.apiKey.slice(0, 4)}****` : '',
374
+ }));
375
+ res.writeHead(200, { 'Content-Type': 'application/json' });
376
+ res.end(JSON.stringify({
377
+ profiles,
378
+ currentId: result.currentId || null,
379
+ dbPath: result.dbPath,
380
+ error: result.error,
381
+ }));
382
+ } catch (err) {
383
+ res.writeHead(200, { 'Content-Type': 'application/json' });
384
+ res.end(JSON.stringify({ profiles: [], error: String(err && err.message || err) }));
385
+ }
386
+ }
387
+
388
+ // POST body 可选 { setActive: true } —— 是否把 cc-switch 的 current 设为 cc-viewer active。
389
+ // 默认不设(避免覆盖用户当前选择)。导入只 merge 列表,凭证刷新幂等。
390
+ async function ccswitchImportPost(req, res, _parsedUrl, isLocal, deps) {
391
+ if (!isLocal) {
392
+ res.writeHead(403, { 'Content-Type': 'application/json' });
393
+ res.end(JSON.stringify({ ok: false, error: 'cc-switch import is local-only' }));
394
+ return;
395
+ }
396
+ let body = '';
397
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
398
+ req.on('end', async () => {
399
+ try {
400
+ const incoming = body ? JSON.parse(body) : {};
401
+ const setActive = incoming && incoming.setActive === true;
402
+ const result = await discoverCcSwitchProviders();
403
+ if (result.error && result.profiles.length === 0) {
404
+ res.writeHead(200, { 'Content-Type': 'application/json' });
405
+ res.end(JSON.stringify({ ok: false, error: result.error, imported: 0, updated: 0 }));
406
+ return;
407
+ }
408
+ // Read existing profile.json. A missing file is a legitimate first import,
409
+ // but a file that exists and fails to parse must ABORT: merging into an
410
+ // empty base and writing back would silently wipe every user-created
411
+ // proxy_ profile (existing IS the source of truth here, unlike
412
+ // proxyProfilesPost where the client sends the full desired list).
413
+ let existing = { profiles: [] };
414
+ if (existsSync(PROFILE_PATH)) {
415
+ let parsed = null;
416
+ try {
417
+ parsed = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'));
418
+ } catch (err) {
419
+ reportSwallowed('ccswitch-import.read-existing', err);
420
+ res.writeHead(200, { 'Content-Type': 'application/json' });
421
+ res.end(JSON.stringify({
422
+ ok: false,
423
+ error: 'existing profile.json is unreadable; import aborted to avoid overwriting your profiles',
424
+ imported: 0, updated: 0,
425
+ }));
426
+ return;
427
+ }
428
+ // JSON that parses to a non-object (null, array, number) is just as
429
+ // destructive to merge into — treat it the same as a parse failure.
430
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
431
+ res.writeHead(200, { 'Content-Type': 'application/json' });
432
+ res.end(JSON.stringify({
433
+ ok: false,
434
+ error: 'existing profile.json has an unexpected shape; import aborted to avoid overwriting your profiles',
435
+ imported: 0, updated: 0,
436
+ }));
437
+ return;
438
+ }
439
+ existing = parsed;
440
+ }
441
+ // merge
442
+ const merged = mergeImportedProfiles(existing.profiles || [], result.profiles);
443
+ const toWrite = { ...existing, profiles: merged.profiles };
444
+ // 落盘
445
+ const dir = dirname(PROFILE_PATH);
446
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
447
+ writeFileSync(PROFILE_PATH, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
448
+ // active 处理:setActive=true 且 cc-switch 有 current → 切换;否则保持现状
449
+ let activeChanged = false;
450
+ if (setActive && result.currentId) {
451
+ setActiveProfileForWorkspace(result.currentId);
452
+ activeChanged = true;
453
+ } else {
454
+ _loadProxyProfile(); // 刷新列表(可能增删了)
455
+ }
456
+ // SSE 广播给前端刷新(profile: 'refresh' 是 truthy 哨兵,
457
+ // 触发 AppBase SSE handler 的 if(data.profile) 重新 GET 全量列表)
458
+ const effectiveActive = getActiveProfileId();
459
+ sendEventToClients(deps.clients, 'proxy_profile', {
460
+ active: effectiveActive,
461
+ profile: 'refresh',
462
+ });
463
+ res.writeHead(200, { 'Content-Type': 'application/json' });
464
+ res.end(JSON.stringify({
465
+ ok: true,
466
+ imported: merged.imported,
467
+ updated: merged.updated,
468
+ total: merged.profiles.length,
469
+ activeChanged,
470
+ dbPath: result.dbPath,
471
+ }));
472
+ } catch (err) {
473
+ // ok:false keeps the response contract uniform — the client decides
474
+ // success strictly on data.ok, never on the presence of counter fields.
475
+ res.writeHead(400, { 'Content-Type': 'application/json' });
476
+ res.end(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
477
+ }
478
+ });
479
+ }
480
+
302
481
  export const preferencesRoutes = [
303
482
  { method: 'GET', match: 'exact', path: '/api/preferences', handler: preferencesGet },
304
483
  { method: 'POST', match: 'exact', path: '/api/preferences', handler: preferencesPost },
@@ -306,4 +485,8 @@ export const preferencesRoutes = [
306
485
  { method: 'POST', match: 'exact', path: '/api/claude-settings', handler: claudeSettingsPost },
307
486
  { method: 'GET', match: 'exact', path: '/api/proxy-profiles', handler: proxyProfilesGet },
308
487
  { method: 'POST', match: 'exact', path: '/api/proxy-profiles', handler: proxyProfilesPost },
488
+ { method: 'GET', match: 'exact', path: '/api/retry-config', handler: retryConfigGet },
489
+ { method: 'POST', match: 'exact', path: '/api/retry-config', handler: retryConfigPost },
490
+ { method: 'GET', match: 'exact', path: '/api/ccswitch-providers', handler: ccswitchProvidersGet },
491
+ { method: 'POST', match: 'exact', path: '/api/ccswitch-import', handler: ccswitchImportPost },
309
492
  ];
package/server/server.js CHANGED
@@ -76,11 +76,20 @@ function execWithStdin(cmd, args, input, options) {
76
76
  child.stdin.end();
77
77
  });
78
78
  }
79
- import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText } from './interceptor.js';
79
+ import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, RETRY_CONFIG_PATH, _retryConfigState, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
80
80
  import { V2LiveFeed } from './lib/v2/live-feed.js';
81
81
  import { sanitizePathComponent } from './lib/v2/layout.js';
82
82
  import { maybeResumeConvert } from './lib/v2/convert-manager.js';
83
83
  import { LOG_DIR, setLogDir, getClaudeConfigDir, isBrowserOpenSuppressed } from '../findcc.js';
84
+ import { loadRetryConfig } from './lib/proxy-retry.js';
85
+
86
+ // 代理重试配置:由 interceptor._retryConfigState 提供(live binding,watchFile 热刷新)。
87
+ // mode 默认 off(向后兼容,不重试)。env 是启动默认/兜底,retry-config.json 覆盖 env。
88
+ // 启动日志读一次首屏值;运行时 proxy.js 每请求读 interceptor._retryConfigState 取最新。
89
+ const _startupRetryConfig = _retryConfigState || loadRetryConfig();
90
+ if (_startupRetryConfig.mode !== 'off' && process.env.CCV_DEBUG) {
91
+ console.error(`[CC Viewer] Proxy retry: mode=${_startupRetryConfig.mode}, maxRetries=${_startupRetryConfig.maxRetries}, interval=${_startupRetryConfig.retryIntervalMs}ms`);
92
+ }
84
93
  import { t, getLang, setLang } from './i18n.js';
85
94
  import { loadAuthConfig, loadAuthState, saveAuthConfig, clearProjectOverride, generatePassword, decideAuth, parseCookies, renderLoginPage, localeFromAcceptLanguage } from './lib/auth.js';
86
95
  import { checkAndUpdate } from './lib/updater.js';
@@ -520,6 +529,7 @@ const deps = {
520
529
  get writeToPty() { return _writeToPty; },
521
530
  get onPtyData() { return _onPtyData; },
522
531
  get statsWorker() { return statsWorker; },
532
+ get retryConfig() { return _retryConfigState; },
523
533
  get workspaceLaunched() { return _workspaceLaunched; },
524
534
  setWorkspaceLaunched(v) { _workspaceLaunched = v; },
525
535
  get launchCallback() { return _launchCallback; },
@@ -554,6 +564,9 @@ const deps = {
554
564
  startLogWatch,
555
565
  stopLogWatch,
556
566
  scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
567
+ // SessionStart hook notify → conversation-switch signal (in-terminal
568
+ // /resume). The source gate + V2Writer re-bind live in the interceptor.
569
+ onSessionStartNotify: markSessionStart,
557
570
  ensureImWatch: _ensureImWatch,
558
571
  maskProfiles: _maskProfiles,
559
572
  maskApiKey: _maskApiKey,
@@ -2303,6 +2316,7 @@ async function _doStop() {
2303
2316
  // 早期请求向已关闭的端口 POST 丢包。新 startViewer 的 listen 回调会再次 setLivePort
2304
2317
  setLivePort(null);
2305
2318
  try { unwatchFile(PROFILE_PATH); } catch {} // 清理 interceptor 的 StatWatcher
2319
+ try { unwatchFile(RETRY_CONFIG_PATH); } catch {} // 清理 retry-config 的 StatWatcher
2306
2320
  }
2307
2321
 
2308
2322
  // ─── SDK Mode Exports ──────────────────────────────────────────
@@ -285,6 +285,17 @@ export function isSyntheticPromptText(text) {
285
285
  return false;
286
286
  }
287
287
 
288
+ // UltraPlan 输入识别:UltraPlanModal / CLI /ultraplan 都会把 ultraplanTemplates.js
289
+ // 组装的 <system-reminder>[SCOPED INSTRUCTION]…</system-reminder> 模板前置到用户 prompt。
290
+ // 该 marker 仅由 ultraplanTemplates.js 产出,故可作为「本轮是 UltraPlan 输入」的可靠信号。
291
+ // stripSystemTags 会剥掉该块,所以必须在原始(未剥离)文本上判断;用负向前瞻确保 marker
292
+ // 出现在同一个 <system-reminder>…</system-reminder> 块内部(不跨过闭合标签),避免用户正文里
293
+ // 在某个无关 reminder 之后只是「提到」该短语时误判。
294
+ export function isUltraplanText(text) {
295
+ if (!text || typeof text !== 'string') return false;
296
+ return /<system-reminder>(?:(?!<\/system-reminder>)[\s\S])*?\[SCOPED INSTRUCTION\]/i.test(text);
297
+ }
298
+
288
299
  export function isSystemText(text) {
289
300
  if (!text) return true;
290
301
  const trimmed = text.trim();
@@ -469,7 +480,12 @@ export function classifyUserContent(content) {
469
480
  // 出现在 textBlocks 中;保留 skillBlocks 键以维持返回 shape(ChatView/ImConversationModal 消费)。
470
481
  const skillBlocks = [];
471
482
 
472
- return { commands, textBlocks, skillBlocks, teammateBlocks, taskNotificationBlocks };
483
+ // 本轮是否为 UltraPlan 输入(原始文本仍含 <system-reminder>[SCOPED INSTRUCTION] marker,
484
+ // 此时尚未被 stripSystemTags 剥离)。消费方(ChatView)据此在用户气泡上方渲染 [UltraPlan] 标签。
485
+ const ultraplan = Array.isArray(content)
486
+ && content.some(b => b && b.type === 'text' && isUltraplanText(b.text));
487
+
488
+ return { commands, textBlocks, skillBlocks, teammateBlocks, taskNotificationBlocks, ultraplan };
473
489
  }
474
490
 
475
491
  /**