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
package/server/routes/v2.js
CHANGED
|
@@ -44,7 +44,12 @@ async function v2Entry(req, res, parsedUrl) {
|
|
|
44
44
|
sseWrite(res, `{"entry":${result.entry},"prevMain":${result.prevMain ?? 'null'}}`);
|
|
45
45
|
wireEnd(res);
|
|
46
46
|
} catch (err) {
|
|
47
|
-
|
|
47
|
+
// A NOT_FOUND here is routine, not an error: the detail-fetch target (a v3
|
|
48
|
+
// row's session dir) was quota-pruned or /clear-cleaned after the list was
|
|
49
|
+
// rendered. The 404 is the correct, sufficient response — logging it to
|
|
50
|
+
// stderr as a stack would be pure noise. Only genuine faults (validation
|
|
51
|
+
// bugs, mid-stream I/O errors) are worth the console.
|
|
52
|
+
if (err.code !== 'NOT_FOUND') console.error('[v2-entry]', file, err && err.stack || err);
|
|
48
53
|
if (!res.headersSent) {
|
|
49
54
|
const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
|
|
50
55
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -82,8 +82,12 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
|
|
|
82
82
|
// 启动 PTY
|
|
83
83
|
const proxyPort = process.env.CCV_PROXY_PORT;
|
|
84
84
|
if (proxyPort) {
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// Multi-instance (2026-10-06): spawnClaude now mints a fresh instanceKey per call and
|
|
86
|
+
// no longer kills an existing same-cwd record, so re-launching an ALREADY-LIVE cwd
|
|
87
|
+
// here would silently start a duplicate claude. ensurePtyForCwd restores the intended
|
|
88
|
+
// "re-open = reuse" semantics: attach to the live record instead of re-spawning.
|
|
89
|
+
const { ensurePtyForCwd } = await import('../pty-manager.js');
|
|
90
|
+
await ensurePtyForCwd({ cwd: wsPath, proxyPort: parseInt(proxyPort), extraArgs: mergedArgs, claudePath: deps.workspaceClaudePath, isNpmVersion: deps.workspaceIsNpmVersion, serverPort: deps.actualPort, serverProtocol: deps.protocol, internalToken: deps.INTERNAL_TOKEN });
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
deps.setWorkspaceLaunched(true);
|
|
@@ -171,7 +175,10 @@ function workspacesDelete(req, res, parsedUrl) {
|
|
|
171
175
|
|
|
172
176
|
function workspacesStop(req, res, parsedUrl, isLocal, deps) {
|
|
173
177
|
Promise.all([
|
|
174
|
-
|
|
178
|
+
// killAllMain (not killPty): a cross-project /resume takeover can leave a background
|
|
179
|
+
// project's PTY alive; stop must reap every main PTY, not just the active one, or the
|
|
180
|
+
// old project's claude leaks past the workspace session.
|
|
181
|
+
import('../pty-manager.js').then(({ killAllMain }) => killAllMain()),
|
|
175
182
|
import('../scratch-pty-manager.js').then(({ killAllScratch }) => killAllScratch()).catch(() => {}),
|
|
176
183
|
]).then(() => {
|
|
177
184
|
// 接续原有清理流程
|
package/server/server.js
CHANGED
|
@@ -40,6 +40,7 @@ import { teamRoutes } from './routes/team.js';
|
|
|
40
40
|
import { authRoutes } from './routes/auth.js';
|
|
41
41
|
import { dingtalkRoutes } from './routes/dingtalk.js';
|
|
42
42
|
import { imRoutes } from './routes/im.js';
|
|
43
|
+
import { resumeRoutes } from './routes/resume.js';
|
|
43
44
|
import { proxyStatsRoutes } from './routes/proxy-stats.js';
|
|
44
45
|
import { setProxyStatsListener } from './lib/proxy/proxy-stats.js';
|
|
45
46
|
import * as imCore from './lib/im/im-bridge-core.js';
|
|
@@ -96,6 +97,7 @@ function execWithStdin(cmd, args, input, options) {
|
|
|
96
97
|
}
|
|
97
98
|
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, RETRY_CONFIG_PATH, _retryConfigState, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
|
|
98
99
|
import { V2LiveFeed } from './lib/v2/live-feed.js';
|
|
100
|
+
import { filterClientsByViewProject, projectOfSessionDir, resolveActivityFeedKey } from './lib/v2/view-router.js';
|
|
99
101
|
import { sanitizePathComponent } from './lib/v2/layout.js';
|
|
100
102
|
import { maybeResumeConvert } from './lib/v2/convert-manager.js';
|
|
101
103
|
import { LOG_DIR, setLogDir, getClaudeConfigDir, isBrowserOpenSuppressed } from '../findcc.js';
|
|
@@ -118,6 +120,7 @@ import { applyTaskEvent, resetTasks, getTaskSnapshot, shouldResetTasks } from '.
|
|
|
118
120
|
import { createImLogWatcher } from './lib/im/im-log-watcher.js';
|
|
119
121
|
import { unwatchAllWorkflows } from './lib/workflow-watcher.js';
|
|
120
122
|
import { backupConfigs } from './lib/config-backup.js';
|
|
123
|
+
import { migrateCredentialsToVault } from './lib/credential-migrate.js';
|
|
121
124
|
import { normalizeBasePath, validateBasePath, stripBasePath } from './lib/base-path.js';
|
|
122
125
|
import { createHardenedCleanup } from './lib/term-signals.js';
|
|
123
126
|
import { createBackpressureGate } from './lib/ws-backpressure.js';
|
|
@@ -172,9 +175,12 @@ const IGNORED_PATTERNS = new Set([
|
|
|
172
175
|
'.idea', '.vscode'
|
|
173
176
|
]);
|
|
174
177
|
|
|
175
|
-
// 多 git 仓库支持:解析 repo 参数为安全的 cwd
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
// 多 git 仓库支持:解析 repo 参数为安全的 cwd 路径。
|
|
179
|
+
// Multi-project (2026-10): `baseDir` lets a viewed parallel project override
|
|
180
|
+
// the bound root (git READ routes pass resolveViewRoot's result); omitting it
|
|
181
|
+
// keeps the bound behavior byte-identical.
|
|
182
|
+
function resolveRepoCwd(repoParam, baseDir) {
|
|
183
|
+
const projectDir = baseDir || process.env.CCV_PROJECT_DIR || process.cwd();
|
|
178
184
|
if (!repoParam || repoParam === '.') return projectDir;
|
|
179
185
|
if (repoParam.includes('/') || repoParam.includes('..') || repoParam.includes('\\')) return null;
|
|
180
186
|
const candidate = join(projectDir, repoParam);
|
|
@@ -490,34 +496,108 @@ const MIME_TYPES = {
|
|
|
490
496
|
'.woff2': 'font/woff2',
|
|
491
497
|
};
|
|
492
498
|
|
|
493
|
-
// S6b: v2 live feed — the live channel when the v2 writer is active.
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
+
// S6b: v2 live feed — the live channel when the v2 writer is active. ONE feed
|
|
500
|
+
// per project (the bound project plus any project a client is currently
|
|
501
|
+
// VIEWING via /events?project=). Feeds are lazy so worker/test paths that never
|
|
502
|
+
// watch anything pay nothing. Keyed by the absolute project dir.
|
|
503
|
+
const _v2LiveFeeds = new Map(); // projectDir → V2LiveFeed
|
|
504
|
+
|
|
505
|
+
// Per-project client filter handed to every feed: a feed for project P must
|
|
506
|
+
// broadcast only to SSE clients whose VIEWED project is P. Pure predicate lives
|
|
507
|
+
// in lib/v2/view-router.js (unit-tested); this closure just binds the live
|
|
508
|
+
// bound-project name.
|
|
509
|
+
function _filterClientsByViewProject(clientList, project) {
|
|
510
|
+
return filterClientsByViewProject(clientList, project, _projectName || '');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function _ensureProjectFeed(projectName) {
|
|
514
|
+
if (!projectName) return null;
|
|
515
|
+
const projectDir = join(LOG_DIR, sanitizePathComponent(projectName));
|
|
516
|
+
let feed = _v2LiveFeeds.get(projectDir);
|
|
517
|
+
if (!feed) {
|
|
518
|
+
feed = new V2LiveFeed({
|
|
499
519
|
clients,
|
|
500
520
|
getClaudePid,
|
|
501
521
|
runParallelHook,
|
|
502
522
|
// The stats worker's v2 units are session dirs (stats-worker.js v9).
|
|
503
523
|
notifyStatsWorker,
|
|
504
524
|
wireV3: WIRE_V3,
|
|
525
|
+
project: projectName,
|
|
526
|
+
clientFilter: _filterClientsByViewProject,
|
|
505
527
|
});
|
|
528
|
+
feed.start(projectDir);
|
|
529
|
+
_v2LiveFeeds.set(projectDir, feed);
|
|
530
|
+
} else if (!feed._active) {
|
|
531
|
+
feed.start(projectDir);
|
|
506
532
|
}
|
|
507
|
-
return
|
|
533
|
+
return feed;
|
|
508
534
|
}
|
|
509
535
|
|
|
510
|
-
/** Start the live channel for the
|
|
511
|
-
*
|
|
536
|
+
/** Start the live channel for the bound project (v2 live feed) and route the
|
|
537
|
+
* writer's per-session activity to the right project's feed. No-op until a
|
|
538
|
+
* project is bound (workspace mode defers to the launch route). */
|
|
512
539
|
function startLogWatch() {
|
|
513
540
|
if (!_projectName) return;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
541
|
+
_ensureProjectFeed(_projectName);
|
|
542
|
+
// The writer holds sessions for EVERY live project (per-project PTYs write
|
|
543
|
+
// through it). Route each activity dir to its owning project's feed by the
|
|
544
|
+
// <LOG_DIR>/<projectSan>/sessions/ prefix, so a viewed background project's
|
|
545
|
+
// live entries reach ITS feed (and its viewing clients) too.
|
|
546
|
+
_v2Writer.setOnActivity((dir) => {
|
|
547
|
+
const feed = _feedForSessionDir(dir);
|
|
548
|
+
if (feed) feed.tick(dir);
|
|
549
|
+
});
|
|
550
|
+
// Feed each resolved (sid, project) into the PTY sid index so chat sends can
|
|
551
|
+
// be routed to the PTY owning that conversation (anchor routing, 2026-10-05).
|
|
552
|
+
// Lazy import keeps pty-manager out of the early module graph (and off the
|
|
553
|
+
// boundary allowlist); a failure must never break logging.
|
|
554
|
+
// Multi-instance (2026-10-06): prefer the instanceKey-exact route when the claude
|
|
555
|
+
// self-reported one (`x-ccv-instance`) — it pins the sid to THIS process, which basename
|
|
556
|
+
// routing cannot do for two same-cwd instances. Fall back to the basename route for
|
|
557
|
+
// legacy / external producers that send no instance header.
|
|
558
|
+
_v2Writer.setOnSessionResolved((sid, project, instanceKey) => {
|
|
559
|
+
import('./pty-manager.js')
|
|
560
|
+
.then((pm) => {
|
|
561
|
+
try {
|
|
562
|
+
const pinned = instanceKey && typeof pm.setPtySessionIdForInstance === 'function'
|
|
563
|
+
? pm.setPtySessionIdForInstance(instanceKey, sid)
|
|
564
|
+
: false;
|
|
565
|
+
if (!pinned) pm.setPtySessionId(project, sid);
|
|
566
|
+
} catch (err) { reportSwallowed('server.sid-pin', err); }
|
|
567
|
+
})
|
|
568
|
+
.catch((err) => { reportSwallowed('server.sid-pin-import', err); });
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// The bound project's feed (may not exist yet). Single key builder, shared by
|
|
573
|
+
// both _feedForSessionDir fallbacks so the ensure/fallback keys can't drift.
|
|
574
|
+
function _boundFeed() {
|
|
575
|
+
return _v2LiveFeeds.get(join(LOG_DIR, sanitizePathComponent(_projectName || ''))) || null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Resolve the live feed that owns a session dir, from its
|
|
579
|
+
// <LOG_DIR>/<projectSan>/sessions/<dir> path. When the prefix isolates a
|
|
580
|
+
// known project but that project has NO live feed (no client is viewing it —
|
|
581
|
+
// feeds are lazy), return null rather than falling back to the bound feed:
|
|
582
|
+
// the activity belongs to a DIFFERENT project, and routing it to the bound
|
|
583
|
+
// project's viewers is exactly the cross-project bleed this layer exists to
|
|
584
|
+
// prevent (2026-10-05). Only when the prefix does not resolve a project at all
|
|
585
|
+
// (a dir outside <LOG_DIR>/<projectSan>/sessions/) do we fall back to the
|
|
586
|
+
// bound feed defensively.
|
|
587
|
+
function _feedForSessionDir(dir) {
|
|
588
|
+
const { feedKey, allowBoundFallback } = resolveActivityFeedKey(dir, LOG_DIR, sep);
|
|
589
|
+
// A resolved project owns this activity: route to ITS feed, or drop when it
|
|
590
|
+
// has none (lazy feeds — no viewer, no broadcast). Never re-route a foreign
|
|
591
|
+
// project's activity to the bound feed (the cross-project bleed, 2026-10-05).
|
|
592
|
+
if (feedKey) return _v2LiveFeeds.get(join(LOG_DIR, feedKey)) || null;
|
|
593
|
+
// No owning project (dir outside every <LOG_DIR>/<projectSan>/sessions/
|
|
594
|
+
// root): bound feed, defensively — the legacy single-project behavior.
|
|
595
|
+
return allowBoundFallback ? _boundFeed() : null;
|
|
517
596
|
}
|
|
518
597
|
|
|
519
598
|
function stopLogWatch() {
|
|
520
|
-
|
|
599
|
+
for (const feed of _v2LiveFeeds.values()) feed.stop();
|
|
600
|
+
_v2LiveFeeds.clear();
|
|
521
601
|
_v2Writer.setOnActivity(null);
|
|
522
602
|
}
|
|
523
603
|
|
|
@@ -610,6 +690,7 @@ const deps = {
|
|
|
610
690
|
notifyParentPending: _notifyParentPending,
|
|
611
691
|
startLogWatch,
|
|
612
692
|
stopLogWatch,
|
|
693
|
+
ensureProjectFeed: _ensureProjectFeed,
|
|
613
694
|
scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
|
|
614
695
|
// SessionStart hook notify → conversation-switch signal (in-terminal
|
|
615
696
|
// /resume). The source gate + V2Writer re-bind live in the interceptor.
|
|
@@ -732,6 +813,7 @@ const _routes = [
|
|
|
732
813
|
...dingtalkRoutes,
|
|
733
814
|
...imRoutes,
|
|
734
815
|
...proxyStatsRoutes,
|
|
816
|
+
...resumeRoutes,
|
|
735
817
|
];
|
|
736
818
|
const dispatch = createDispatcher(_routes);
|
|
737
819
|
|
|
@@ -785,6 +867,7 @@ async function handleRequest(req, res) {
|
|
|
785
867
|
accessToken: ACCESS_TOKEN,
|
|
786
868
|
enabled: authConfig.enabled,
|
|
787
869
|
password: authConfig.password,
|
|
870
|
+
passwordUnreadable: authConfig.passwordUnreadable,
|
|
788
871
|
wantsHtml,
|
|
789
872
|
});
|
|
790
873
|
// Remote-admin elevation (container/cloud deploy): an authenticated remote caller is
|
|
@@ -990,6 +1073,11 @@ export async function startViewer() {
|
|
|
990
1073
|
// (滚动留 10 份)。2026-06-06 事故:配置随 LOG_DIR 整树丢失后无处可恢复。fire-and-forget。
|
|
991
1074
|
setImmediate(() => { try { backupConfigs(); } catch { /* ignore */ } });
|
|
992
1075
|
|
|
1076
|
+
// 启动期一次性凭证迁移:preferences/profile 里的明文/base64 密钥 → credentials.json(AES-256-GCM)。
|
|
1077
|
+
// 必须在 backupConfigs 之后挂(先留一份迁移前备份),幂等(逐字段写 vault→读回比对→剔旧字段),
|
|
1078
|
+
// 失败字段保留原值不丢,下次启动重试。fire-and-forget,不阻塞启动。
|
|
1079
|
+
setImmediate(() => { try { migrateCredentialsToVault(); } catch { /* ignore */ } });
|
|
1080
|
+
|
|
993
1081
|
// 启动时清理磁盘上 ASK_HOOK_TIMEOUT_MS 之前的 ask 条目(兜底防泄漏)。
|
|
994
1082
|
// 内存 Map 不 hydrate:旧 res 已死、新 ask-bridge 重连同 toolUseId 会自动复用槽位
|
|
995
1083
|
// (server.js 已有"旧 res 已断 → 复用"分支),无需在这里主动重建内存态。
|
|
@@ -1292,7 +1380,7 @@ export async function startViewer() {
|
|
|
1292
1380
|
async function setupTerminalWebSocket(httpServer) {
|
|
1293
1381
|
try {
|
|
1294
1382
|
const { WebSocketServer } = await import('ws');
|
|
1295
|
-
const { writeToPty, writeToPtySequential, resizePty, onPtyData, onPtyExit, getPtyState, getPtyKind, getOutputBuffer, getCurrentWorkspace, spawnShell, findSafeSliceStart } = await import('./pty-manager.js');
|
|
1383
|
+
const { writeToPty, writeToPtySequential, writeToPtyFor, writeToPtySequentialFor, resizePty, onPtyData, onPtyExit, getPtyState, getPtyKind, getOutputBuffer, getCurrentWorkspace, spawnShell, findSafeSliceStart, attachPtyFor } = await import('./pty-manager.js');
|
|
1296
1384
|
const {
|
|
1297
1385
|
spawnScratch,
|
|
1298
1386
|
writeScratch,
|
|
@@ -1317,6 +1405,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1317
1405
|
chatQueue.initChatQueue({
|
|
1318
1406
|
writeToPty,
|
|
1319
1407
|
writeToPtySequential,
|
|
1408
|
+
writeToPtySequentialFor,
|
|
1320
1409
|
getPtyKind,
|
|
1321
1410
|
isStreaming: () => streamingState.active,
|
|
1322
1411
|
hasPendingApproval: () => pendingAskHooks.size > 0 || pendingPermHooks.size > 0,
|
|
@@ -1364,6 +1453,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1364
1453
|
accessToken: ACCESS_TOKEN,
|
|
1365
1454
|
enabled: authConfig.enabled,
|
|
1366
1455
|
password: authConfig.password,
|
|
1456
|
+
passwordUnreadable: authConfig.passwordUnreadable,
|
|
1367
1457
|
wantsHtml: false,
|
|
1368
1458
|
});
|
|
1369
1459
|
if (wsAuth.action !== 'allow') {
|
|
@@ -1371,6 +1461,18 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1371
1461
|
return;
|
|
1372
1462
|
}
|
|
1373
1463
|
if (pathname === '/ws/terminal') {
|
|
1464
|
+
// Multi-PTY view attach (2026-10): `?project=<name>` pins the freshly
|
|
1465
|
+
// connected client to the project it is VIEWING, so the initial
|
|
1466
|
+
// state/buffer frames below come from that project's record — not from
|
|
1467
|
+
// whatever project happened to spawn last. Unknown/absent project is a
|
|
1468
|
+
// silent no-op (single-project behavior unchanged).
|
|
1469
|
+
const viewProject = wsUrl.searchParams.get('project');
|
|
1470
|
+
// Multi-instance: `&instance=<key>` pins the attach to THAT exact process when a
|
|
1471
|
+
// same-cwd project runs two concurrent instances (basename alone is ambiguous).
|
|
1472
|
+
const viewInstance = wsUrl.searchParams.get('instance');
|
|
1473
|
+
if (viewProject || viewInstance) {
|
|
1474
|
+
try { attachPtyFor({ project: viewProject || undefined, instanceKey: viewInstance || undefined }); } catch { }
|
|
1475
|
+
}
|
|
1374
1476
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1375
1477
|
wss.emit('connection', ws, req);
|
|
1376
1478
|
});
|
|
@@ -1775,11 +1877,11 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1775
1877
|
ws.on('message', async (raw) => {
|
|
1776
1878
|
try {
|
|
1777
1879
|
const msg = JSON.parse(raw.toString());
|
|
1778
|
-
// SDK 模式没有 PTY:PTY 专属消息(input/sequential input/resync/resize)一律 no-op,
|
|
1880
|
+
// SDK 模式没有 PTY:PTY 专属消息(input/sequential input/resync/resize/attach)一律 no-op,
|
|
1779
1881
|
// 否则 'input' 会在无会话时触发 spawnShell() 拉出一个用户看不见的裸 shell。
|
|
1780
1882
|
// sdk-*/ask-*/perm-*/image-* 等控制消息不受影响。scratch WS 是独立 handler,不受此闸门影响。
|
|
1781
1883
|
if (isSdkMode && (msg.type === 'input' || msg.type === 'input-sequential'
|
|
1782
|
-
|| msg.type === 'resync-request' || msg.type === 'resize')) {
|
|
1884
|
+
|| msg.type === 'resync-request' || msg.type === 'resize' || msg.type === 'attach')) {
|
|
1783
1885
|
return;
|
|
1784
1886
|
}
|
|
1785
1887
|
if (msg.type === 'input') {
|
|
@@ -1806,6 +1908,12 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1806
1908
|
}
|
|
1807
1909
|
}
|
|
1808
1910
|
// 拦截连续 Ctrl+C:2秒内连按2次则阻止并提醒,避免误退出 CLI
|
|
1911
|
+
// Anchor routing (2026-10-05): a send carries the viewed project's
|
|
1912
|
+
// {project, sessionId} so the input lands on that conversation's PTY,
|
|
1913
|
+
// not whichever record the global activePtyKey last pointed to. Falls
|
|
1914
|
+
// back to the legacy active-targeted write when no anchor is present.
|
|
1915
|
+
const _anchor = { project: typeof msg.project === 'string' ? msg.project : undefined, sessionId: typeof msg.sessionId === 'string' ? msg.sessionId : undefined, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined };
|
|
1916
|
+
const _write = (data) => (_anchor.project || _anchor.sessionId || _anchor.instanceKey) ? writeToPtyFor(data, _anchor) : writeToPty(data);
|
|
1809
1917
|
if (msg.data === '\x03') {
|
|
1810
1918
|
const now = Date.now();
|
|
1811
1919
|
if (!ws._ctrlCLastTime) ws._ctrlCLastTime = 0;
|
|
@@ -1815,10 +1923,10 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1815
1923
|
// 不发送第二次 Ctrl+C 到 PTY
|
|
1816
1924
|
} else {
|
|
1817
1925
|
ws._ctrlCLastTime = now;
|
|
1818
|
-
|
|
1926
|
+
_write(msg.data);
|
|
1819
1927
|
}
|
|
1820
1928
|
} else {
|
|
1821
|
-
|
|
1929
|
+
_write(msg.data);
|
|
1822
1930
|
}
|
|
1823
1931
|
} else if (msg.type === 'input-sequential') {
|
|
1824
1932
|
// Programmatic sequential input: send chunks one by one, waiting for PTY ACK
|
|
@@ -1844,7 +1952,12 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1844
1952
|
}
|
|
1845
1953
|
};
|
|
1846
1954
|
if (Array.isArray(chunks) && chunks.length > 0 && chunks.every(c => typeof c === 'string')) {
|
|
1847
|
-
|
|
1955
|
+
const _seqAnchor = { project: typeof msg.project === 'string' ? msg.project : undefined, sessionId: typeof msg.sessionId === 'string' ? msg.sessionId : undefined, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined };
|
|
1956
|
+
if (_seqAnchor.project || _seqAnchor.sessionId || _seqAnchor.instanceKey) {
|
|
1957
|
+
writeToPtySequentialFor(chunks, replyDone, { settleMs: msg.settleMs || 150 }, _seqAnchor);
|
|
1958
|
+
} else {
|
|
1959
|
+
writeToPtySequential(chunks, replyDone, { settleMs: msg.settleMs || 150 });
|
|
1960
|
+
}
|
|
1848
1961
|
} else {
|
|
1849
1962
|
replyDone(false);
|
|
1850
1963
|
}
|
|
@@ -1854,6 +1967,39 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1854
1967
|
floodGate.reset();
|
|
1855
1968
|
sendResync();
|
|
1856
1969
|
}
|
|
1970
|
+
} else if (msg.type === 'attach') {
|
|
1971
|
+
// Multi-PTY view attach (2026-10): the client switched the main view to
|
|
1972
|
+
// another parallel project (chip click / launch / cold-load landing) —
|
|
1973
|
+
// re-anchor the shared PTY stream to THAT project's record, then replay
|
|
1974
|
+
// its outputBuffer so the terminal paints the right screen immediately.
|
|
1975
|
+
// attachPtyFor is idempotent and never spawns/kills; an unknown project
|
|
1976
|
+
// degrades to attach-failed with the current view left untouched.
|
|
1977
|
+
//
|
|
1978
|
+
// KNOWN LIMITATION (documented, pre-existing): the attachment is a single
|
|
1979
|
+
// server-wide pointer, not per-connection. Two tabs viewing DIFFERENT
|
|
1980
|
+
// projects share it — the last attach wins and the other tab's terminal
|
|
1981
|
+
// follows the new project too (its chat view is unaffected: SSE per-project
|
|
1982
|
+
// filtering is per-connection). Same-project tabs attach identically →
|
|
1983
|
+
// no-op. A per-connection PTY mux is out of scope for this fix.
|
|
1984
|
+
const target = typeof msg.project === 'string' ? msg.project : '';
|
|
1985
|
+
// Multi-instance: an explicit instanceKey pins the attach to THAT exact process
|
|
1986
|
+
// (a same-cwd project's basename alone is ambiguous across two live instances).
|
|
1987
|
+
const targetInstance = typeof msg.instanceKey === 'string' ? msg.instanceKey : '';
|
|
1988
|
+
let res = null;
|
|
1989
|
+
try { res = (target || targetInstance) ? attachPtyFor({ project: target || undefined, instanceKey: targetInstance || undefined }) : null; } catch { res = null; }
|
|
1990
|
+
if (res && res.ok) {
|
|
1991
|
+
try { ws.send(JSON.stringify({ type: 'attached', project: target, running: res.running, ptyKind: res.ptyKind, exitCode: res.exitCode })); } catch { }
|
|
1992
|
+
const snap = getOutputBuffer();
|
|
1993
|
+
if (snap) {
|
|
1994
|
+
try { ws.send(JSON.stringify({ type: 'data', data: snap })); } catch { }
|
|
1995
|
+
}
|
|
1996
|
+
} else {
|
|
1997
|
+
// Multi-instance: surface an ambiguous project-name attach (with the live
|
|
1998
|
+
// candidates) so the client re-issues with an instanceKey instead of silently
|
|
1999
|
+
// pinning an arbitrary same-cwd process.
|
|
2000
|
+
const reason = (res && res.reason) || 'not-found';
|
|
2001
|
+
try { ws.send(JSON.stringify({ type: 'attach-failed', project: target, reason, ...(res && res.candidates ? { candidates: res.candidates } : {}) })); } catch { }
|
|
2002
|
+
}
|
|
1857
2003
|
} else if (msg.type === 'ask-hook-answer') {
|
|
1858
2004
|
// Client answered AskUserQuestion via hook bridge.
|
|
1859
2005
|
// New protocol: msg.id required to address one of multiple pending asks.
|
|
@@ -2116,7 +2262,9 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2116
2262
|
});
|
|
2117
2263
|
}
|
|
2118
2264
|
} else {
|
|
2119
|
-
|
|
2265
|
+
const _qAnchor = { project: typeof msg.project === 'string' ? msg.project : undefined, sessionId: typeof msg.sessionId === 'string' ? msg.sessionId : undefined, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined };
|
|
2266
|
+
const _anchorArg = (_qAnchor.project || _qAnchor.sessionId || _qAnchor.instanceKey) ? _qAnchor : undefined;
|
|
2267
|
+
try { chatQueue.enqueue(msg.text, _anchorArg); } catch (err) { reportSwallowed('server.queue-message', err); }
|
|
2120
2268
|
}
|
|
2121
2269
|
}
|
|
2122
2270
|
} else if (msg.type === 'queue-send-now') {
|
|
@@ -2485,7 +2633,15 @@ export function startStreamingStatusTimer() {
|
|
|
2485
2633
|
const data = isActive
|
|
2486
2634
|
? { ...streamingState, elapsed: Date.now() - streamingState.startTime }
|
|
2487
2635
|
: { active: false };
|
|
2488
|
-
|
|
2636
|
+
// Multi-project (2026-10): streamingState is a global singleton tracking the
|
|
2637
|
+
// BOUND project's main process — route the spinner only to clients viewing
|
|
2638
|
+
// that project, so a parallel project's view never shows the bound
|
|
2639
|
+
// project's streaming flag (cross-project spinner bleed). (Per-project
|
|
2640
|
+
// streaming state is a follow-up; today a parallel view simply shows no
|
|
2641
|
+
// spinner, matching the pre-multi-PTY single-project behavior.)
|
|
2642
|
+
if (clients.length > 0 && sendEventToClients) {
|
|
2643
|
+
sendEventToClients(_filterClientsByViewProject(clients, _projectName || ''), 'streaming_status', data);
|
|
2644
|
+
}
|
|
2489
2645
|
}
|
|
2490
2646
|
}, 500);
|
|
2491
2647
|
_streamingStatusTimer.unref();
|
|
@@ -2717,7 +2873,25 @@ if (!globalThis._ccvServerSignalsRegistered) {
|
|
|
2717
2873
|
process.on('exit', handleExit);
|
|
2718
2874
|
// hardened:watchdog 5s 强退 + 重复触发立退(防 Windows 上 stopViewer 内部
|
|
2719
2875
|
// await 挂住导致 .finally(exit) 永不执行 = Ctrl+C 完全无反应)。
|
|
2720
|
-
|
|
2876
|
+
// Also reap scratch/resume children at this PROCESS-level exit point —
|
|
2877
|
+
// without it they orphan on SIGINT/SIGTERM (node-pty children are session
|
|
2878
|
+
// leaders, not cascade-killed by the OS). killAllScratch is idempotent.
|
|
2879
|
+
// _doStop (the session-level stop/start cycle point) deliberately does NOT
|
|
2880
|
+
// kill scratch PTYs: Electron tab switches go through it and must not reap a
|
|
2881
|
+
// resume child owned by another tab.
|
|
2882
|
+
const _hardenedStop = createHardenedCleanup({
|
|
2883
|
+
doCleanup: () => Promise.all([
|
|
2884
|
+
stopViewer(),
|
|
2885
|
+
import('./scratch-pty-manager.js')
|
|
2886
|
+
.then(({ killAllScratch }) => killAllScratch())
|
|
2887
|
+
.catch((e) => reportSwallowed('server.stop.scratch', e)),
|
|
2888
|
+
// Also reap every kept-alive main PTY (cross-project /resume leaves old
|
|
2889
|
+
// projects' main PTYs running); idempotent, equals killPty() when single.
|
|
2890
|
+
import('./pty-manager.js')
|
|
2891
|
+
.then(({ killAllMain }) => killAllMain())
|
|
2892
|
+
.catch((e) => reportSwallowed('server.stop.mainpty', e)),
|
|
2893
|
+
]),
|
|
2894
|
+
});
|
|
2721
2895
|
process.on('SIGINT', _hardenedStop);
|
|
2722
2896
|
process.on('SIGTERM', _hardenedStop);
|
|
2723
2897
|
}
|
|
@@ -36,7 +36,7 @@ IMPORTANT: Never generate or guess URLs unless you are confident they help the u
|
|
|
36
36
|
- Do not wait passively for a teammate to report back: teammates sometimes finish their task without sending you a message. No notification ≠ not done — check `~/.claude/projects/<slug>/<session>/subagents/agent-*.jsonl` (or SendMessage the teammate) before waiting.
|
|
37
37
|
- When a teammate goes quiet, ask it directly for its result or status — treat silence as "finished or stuck", never as "still working". Read results back with `tail -N <file> | jq -r 'select(.type=="assistant") | .message.content[] | select(.type=="text") | .text'` — never Read the whole jsonl.
|
|
38
38
|
- Before telling the user a delegated task is blocked or incomplete, ping the teammate once; escalate to the user only if it still does not respond. After 2 empty wait rounds, change strategy; after ~10 minutes, do it yourself or tell the user. Never idle-poll.
|
|
39
|
-
- When waiting for an async result (teammate/subagent report, background task),
|
|
39
|
+
- When waiting for an async result (teammate/subagent report, background task), end your turn and let it arrive — these results come back automatically as messages, so there is nothing to check. Never poll for completion: no Monitor, CronCreate, `sleep`, re-reading the agent's output file, or repeated TaskList/status checks. And never fill the wait with "waiting…" filler text or empty echo/status lines — if you have nothing to advance, stop. One short "delegated, awaiting result" line is enough. Reserve Monitor-style event-driven waiting for external state only (files, processes, ports). If you catch yourself checking the same thing's progress two turns in a row, that is polling — stop immediately.
|
|
40
40
|
|
|
41
41
|
# Executing actions with care
|
|
42
42
|
Consider the reversibility and blast radius of each action. Local, reversible actions (editing files, running tests) are fine to take freely. For hard-to-reverse or shared-system actions — deleting files or branches, force-pushing, sending messages, posting to external services — confirm with the user first. Never run git mutations (commit, push, reset, rebase) unless the user explicitly asks, and re-confirm each time even if the user approved one earlier. Investigate unexpected state before overwriting it.
|
|
@@ -1,42 +1,25 @@
|
|
|
1
1
|
// Workspace Registry - 工作区持久化管理
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
3
2
|
import { readdir, stat } from 'node:fs/promises';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { statSync } from 'node:fs';
|
|
4
|
+
import { mutateJson, readJsonSafe, writeJsonAtomic } from './lib/json-store.js';
|
|
6
5
|
import { dirSizeSync } from './lib/v2/layout.js';
|
|
7
6
|
import { isDiscardableSession } from './lib/v2/session-select.js';
|
|
8
|
-
import { join, basename, resolve } from 'node:path';
|
|
7
|
+
import { join, basename, resolve, dirname } from 'node:path';
|
|
9
8
|
import { randomBytes } from 'node:crypto';
|
|
10
9
|
import { LOG_DIR } from '../findcc.js';
|
|
11
10
|
|
|
12
11
|
// 动态获取(LOG_DIR 可能在运行时被 setLogDir 修改)
|
|
13
12
|
function getWorkspacesFile() { return join(LOG_DIR, 'workspaces.json'); }
|
|
14
|
-
function getLockFile() { return join(LOG_DIR, 'workspaces.lock'); }
|
|
15
13
|
|
|
16
14
|
export function loadWorkspaces() {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const data = JSON.parse(readFileSync(getWorkspacesFile(), 'utf-8'));
|
|
20
|
-
return Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
21
|
-
} catch {
|
|
22
|
-
return [];
|
|
23
|
-
}
|
|
15
|
+
const data = readJsonSafe(getWorkspacesFile(), {});
|
|
16
|
+
return Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
24
17
|
}
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
writeFileSync(tmpFile, JSON.stringify({ workspaces: list }, null, 2));
|
|
31
|
-
|
|
32
|
-
// Windows 上 renameSync 可能会因为目标文件存在或被占用而失败。统一走 server/lib/file-api.js
|
|
33
|
-
// renameSyncWithRetry helper(同款重试策略,跟 interceptor / log-management 一致)。
|
|
34
|
-
renameSyncWithRetry(tmpFile, getWorkspacesFile());
|
|
35
|
-
} catch (err) {
|
|
36
|
-
console.error('[CC Viewer] Failed to save workspaces:', err.message);
|
|
37
|
-
// 尝试清理临时文件
|
|
38
|
-
try { unlinkSync(tmpFile); } catch { }
|
|
39
|
-
}
|
|
19
|
+
// Full-list rewrite on every mutation; non-secret so no 0600 (mode:false → umask).
|
|
20
|
+
// Atomicity + cross-process mutex come from mutateJson below.
|
|
21
|
+
function _saveWorkspaces(list) {
|
|
22
|
+
return { workspaces: list };
|
|
40
23
|
}
|
|
41
24
|
|
|
42
25
|
// 失效 file-access-policy 的 allowlist roots 缓存。lazy import 避免循环依赖。
|
|
@@ -47,10 +30,10 @@ function _invalidatePolicyCache() {
|
|
|
47
30
|
}
|
|
48
31
|
|
|
49
32
|
export async function registerWorkspace(absolutePath) {
|
|
50
|
-
const result = await
|
|
33
|
+
const result = await mutateJson(getWorkspacesFile(), (data) => {
|
|
51
34
|
const resolvedPath = resolve(absolutePath);
|
|
52
35
|
const projectName = basename(resolvedPath).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
53
|
-
|
|
36
|
+
let list = Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
54
37
|
// Windows NTFS 不分大小写——`C:\App` 跟 `c:\app` 是同目录但 `===` 视为不同。
|
|
55
38
|
// 仅 Win 下小写化比较;POSIX 保持原样不引入回归。
|
|
56
39
|
const pathEq = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
@@ -58,9 +41,22 @@ export async function registerWorkspace(absolutePath) {
|
|
|
58
41
|
if (existing) {
|
|
59
42
|
existing.lastUsed = new Date().toISOString();
|
|
60
43
|
existing.projectName = projectName;
|
|
61
|
-
|
|
44
|
+
data.workspaces = list;
|
|
62
45
|
return existing;
|
|
63
46
|
}
|
|
47
|
+
// Gentle self-heal (2026-10-05): a same-name entry whose path no longer
|
|
48
|
+
// exists (moved/deleted checkout residue) is pruned on register — otherwise
|
|
49
|
+
// the collision makes view-root's no-live-PTY branch 400. Same-name entries
|
|
50
|
+
// whose path still exists are kept (never auto-delete user data).
|
|
51
|
+
// Only ENOENT/ENOTDIR count as "gone": existsSync() also returns false on
|
|
52
|
+
// EIO/EACCES/EPERM/a transiently unmounted volume, which must NOT prune a
|
|
53
|
+
// legitimate workspace.
|
|
54
|
+
const isGone = (p) => {
|
|
55
|
+
try { statSync(p); return false; }
|
|
56
|
+
catch (e) { return e && (e.code === 'ENOENT' || e.code === 'ENOTDIR'); }
|
|
57
|
+
};
|
|
58
|
+
list = list.filter(w =>
|
|
59
|
+
w.projectName !== projectName || pathEq(w.path, resolvedPath) || !isGone(w.path));
|
|
64
60
|
const now = new Date().toISOString();
|
|
65
61
|
const entry = {
|
|
66
62
|
id: randomBytes(6).toString('hex'),
|
|
@@ -70,27 +66,37 @@ export async function registerWorkspace(absolutePath) {
|
|
|
70
66
|
createdAt: now,
|
|
71
67
|
};
|
|
72
68
|
list.push(entry);
|
|
73
|
-
|
|
69
|
+
data.workspaces = list;
|
|
74
70
|
return entry;
|
|
75
|
-
}, { ensureDir: LOG_DIR });
|
|
71
|
+
}, { mode: false, fallback: {}, ensureDir: LOG_DIR });
|
|
76
72
|
_invalidatePolicyCache();
|
|
77
73
|
return result;
|
|
78
74
|
}
|
|
79
75
|
|
|
80
76
|
export async function removeWorkspace(id) {
|
|
81
|
-
const result = await
|
|
82
|
-
const list =
|
|
77
|
+
const result = await mutateJson(getWorkspacesFile(), (data) => {
|
|
78
|
+
const list = Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
83
79
|
const filtered = list.filter(w => w.id !== id);
|
|
84
80
|
if (filtered.length !== list.length) {
|
|
85
|
-
|
|
81
|
+
data.workspaces = filtered;
|
|
86
82
|
return true;
|
|
87
83
|
}
|
|
88
84
|
return false;
|
|
89
|
-
}, { ensureDir: LOG_DIR });
|
|
85
|
+
}, { mode: false, fallback: {}, ensureDir: LOG_DIR });
|
|
90
86
|
if (result) _invalidatePolicyCache();
|
|
91
87
|
return result;
|
|
92
88
|
}
|
|
93
89
|
|
|
90
|
+
// Legacy sync save entry kept for any direct caller: writes the full list atomically via the
|
|
91
|
+
// kernel (non-secret → umask). mkdirSync is handled inside writeJsonAtomic.
|
|
92
|
+
export function saveWorkspaces(list) {
|
|
93
|
+
try {
|
|
94
|
+
writeJsonAtomic(getWorkspacesFile(), _saveWorkspaces(list), { mode: false });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error('[CC Viewer] Failed to save workspaces:', err.message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
94
100
|
export async function getWorkspaces() {
|
|
95
101
|
const list = loadWorkspaces();
|
|
96
102
|
const enriched = await Promise.all(list.map(async (w) => {
|