cc-viewer 1.8.19 → 1.8.21
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 +3 -1
- package/cli.js +33 -4
- package/dist/assets/App-BmZyzd9Q.js +2 -0
- package/dist/assets/App-DOZI5HME.css +1 -0
- package/dist/assets/{MdxEditorPanel-DU_5CRtW.js → MdxEditorPanel-BHvoYOF-.js} +1 -1
- package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
- package/dist/assets/Mobile-C10tOIQo.js +1 -0
- package/dist/assets/ProxyStatsModal-Do4qrHT7.js +1 -0
- package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
- package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
- package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
- package/dist/assets/clone-CFj55h49.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
- package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
- package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
- package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
- package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
- package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
- package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
- package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
- package/dist/assets/index-DyVKoGOX.js +2 -0
- package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
- package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
- package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
- package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
- package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
- package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
- package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
- package/dist/assets/seqResourceLoaders-B-wmkeam.js +2 -0
- package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
- package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
- package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
- package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
- package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
- package/dist/index.html +5 -5
- package/package.json +1 -1
- package/server/interceptor.js +192 -24
- package/server/lib/ask/ask-store.js +7 -3
- package/server/lib/async-file-lock.js +29 -5
- package/server/lib/auth.js +71 -54
- package/server/lib/chat-queue.js +67 -4
- package/server/lib/config-backup.js +8 -5
- package/server/lib/credential-access.js +233 -0
- package/server/lib/credential-migrate.js +147 -0
- package/server/lib/credential-store.js +120 -0
- package/server/lib/credential-vault.js +112 -0
- package/server/lib/file-access-policy.js +16 -0
- package/server/lib/im/im-config.js +81 -31
- package/server/lib/im-deny.js +22 -8
- package/server/lib/json-store.js +264 -0
- package/server/lib/prefs-store.js +8 -33
- package/server/lib/same-origin.js +22 -0
- package/server/lib/session-pin-store.js +7 -14
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/layout.js +28 -3
- package/server/lib/v2/live-feed.js +41 -4
- package/server/lib/v2/resume-list.js +150 -0
- package/server/lib/v2/session-select.js +63 -14
- package/server/lib/v2/v2-writer.js +172 -34
- package/server/lib/v2/view-router.js +83 -0
- package/server/lib/view-root.js +209 -0
- package/server/pty-manager.js +800 -163
- package/server/routes/ask-perm.js +21 -1
- package/server/routes/auth.js +21 -1
- package/server/routes/events.js +123 -7
- package/server/routes/expert.js +46 -14
- package/server/routes/files-content.js +72 -7
- package/server/routes/files-fs.js +28 -3
- package/server/routes/git.js +31 -5
- package/server/routes/preferences.js +59 -46
- package/server/routes/project-meta.js +32 -3
- package/server/routes/proxy-stats.js +33 -5
- package/server/routes/resume.js +302 -0
- package/server/routes/search.js +23 -1
- package/server/routes/skills.js +18 -2
- package/server/routes/v2.js +6 -1
- package/server/routes/workspaces.js +10 -3
- package/server/server.js +199 -25
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
- package/server/workspace-registry.js +41 -35
- package/dist/assets/App-C9Tx6Iqw.js +0 -2
- package/dist/assets/App-M61pqQEx.css +0 -1
- package/dist/assets/Mobile--EECC49-.js +0 -1
- package/dist/assets/ProxyStatsModal-DJu0xq3Z.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
- package/dist/assets/clone-B0_ghF3F.js +0 -1
- package/dist/assets/index-CJdRSHTP.js +0 -2
- package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
- package/dist/assets/seqResourceLoaders-C5UX6ecI.js +0 -2
|
@@ -3,7 +3,7 @@ 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, discoverClaudeExecutables, resolveExplicitClaudePath, CLAUDE_EXECUTABLE_PREF_KEY } from '../../findcc.js';
|
|
6
|
-
import { PROFILE_PATH, _defaultConfig, getActiveProfileId, getStoredRoles, isOfficialDefaultEndpoint, setActiveProfileForWorkspace, _loadProxyProfile, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState } from '../interceptor.js';
|
|
6
|
+
import { PROFILE_PATH, _defaultConfig, getActiveProfileId, getStoredRoles, isOfficialDefaultEndpoint, setActiveProfileForWorkspace, _loadProxyProfile, hydrateProfilesWithApiKeys, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState } from '../interceptor.js';
|
|
7
7
|
import { migrateProxyProfileList, isValidRoleValue, PROXY_ROLE_KEYS } from '../lib/interceptor-core.js';
|
|
8
8
|
import { DEFAULT_RETRY_CONFIG, validateRetryConfig, resolveRetryConfig } from '../lib/proxy/proxy-retry.js';
|
|
9
9
|
import { discoverCcSwitchProviders, mergeImportedProfiles } from '../lib/ccswitch-import.js';
|
|
@@ -15,7 +15,10 @@ import { inspectShellHook } from '../lib/shell-hook-inspect.js';
|
|
|
15
15
|
import { sendEventToClients } from '../lib/log-watcher.js';
|
|
16
16
|
import { listPlatforms } from '../lib/im/im-config.js';
|
|
17
17
|
import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
|
|
18
|
+
import { mutateJsonSync } from '../lib/json-store.js';
|
|
19
|
+
import { persistProfilesApiKeys } from '../lib/credential-access.js';
|
|
18
20
|
import { isAdminReq } from '../lib/is-admin.js';
|
|
21
|
+
import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
|
|
19
22
|
import {
|
|
20
23
|
getCurrentProjectKey, getCurrentProjectName, hasFork, listForks, resolveScoped,
|
|
21
24
|
} from '../lib/project-prefs.js';
|
|
@@ -27,22 +30,6 @@ function stripImConfigs(obj) {
|
|
|
27
30
|
if (obj) for (const id of listPlatforms()) delete obj[id];
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
// Loopback identifies the socket peer, not the browser page that initiated a
|
|
31
|
-
// request. Since the server intentionally has permissive CORS for legacy APIs,
|
|
32
|
-
// sensitive executable-selection endpoints need their own same-origin guard.
|
|
33
|
-
function isSameOriginBrowserRequest(req, parsedUrl) {
|
|
34
|
-
const origin = req.headers?.origin;
|
|
35
|
-
if (!origin) return true; // CLI/native clients do not send Origin.
|
|
36
|
-
if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
|
|
37
|
-
try {
|
|
38
|
-
const originUrl = new URL(origin);
|
|
39
|
-
if (parsedUrl?.origin) return originUrl.origin.toLowerCase() === parsedUrl.origin.toLowerCase();
|
|
40
|
-
return originUrl.host.toLowerCase() === String(req.headers?.host || '').toLowerCase();
|
|
41
|
-
} catch {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
33
|
// /theme 选择器特征:选项文案高特异、不太可能出现在普通生成输出里(ESC 兜底的门控签名)
|
|
47
34
|
const THEME_PICKER_RE = /Auto \(match terminal\)|colorblind-friendly/;
|
|
48
35
|
|
|
@@ -163,12 +150,18 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
163
150
|
// 切日志目录:把旧文件的完整内容(含 auth 密码 / prefsByProject forks / 其它偏好)带到新位置,
|
|
164
151
|
// 避免切目录后密码与各项目 fork 凭空消失;并把写目标在 setLogDir 前后固定为"新文件",让本次
|
|
165
152
|
// 合并只跑在一个文件 / 一把锁上(不再因 LOG_DIR 中途漂移而劈裂进程内锁队列)。
|
|
153
|
+
// logDir 是机器全局、可改变进程数据根的操作(移动 vault 根会影响 LAN 门与代理注入),
|
|
154
|
+
// 因此只允许 admin(本机 loopback 或已鉴权远程 admin)触发——未鉴权远程/项目客户端直接剥掉。
|
|
166
155
|
let targetFile = deps.getPrefsFile();
|
|
167
156
|
let carried = null;
|
|
168
157
|
if (incoming.logDir && typeof incoming.logDir === 'string') {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
158
|
+
if (!isAdminReq(req, isLocal)) {
|
|
159
|
+
delete incoming.logDir; // non-admin cannot move the data root (would open the LAN gate)
|
|
160
|
+
} else {
|
|
161
|
+
carried = readPrefsRaw(targetFile); // 旧文件全量(锁外读,迁移属罕见 admin 操作)
|
|
162
|
+
setLogDir(incoming.logDir);
|
|
163
|
+
targetFile = deps.getPrefsFile();
|
|
164
|
+
}
|
|
172
165
|
}
|
|
173
166
|
// 全局写入:locked + atomic(prefs-store),与 fork 写共用同一把锁,避免并发写互相覆盖含密码的
|
|
174
167
|
// preferences.json。Deep-merge approvalModal 的逻辑下沉到 applyPrefsPatch,与 /api/project-prefs
|
|
@@ -310,14 +303,28 @@ function proxyProfilesGet(req, res, parsedUrl, isLocal, deps) {
|
|
|
310
303
|
if (Array.isArray(data.profiles)) {
|
|
311
304
|
const { profiles: migrated, changed } = migrateProxyProfileList(data.profiles);
|
|
312
305
|
if (changed) {
|
|
313
|
-
data = { ...data, profiles: migrated };
|
|
314
306
|
try {
|
|
315
|
-
|
|
316
|
-
|
|
307
|
+
// Write the migration back while pulling apiKeys into the vault (so the old plaintext is
|
|
308
|
+
// never re-persisted). Read-merge-write INSIDE the lock (mutateJsonSync mutates in place
|
|
309
|
+
// and the kernel writes back the same reference), mutexed with the startup credential-strip
|
|
310
|
+
// and proxyProfilesPost.
|
|
311
|
+
mutateJsonSync(PROFILE_PATH, (current) => {
|
|
312
|
+
const base = (current && typeof current === 'object' && !Array.isArray(current)) ? current : {};
|
|
313
|
+
const stripped = persistProfilesApiKeys(migrated, null, { isMaskedFn: () => false });
|
|
314
|
+
base.profiles = stripped;
|
|
315
|
+
return base;
|
|
316
|
+
}, { mode: 0o600, strictCorrupt: true, fallback: {} });
|
|
317
|
+
data = { ...data, profiles: migrated };
|
|
317
318
|
_loadProxyProfile();
|
|
318
319
|
} catch { /* 迁移落盘失败不阻塞 GET;下次仍会尝试 */ }
|
|
319
320
|
}
|
|
320
321
|
}
|
|
322
|
+
// profile.json no longer carries apiKeys (they live in the credential vault) — decrypt-backfill
|
|
323
|
+
// them onto the list for serving. The local admin gets plaintext to view/copy; a remote caller
|
|
324
|
+
// is masked by deps.maskProfiles below (same as before).
|
|
325
|
+
if (Array.isArray(data.profiles)) {
|
|
326
|
+
data = { ...data, profiles: hydrateProfilesWithApiKeys(data.profiles) };
|
|
327
|
+
}
|
|
321
328
|
// 用 interceptor.getActiveProfileId() 返回 effective active(workspace > profile.json.active > 'max')
|
|
322
329
|
const effectiveActive = getActiveProfileId();
|
|
323
330
|
// 本机(127.0.0.1)= admin:下发明文 profile.apiKey 供本人在编辑表单(👁 折叠)里查阅/复制;已授权
|
|
@@ -353,22 +360,21 @@ function proxyProfilesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
353
360
|
if (!incoming.profiles.some(p => p.id === 'max')) {
|
|
354
361
|
incoming.profiles = [{ id: 'max', name: 'Default' }, ...(incoming.profiles || [])];
|
|
355
362
|
}
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
writeFileSync(PROFILE_PATH, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
|
|
363
|
+
// Only the profiles list is written to profile.json; `active` is no longer persisted here
|
|
364
|
+
// (avoids cross-process clobbering). The existing file's `active` field is preserved so an
|
|
365
|
+
// older ccv or a hand editor can still fall back to it. Read-merge-write INSIDE the lock
|
|
366
|
+
// (mutateJsonSync), mutexed with the startup credential-strip and setActiveProfileForWorkspace —
|
|
367
|
+
// an unlocked snapshot would let persistProfilesApiKeys' deletion loop remove concurrently-added
|
|
368
|
+
// vault entries and overwrite the whole list.
|
|
369
|
+
// apiKey 抽出到凭证 vault(密文存 credentials.json),profile.json 不再携带 key。
|
|
370
|
+
// masked 回显 → 保留该 profile 的既有 vault 条目(不动、不回写 sentinel);
|
|
371
|
+
// 新明文 → 写 vault;空 → 删该条目;被移除的 profile(全量替换端点)→ 删其 vault 条目。
|
|
372
|
+
mutateJsonSync(PROFILE_PATH, (current) => {
|
|
373
|
+
const existing = (current && typeof current === 'object' && !Array.isArray(current)) ? current : {};
|
|
374
|
+
const strippedProfiles = persistProfilesApiKeys(incoming.profiles, existing.profiles, { isMaskedFn: deps.isMasked });
|
|
375
|
+
existing.profiles = strippedProfiles;
|
|
376
|
+
return existing;
|
|
377
|
+
}, { mode: 0o600, strictCorrupt: true, fallback: {} });
|
|
372
378
|
// 角色分配校验:只认 subagent/teammate 两个 key(roles.main 之类的杂键直接丢弃);
|
|
373
379
|
// 值必须是 follow / max / 入参 profiles 里存在的 id(按将落盘的列表校验,而非旧文件),
|
|
374
380
|
// 非法值归 'follow'(宽容风格,不 400)。被删 profile 的悬空角色由读取时归 follow 兜底。
|
|
@@ -541,13 +547,20 @@ async function ccswitchImportPost(req, res, _parsedUrl, isLocal, deps) {
|
|
|
541
547
|
}
|
|
542
548
|
existing = parsed;
|
|
543
549
|
}
|
|
544
|
-
// merge
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
550
|
+
// Persist: locked read-merge-write (mutateJsonSync), mutexed with the other profile.json
|
|
551
|
+
// writers. The merge MUST be recomputed inside the lock against the CURRENT on-disk list —
|
|
552
|
+
// computing it outside from a pre-lock snapshot and overwriting the whole list would erase a
|
|
553
|
+
// proxy-profiles POST that lands in that window. apiKeys are pulled into the vault; merge
|
|
554
|
+
// semantics never delete existing vault entries (no existingProfiles passed). cc-switch imports
|
|
555
|
+
// fresh plaintext keys (not a masked echo), so isMaskedFn is always false. `merged` is read
|
|
556
|
+
// back out of the lock for the response counts.
|
|
557
|
+
let merged = null;
|
|
558
|
+
mutateJsonSync(PROFILE_PATH, (current) => {
|
|
559
|
+
const base = (current && typeof current === 'object' && !Array.isArray(current)) ? current : {};
|
|
560
|
+
merged = mergeImportedProfiles(base.profiles || [], result.profiles);
|
|
561
|
+
base.profiles = persistProfilesApiKeys(merged.profiles, null, { isMaskedFn: () => false });
|
|
562
|
+
return base;
|
|
563
|
+
}, { mode: 0o600, strictCorrupt: true, fallback: {} });
|
|
551
564
|
// active 处理:setActive=true 且 cc-switch 有 current → 切换;否则保持现状
|
|
552
565
|
let activeChanged = false;
|
|
553
566
|
if (setActive && result.currentId) {
|
|
@@ -4,8 +4,32 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { PACKAGE_JSON } from '../_paths.js';
|
|
5
5
|
import { LOG_DIR } from '../../findcc.js';
|
|
6
6
|
import { _projectName } from '../interceptor.js';
|
|
7
|
+
import { sanitizePathComponent } from '../lib/v2/layout.js';
|
|
8
|
+
import { resolveViewRoot } from '../lib/view-root.js';
|
|
9
|
+
import { loadWorkspaces } from '../workspace-registry.js';
|
|
10
|
+
import { listLivePtys } from '../pty-manager.js';
|
|
7
11
|
import { detectHomebrewInstall } from '../lib/updater.js';
|
|
8
12
|
|
|
13
|
+
// Multi-project (2026-10, review P2-6): stats reads for a VIEWED project must
|
|
14
|
+
// not become a presence oracle over LOG_DIR — `?project=` is accepted ONLY
|
|
15
|
+
// when it resolves to a registered workspace / live PTY (or the bound
|
|
16
|
+
// project), never an arbitrary sanitized name.
|
|
17
|
+
function _resolveStatsName(projectParam, res) {
|
|
18
|
+
if (!projectParam) return _projectName || '';
|
|
19
|
+
const r = resolveViewRoot({
|
|
20
|
+
projectParam,
|
|
21
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
22
|
+
loadWorkspaces,
|
|
23
|
+
listLivePtys,
|
|
24
|
+
});
|
|
25
|
+
if (!r.ok) {
|
|
26
|
+
res.writeHead(r.status, { 'Content-Type': 'application/json' });
|
|
27
|
+
res.end(JSON.stringify({ error: r.error }));
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return sanitizePathComponent(projectParam);
|
|
31
|
+
}
|
|
32
|
+
|
|
9
33
|
// 判定当前 cc-viewer 的安装渠道,供前端精准匹配升级命令。
|
|
10
34
|
// - electron:桌面版(in-process server),走 GitHub Releases 重新下载安装包。
|
|
11
35
|
// - brew:Homebrew Cellar 布局命中 → `brew upgrade cc-viewer`(npm install -g 会跟 Cellar 打架)。
|
|
@@ -44,14 +68,19 @@ function versionInfo(req, res) {
|
|
|
44
68
|
}
|
|
45
69
|
}
|
|
46
70
|
|
|
47
|
-
function projectStats(req, res) {
|
|
71
|
+
function projectStats(req, res, parsedUrl) {
|
|
48
72
|
try {
|
|
49
|
-
|
|
73
|
+
// Multi-project (2026-10): project-stats resolves the VIEWED project
|
|
74
|
+
// (?project=, registered/live only — never an arbitrary name).
|
|
75
|
+
const projectParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('project') : null;
|
|
76
|
+
const name = _resolveStatsName(projectParam, res);
|
|
77
|
+
if (name === null) return; // error already replied
|
|
78
|
+
if (!name) {
|
|
50
79
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
51
80
|
res.end(JSON.stringify({ error: 'No project name' }));
|
|
52
81
|
return;
|
|
53
82
|
}
|
|
54
|
-
const statsFile = join(LOG_DIR,
|
|
83
|
+
const statsFile = join(LOG_DIR, name, `${name}.json`);
|
|
55
84
|
if (!existsSync(statsFile)) {
|
|
56
85
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
57
86
|
res.end(JSON.stringify({ error: 'Stats file not found' }));
|
|
@@ -10,17 +10,45 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { LOG_DIR } from '../../findcc.js';
|
|
12
12
|
import { _projectName } from '../interceptor.js';
|
|
13
|
+
import { sanitizePathComponent } from '../lib/v2/layout.js';
|
|
14
|
+
import { resolveViewRoot } from '../lib/view-root.js';
|
|
15
|
+
import { loadWorkspaces } from '../workspace-registry.js';
|
|
16
|
+
import { listLivePtys } from '../pty-manager.js';
|
|
13
17
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
18
|
+
// Multi-project (2026-10, review P2-6): stats reads for a VIEWED project must
|
|
19
|
+
// not become a presence oracle over LOG_DIR — `?project=` is accepted ONLY
|
|
20
|
+
// when it resolves to a registered workspace / live PTY (or the bound
|
|
21
|
+
// project), never an arbitrary sanitized name.
|
|
22
|
+
function _resolveStatsName(projectParam, res) {
|
|
23
|
+
if (!projectParam) return _projectName || '';
|
|
24
|
+
const r = resolveViewRoot({
|
|
25
|
+
projectParam,
|
|
26
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
27
|
+
loadWorkspaces,
|
|
28
|
+
listLivePtys,
|
|
29
|
+
});
|
|
30
|
+
if (!r.ok) {
|
|
31
|
+
res.writeHead(r.status, { 'Content-Type': 'application/json' });
|
|
32
|
+
res.end(JSON.stringify({ error: r.error }));
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return sanitizePathComponent(projectParam);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// GET /api/proxy-stats — returns the VIEWED project's proxy retry stats
|
|
39
|
+
// (?project=, registered/live only; the log store is name-keyed, not fs-dir).
|
|
40
|
+
// Response body: { proxyStats: {...} }; null when there is no data.
|
|
41
|
+
function getProxyStats(req, res, parsedUrl) {
|
|
17
42
|
try {
|
|
18
|
-
|
|
43
|
+
const projectParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('project') : null;
|
|
44
|
+
const name = _resolveStatsName(projectParam, res);
|
|
45
|
+
if (name === null) return; // error already replied
|
|
46
|
+
if (!name) {
|
|
19
47
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
20
48
|
res.end(JSON.stringify({ error: 'No project name', proxyStats: null }));
|
|
21
49
|
return;
|
|
22
50
|
}
|
|
23
|
-
const statsFile = join(LOG_DIR,
|
|
51
|
+
const statsFile = join(LOG_DIR, name, `${name}.json`);
|
|
24
52
|
if (!existsSync(statsFile)) {
|
|
25
53
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
26
54
|
res.end(JSON.stringify({ proxyStats: null }));
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// /resume feature routes (2026-10).
|
|
2
|
+
//
|
|
3
|
+
// GET /api/resume-sessions?limit=5
|
|
4
|
+
// Recent-session list for the CURRENT project only (ccv's own v2 store).
|
|
5
|
+
// Powers the header's current-project hover dropdown. Read-only; no
|
|
6
|
+
// loopback gate (view state).
|
|
7
|
+
//
|
|
8
|
+
// GET /api/live-processes
|
|
9
|
+
// Live main claude PTYs this server owns (one per activated project), for
|
|
10
|
+
// the Header's parallel-project chips. Read-only.
|
|
11
|
+
//
|
|
12
|
+
// POST /api/live-processes/attach { project }
|
|
13
|
+
// Attach the main view's PTY stream to that project's record (multi-PTY
|
|
14
|
+
// view switch, 2026-10). Idempotent; never spawns/kills. 404 when the
|
|
15
|
+
// project has no live record (caller degrades silently).
|
|
16
|
+
//
|
|
17
|
+
// POST /api/live-processes/close { project }
|
|
18
|
+
// Kill that project's main PTY (the web header tab's × button). Admin-only
|
|
19
|
+
// (loopback or authenticated remote admin); 403 otherwise, 404 when the
|
|
20
|
+
// project has no record. The record is kept for a later re-launch.
|
|
21
|
+
//
|
|
22
|
+
// POST /api/resume-session { sessionUuid, project?, instanceKey? }
|
|
23
|
+
// TRUE /resume (2026-10-06): inject `/resume <uuid>` into the target
|
|
24
|
+
// project's running claude PTY so the process switches INTO that session
|
|
25
|
+
// in place (Claude Code's native SessionStart hook then re-binds the v2
|
|
26
|
+
// writer). Destructive-ish (re-points a live conversation), so admin +
|
|
27
|
+
// same-origin gated like /close. 409 busy when the TUI isn't observably
|
|
28
|
+
// idle, 409 ambiguous when a project-only target has 2+ live instances,
|
|
29
|
+
// 404 when the target isn't a live claude PTY, 400 on a bad uuid.
|
|
30
|
+
//
|
|
31
|
+
// Boundary note (verify:boundaries): routes (L3) may import lib/ (L1) and
|
|
32
|
+
// interceptor (L2) per the existing house pattern (session-pin.js / logs.js).
|
|
33
|
+
// The aggregation itself lives in lib/v2/resume-list.js (pure, unit-tested);
|
|
34
|
+
// this handler is a thin HTTP wrapper.
|
|
35
|
+
|
|
36
|
+
import { LOG_DIR } from '../../findcc.js';
|
|
37
|
+
import { _projectName, _v2Writer } from '../interceptor.js';
|
|
38
|
+
import { listResumeSessions } from '../lib/v2/resume-list.js';
|
|
39
|
+
import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
|
|
40
|
+
import { isAdminReq } from '../lib/is-admin.js';
|
|
41
|
+
import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
|
|
42
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
43
|
+
|
|
44
|
+
function sendJson(res, code, obj) {
|
|
45
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
46
|
+
res.end(JSON.stringify(obj));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getSessionsHandler(req, res, parsedUrl) {
|
|
50
|
+
// parsedUrl is a URL object (server.js handleRequest) — read via searchParams.
|
|
51
|
+
const limitRaw = Number(parsedUrl.searchParams.get('limit'));
|
|
52
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 50) : 10;
|
|
53
|
+
try {
|
|
54
|
+
// Multi-project (2026-10): the viewed tab's hover dropdown may ask for the
|
|
55
|
+
// VIEWED project's sessions via ?project= (name-sanitized by
|
|
56
|
+
// listResumeSessions itself); absent ⇒ the bound project (the header's
|
|
57
|
+
// current-project dropdown, the pre-multi-project behavior).
|
|
58
|
+
const projectParam = parsedUrl.searchParams.get('project');
|
|
59
|
+
const project = (typeof projectParam === 'string' && projectParam) ? projectParam : (_projectName || '');
|
|
60
|
+
const { items, total } = listResumeSessions(LOG_DIR, { project, limit });
|
|
61
|
+
// currentSessionUuid: the single live session the main process is running.
|
|
62
|
+
// The client marks exactly ONE row "current" (its sessionUuid === this).
|
|
63
|
+
// Null before the first sid-bearing request.
|
|
64
|
+
let currentSessionUuid = null;
|
|
65
|
+
try { currentSessionUuid = _v2Writer && typeof _v2Writer.currentSessionId === 'function' ? _v2Writer.currentSessionId() : null; } catch (err) { reportSwallowed('resume-route.current-sid', err); }
|
|
66
|
+
sendJson(res, 200, { items, total, currentSessionUuid });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
reportSwallowed('resume-route.list', err);
|
|
69
|
+
sendJson(res, 500, { error: 'list failed' });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// GET /api/live-processes — enumerate the LIVE main claude PTYs this server owns
|
|
74
|
+
// (one per activated project, kept alive across project switches), for the Header's
|
|
75
|
+
// parallel-project chips. Each entry: { kind:'main', project, cwd, pid, active }.
|
|
76
|
+
// `project` is derived from cwd via projectKeyForCwd (same mapping the interceptor
|
|
77
|
+
// uses for write routing). Read-only; a probe failure degrades to an empty list.
|
|
78
|
+
async function getLiveProcessesHandler(req, res, parsedUrl) {
|
|
79
|
+
const processes = [];
|
|
80
|
+
let currentInstanceKey = null;
|
|
81
|
+
try {
|
|
82
|
+
const { listLivePtys } = await import('../pty-manager.js');
|
|
83
|
+
const live = listLivePtys();
|
|
84
|
+
// Bound project's own instanceKey (pure-client viewing, 2026-10-07): the identity of
|
|
85
|
+
// the BOUND project — the live record whose cwd equals the server's bound root. This
|
|
86
|
+
// is per-server and single-valued, deliberately NOT the shared `active` pointer (that
|
|
87
|
+
// is the terminal attachment, which lags and differs per client). The header uses it
|
|
88
|
+
// to single out THE bound tab among same-basename tabs without reading `active`.
|
|
89
|
+
const boundCwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
90
|
+
for (const p of live) {
|
|
91
|
+
// Multi-project (2026-10, review P1): only CLAUDE main PTYs become project
|
|
92
|
+
// tabs. A scratch/$EDITOR shell (ptyKind 'shell') in a differently-named
|
|
93
|
+
// cwd would otherwise surface as a closable "project" tab and flip the
|
|
94
|
+
// Header into the multi-project tab-bar form on its own — violating the
|
|
95
|
+
// single-project parity contract.
|
|
96
|
+
if (p.ptyKind && p.ptyKind !== 'claude') continue;
|
|
97
|
+
if (!currentInstanceKey && p.instanceKey && (p.cwd || '') === boundCwd) {
|
|
98
|
+
currentInstanceKey = p.instanceKey;
|
|
99
|
+
}
|
|
100
|
+
processes.push({
|
|
101
|
+
kind: 'main',
|
|
102
|
+
project: projectKeyForCwd(p.cwd),
|
|
103
|
+
cwd: p.cwd,
|
|
104
|
+
pid: p.pid,
|
|
105
|
+
active: p.isActive === true,
|
|
106
|
+
// Multi-instance (2026-10-06): surface the per-process instanceKey so the web tab bar
|
|
107
|
+
// can tell two concurrent same-cwd instances apart and target attach/close precisely.
|
|
108
|
+
instanceKey: p.instanceKey || null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} catch (err) { reportSwallowed('resume-route.live-main', err); }
|
|
112
|
+
sendJson(res, 200, { processes, currentProject: _projectName || '', currentInstanceKey });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// POST /api/live-processes/attach { project } — move the main view's PTY
|
|
116
|
+
// attachment to the given project's record. Powers the terminal panel after a
|
|
117
|
+
// chip view-switch / [+] launch: without it the shared /ws/terminal stream
|
|
118
|
+
// stays pinned to the last-spawned project and the viewed project's terminal
|
|
119
|
+
// shows the wrong screen (or never starts). Idempotent: re-attaching the
|
|
120
|
+
// already-active project is a no-op; unknown projects get 404 and nothing
|
|
121
|
+
// changes. Body capped via deps.MAX_POST_BODY like the other POST routes.
|
|
122
|
+
function postLiveProcessAttachHandler(req, res, parsedUrl, isLocal, deps) {
|
|
123
|
+
let body = '';
|
|
124
|
+
req.on('data', (chunk) => { body += chunk; if (deps && body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
125
|
+
req.on('end', async () => {
|
|
126
|
+
let project = '';
|
|
127
|
+
let instanceKey = '';
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(body || '{}');
|
|
130
|
+
project = typeof parsed.project === 'string' ? parsed.project : '';
|
|
131
|
+
instanceKey = typeof parsed.instanceKey === 'string' ? parsed.instanceKey : '';
|
|
132
|
+
} catch {
|
|
133
|
+
sendJson(res, 400, { ok: false, reason: 'bad-json' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!project && !instanceKey) {
|
|
137
|
+
sendJson(res, 400, { ok: false, reason: 'missing-project' });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const { attachPtyFor } = await import('../pty-manager.js');
|
|
142
|
+
const r = attachPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
|
|
143
|
+
if (!r.ok) {
|
|
144
|
+
// Multi-instance: an ambiguous project-name-only attach (2+ live same-basename
|
|
145
|
+
// instances) is a 409 with the live candidates, so the client re-issues with an
|
|
146
|
+
// instanceKey instead of silently pinning an arbitrary process.
|
|
147
|
+
if (r.reason === 'ambiguous') {
|
|
148
|
+
sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: r.candidates || [] });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
sendJson(res, 404, { ok: false, reason: r.reason || 'not-found' });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, running: r.running, ptyKind: r.ptyKind, exitCode: r.exitCode, switched: r.switched });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
reportSwallowed('resume-route.attach', err);
|
|
157
|
+
sendJson(res, 500, { ok: false, reason: 'attach-failed' });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// POST /api/live-processes/close { project } — kill that project's main PTY
|
|
163
|
+
// (the web multi-project tab bar's × button, 2026-10). Destructive, so unlike
|
|
164
|
+
// the read-only routes above this is admin-gated: loopback or an authenticated
|
|
165
|
+
// remote admin (isAdminReq; killing another user's process over LAN must not be
|
|
166
|
+
// possible with a mere view token). 404 when the project has no record. When
|
|
167
|
+
// the killed PTY was the terminal's active attachment, the server re-anchors
|
|
168
|
+
// the attachment to another live record internally (killedActive in the
|
|
169
|
+
// response); the client decides its own view repair from the remaining tab
|
|
170
|
+
// list (it does not need the re-anchor target).
|
|
171
|
+
function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
172
|
+
// Two gates, both required: admin identity (loopback or authenticated remote
|
|
173
|
+
// admin) AND same-origin browser context. The server intentionally answers
|
|
174
|
+
// CORS preflight permissively for legacy APIs, so without the second gate a
|
|
175
|
+
// cross-site page could kill a project's process through the user's browser
|
|
176
|
+
// (their token/cookie rides along). Same guard the executable-selection
|
|
177
|
+
// endpoints in routes/preferences.js stack on top of isAdminReq.
|
|
178
|
+
if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
179
|
+
sendJson(res, 403, { ok: false, reason: 'forbidden' });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
let body = '';
|
|
183
|
+
req.on('data', (chunk) => { body += chunk; if (deps && body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
184
|
+
req.on('end', async () => {
|
|
185
|
+
let project = '';
|
|
186
|
+
let instanceKey = '';
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(body || '{}');
|
|
189
|
+
project = typeof parsed.project === 'string' ? parsed.project : '';
|
|
190
|
+
instanceKey = typeof parsed.instanceKey === 'string' ? parsed.instanceKey : '';
|
|
191
|
+
} catch {
|
|
192
|
+
sendJson(res, 400, { ok: false, reason: 'bad-json' });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!project && !instanceKey) {
|
|
196
|
+
sendJson(res, 400, { ok: false, reason: 'missing-project' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const { killPtyFor } = await import('../pty-manager.js');
|
|
201
|
+
const r = killPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
|
|
202
|
+
if (!r.ok) {
|
|
203
|
+
// Multi-instance: an ambiguous project-name-only close (2+ live same-basename
|
|
204
|
+
// instances) is a 409 with the live candidates, so the client re-issues with an
|
|
205
|
+
// instanceKey instead of killing an arbitrary process.
|
|
206
|
+
if (r.reason === 'ambiguous') {
|
|
207
|
+
sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: r.candidates || [] });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
sendJson(res, 404, { ok: false, reason: r.reason || 'not-found' });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
reportSwallowed('resume-route.close', err);
|
|
216
|
+
sendJson(res, 500, { ok: false, reason: 'close-failed' });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const RESUME_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
222
|
+
|
|
223
|
+
// POST /api/resume-session { sessionUuid, project?, instanceKey? } — TRUE /resume:
|
|
224
|
+
// inject `/resume <uuid>` into the target project's live claude PTY so the running
|
|
225
|
+
// process switches INTO that session in place. Admin + same-origin gated (it re-points
|
|
226
|
+
// a live conversation). See the header comment for the status/reason mapping.
|
|
227
|
+
function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
|
|
228
|
+
if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
|
|
229
|
+
sendJson(res, 403, { ok: false, reason: 'forbidden' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let body = '';
|
|
233
|
+
req.on('data', (chunk) => { body += chunk; if (deps && body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
234
|
+
req.on('end', async () => {
|
|
235
|
+
let sessionUuid = '';
|
|
236
|
+
let project = '';
|
|
237
|
+
let instanceKey = '';
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(body || '{}');
|
|
240
|
+
sessionUuid = typeof parsed.sessionUuid === 'string' ? parsed.sessionUuid.trim() : '';
|
|
241
|
+
project = typeof parsed.project === 'string' ? parsed.project : '';
|
|
242
|
+
instanceKey = typeof parsed.instanceKey === 'string' ? parsed.instanceKey : '';
|
|
243
|
+
} catch {
|
|
244
|
+
sendJson(res, 400, { ok: false, reason: 'bad-json' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!sessionUuid) {
|
|
248
|
+
sendJson(res, 400, { ok: false, reason: 'missing-session' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Shape-validate BEFORE it ever reaches the TUI paste buffer (defense-in-depth;
|
|
252
|
+
// sanitizeInbound alone would still let `;`, quotes, `$()` through).
|
|
253
|
+
if (!RESUME_UUID_RE.test(sessionUuid)) {
|
|
254
|
+
sendJson(res, 400, { ok: false, reason: 'bad-uuid' });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const pm = await import('../pty-manager.js');
|
|
259
|
+
const targetProject = project || (_projectName || '');
|
|
260
|
+
// Multi-instance: a project-name-only resume that matches 2+ live same-basename
|
|
261
|
+
// instances is ambiguous — refuse with the candidates so the client re-issues
|
|
262
|
+
// with an instanceKey (mirror of attach/close).
|
|
263
|
+
if (!instanceKey && targetProject) {
|
|
264
|
+
const live = pm.liveInstancesForProject(targetProject);
|
|
265
|
+
if (live.length > 1) {
|
|
266
|
+
sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: live });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Strict target-kind gate (no active-PTY fallback): never inject into the wrong
|
|
271
|
+
// project's conversation when the anchor doesn't resolve, and never into a shell.
|
|
272
|
+
if (pm.getPtyKindFor({ project: targetProject || undefined, instanceKey: instanceKey || undefined }) !== 'claude') {
|
|
273
|
+
sendJson(res, 404, { ok: false, reason: 'not-found' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const chatQueue = await import('../lib/chat-queue.js');
|
|
277
|
+
const r = await chatQueue.injectResumeCommand(sessionUuid, { project: targetProject || undefined, instanceKey: instanceKey || undefined });
|
|
278
|
+
if (r.ok) {
|
|
279
|
+
// Session identity just changed — drop any still-queued composer messages so a
|
|
280
|
+
// message typed for the OLD conversation never drains into the resumed one.
|
|
281
|
+
try { chatQueue.clear(); } catch (err) { reportSwallowed('resume-route.clear-queue', err); }
|
|
282
|
+
sendJson(res, 200, { ok: true });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
|
|
286
|
+
if (r.reason === 'bad-uuid') { sendJson(res, 400, { ok: false, reason: 'bad-uuid' }); return; }
|
|
287
|
+
if (r.reason === 'no-deps') { sendJson(res, 409, { ok: false, reason: 'unavailable' }); return; }
|
|
288
|
+
sendJson(res, 500, { ok: false, reason: r.reason || 'inject-failed' });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
reportSwallowed('resume-route.resume', err);
|
|
291
|
+
sendJson(res, 500, { ok: false, reason: 'resume-failed' });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const resumeRoutes = [
|
|
297
|
+
{ method: 'GET', match: 'exact', path: '/api/resume-sessions', handler: getSessionsHandler },
|
|
298
|
+
{ method: 'GET', match: 'exact', path: '/api/live-processes', handler: getLiveProcessesHandler },
|
|
299
|
+
{ method: 'POST', match: 'exact', path: '/api/live-processes/attach', handler: postLiveProcessAttachHandler },
|
|
300
|
+
{ method: 'POST', match: 'exact', path: '/api/live-processes/close', handler: postLiveProcessCloseHandler },
|
|
301
|
+
{ method: 'POST', match: 'exact', path: '/api/resume-session', handler: postResumeSessionHandler },
|
|
302
|
+
];
|
package/server/routes/search.js
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
// Scopes to the project root and delegates to lib/code-search.js and lib/code-replace.js.
|
|
3
3
|
import { searchCode } from '../lib/code-search.js';
|
|
4
4
|
import { searchReplace } from '../lib/code-replace.js';
|
|
5
|
+
import { resolveViewRoot } from '../lib/view-root.js';
|
|
6
|
+
import { loadWorkspaces } from '../workspace-registry.js';
|
|
7
|
+
|
|
8
|
+
// Multi-project (2026-10): code SEARCH (read-only) resolves the viewed project
|
|
9
|
+
// via resolveViewRoot (body {project}). search-replace (destructive) stays bound.
|
|
10
|
+
async function _viewRootOrReply(req, res, projectParam, instanceParam) {
|
|
11
|
+
let listLivePtys = () => [];
|
|
12
|
+
try { ({ listLivePtys } = await import('../pty-manager.js')); } catch { /* no pty map */ }
|
|
13
|
+
return resolveViewRoot({
|
|
14
|
+
projectParam,
|
|
15
|
+
instanceParam,
|
|
16
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
17
|
+
loadWorkspaces,
|
|
18
|
+
listLivePtys,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
5
21
|
|
|
6
22
|
const VALID_ENGINES = new Set(['auto', 'ripgrep', 'node']);
|
|
7
23
|
const VALID_SCOPES = new Set(['all', 'file', 'match']);
|
|
@@ -50,7 +66,13 @@ function searchHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
const engine = VALID_ENGINES.has(parsed.engine) ? parsed.engine : 'auto';
|
|
53
|
-
const
|
|
69
|
+
const viewR = await _viewRootOrReply(req, res, typeof parsed.project === 'string' ? parsed.project : '', typeof parsed.instance === 'string' ? parsed.instance : '');
|
|
70
|
+
if (!viewR.ok) {
|
|
71
|
+
res.writeHead(viewR.status, { 'Content-Type': 'application/json' });
|
|
72
|
+
res.end(JSON.stringify({ error: viewR.error }));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const root = viewR.root;
|
|
54
76
|
|
|
55
77
|
try {
|
|
56
78
|
const result = await searchCode({
|
package/server/routes/skills.js
CHANGED
|
@@ -4,10 +4,26 @@ import { join, resolve, sep, dirname } from 'node:path';
|
|
|
4
4
|
import { getClaudeConfigDir } from '../../findcc.js';
|
|
5
5
|
import { listSkills, moveSkill, deleteSkill, validateSkillName, parseSkillFrontmatter } from '../lib/skills-api.js';
|
|
6
6
|
import { isAdminReq } from '../lib/is-admin.js';
|
|
7
|
+
import { viewRootOrReply } from '../lib/view-root.js';
|
|
8
|
+
import { loadWorkspaces } from '../workspace-registry.js';
|
|
9
|
+
import { listLivePtys } from '../pty-manager.js';
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
// Multi-project (2026-10): the skills LIST resolves the viewed project via
|
|
12
|
+
// the shared viewRootOrReply (?project=). toggle/delete (mutations) keep the
|
|
13
|
+
// bound root.
|
|
14
|
+
function _viewRootOrReply(req, res, parsedUrl) {
|
|
15
|
+
return viewRootOrReply(req, res, parsedUrl, {
|
|
16
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
17
|
+
loadWorkspaces,
|
|
18
|
+
listLivePtys,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function skillsList(req, res, parsedUrl) {
|
|
9
23
|
try {
|
|
10
|
-
const
|
|
24
|
+
const viewRoot = _viewRootOrReply(req, res, parsedUrl);
|
|
25
|
+
if (!viewRoot) return;
|
|
26
|
+
const skills = listSkills({ projectDir: viewRoot });
|
|
11
27
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
12
28
|
res.end(JSON.stringify({ ok: true, skills }));
|
|
13
29
|
} catch (err) {
|