cc-viewer 1.5.21 → 1.5.23
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 +22 -3
- package/cli.js +6 -4
- package/dist/assets/{index-CRbM_95R.js → index-Drg6iuc9.js} +141 -139
- package/dist/assets/{index-C9cmcYOr.css → index-Dt59iD4-.css} +2 -2
- package/dist/index.html +2 -2
- package/findcc.js +19 -4
- package/interceptor.js +2 -257
- package/lib/interceptor-core.js +230 -0
- package/lib/plugin-loader.js +8 -7
- package/lib/updater.js +8 -3
- package/package.json +2 -2
- package/pty-manager.js +21 -10
- package/workspace-registry.js +98 -30
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const SUBAGENT_SYSTEM_RE = /(?:command execution|file search|planning) specialist|general-purpose agent/i;
|
|
5
|
+
|
|
6
|
+
export function getSystemText(body) {
|
|
7
|
+
const system = body?.system;
|
|
8
|
+
if (typeof system === 'string') return system;
|
|
9
|
+
if (Array.isArray(system)) {
|
|
10
|
+
return system.map(s => (s && s.text) || '').join('');
|
|
11
|
+
}
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isMainAgentRequest(body) {
|
|
16
|
+
if (!body?.system || !Array.isArray(body?.tools)) return false;
|
|
17
|
+
|
|
18
|
+
const sysText = getSystemText(body);
|
|
19
|
+
if (!sysText.includes('You are Claude Code')) return false;
|
|
20
|
+
if (SUBAGENT_SYSTEM_RE.test(sysText)) return false;
|
|
21
|
+
|
|
22
|
+
const isSystemArray = Array.isArray(body.system);
|
|
23
|
+
const hasToolSearch = body.tools.some(t => t.name === 'ToolSearch');
|
|
24
|
+
|
|
25
|
+
if (isSystemArray && hasToolSearch) {
|
|
26
|
+
const messages = body.messages || [];
|
|
27
|
+
const firstMsgContent = messages.length > 0 ?
|
|
28
|
+
(typeof messages[0].content === 'string' ? messages[0].content :
|
|
29
|
+
Array.isArray(messages[0].content) ? messages[0].content.map(c => c.text || '').join('') : '') : '';
|
|
30
|
+
if (firstMsgContent.includes('<available-deferred-tools>')) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (body.tools.length > 10) {
|
|
36
|
+
const hasEdit = body.tools.some(t => t.name === 'Edit');
|
|
37
|
+
const hasBash = body.tools.some(t => t.name === 'Bash');
|
|
38
|
+
const hasTaskOrAgent = body.tools.some(t => t.name === 'Task' || t.name === 'Agent');
|
|
39
|
+
if (hasEdit && hasBash && hasTaskOrAgent) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isPreflightEntry(entry) {
|
|
48
|
+
if (entry.mainAgent || entry.isHeartbeat || entry.isCountTokens) return false;
|
|
49
|
+
const body = entry.body || {};
|
|
50
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) return false;
|
|
51
|
+
const msgs = body.messages || [];
|
|
52
|
+
if (msgs.length !== 1 || msgs[0].role !== 'user') return false;
|
|
53
|
+
const sysText = typeof body.system === 'string' ? body.system :
|
|
54
|
+
Array.isArray(body.system) ? body.system.map(s => s?.text || '').join('') : '';
|
|
55
|
+
return sysText.includes('Claude Code');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isAnthropicApiPath(urlStr) {
|
|
59
|
+
try {
|
|
60
|
+
const pathname = new URL(urlStr).pathname;
|
|
61
|
+
return /^\/v1\/messages(\/count_tokens|\/batches(\/.*)?)?$/.test(pathname)
|
|
62
|
+
|| /^\/api\/eval\/sdk-/.test(pathname);
|
|
63
|
+
} catch {
|
|
64
|
+
return /\/v1\/messages/.test(urlStr);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function assembleStreamMessage(events) {
|
|
69
|
+
let message = null;
|
|
70
|
+
const contentBlocks = [];
|
|
71
|
+
let currentBlockIndex = -1;
|
|
72
|
+
|
|
73
|
+
for (const event of events) {
|
|
74
|
+
if (!event || typeof event !== 'object' || !event.type) continue;
|
|
75
|
+
|
|
76
|
+
switch (event.type) {
|
|
77
|
+
case 'message_start':
|
|
78
|
+
message = { ...event.message };
|
|
79
|
+
message.content = [];
|
|
80
|
+
break;
|
|
81
|
+
|
|
82
|
+
case 'content_block_start':
|
|
83
|
+
currentBlockIndex = event.index;
|
|
84
|
+
contentBlocks[currentBlockIndex] = { ...event.content_block };
|
|
85
|
+
if (contentBlocks[currentBlockIndex].type === 'text') {
|
|
86
|
+
contentBlocks[currentBlockIndex].text = '';
|
|
87
|
+
} else if (contentBlocks[currentBlockIndex].type === 'thinking') {
|
|
88
|
+
contentBlocks[currentBlockIndex].thinking = '';
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
|
|
92
|
+
case 'content_block_delta':
|
|
93
|
+
if (event.index >= 0 && contentBlocks[event.index] && event.delta) {
|
|
94
|
+
if (event.delta.type === 'text_delta' && event.delta.text) {
|
|
95
|
+
contentBlocks[event.index].text += event.delta.text;
|
|
96
|
+
} else if (event.delta.type === 'input_json_delta' && event.delta.partial_json) {
|
|
97
|
+
if (typeof contentBlocks[event.index]._inputJson !== 'string') {
|
|
98
|
+
contentBlocks[event.index]._inputJson = '';
|
|
99
|
+
}
|
|
100
|
+
contentBlocks[event.index]._inputJson += event.delta.partial_json;
|
|
101
|
+
} else if (event.delta.type === 'thinking_delta' && event.delta.thinking) {
|
|
102
|
+
contentBlocks[event.index].thinking += event.delta.thinking;
|
|
103
|
+
} else if (event.delta.type === 'signature_delta' && event.delta.signature) {
|
|
104
|
+
contentBlocks[event.index].signature = event.delta.signature;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
|
|
109
|
+
case 'content_block_stop':
|
|
110
|
+
if (event.index >= 0 && contentBlocks[event.index]) {
|
|
111
|
+
if (contentBlocks[event.index].type === 'tool_use' && typeof contentBlocks[event.index]._inputJson === 'string') {
|
|
112
|
+
try {
|
|
113
|
+
contentBlocks[event.index].input = JSON.parse(contentBlocks[event.index]._inputJson);
|
|
114
|
+
} catch {
|
|
115
|
+
contentBlocks[event.index].input = contentBlocks[event.index]._inputJson;
|
|
116
|
+
}
|
|
117
|
+
delete contentBlocks[event.index]._inputJson;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
|
|
122
|
+
case 'message_delta':
|
|
123
|
+
if (message && event.delta) {
|
|
124
|
+
if (event.delta.stop_reason) {
|
|
125
|
+
message.stop_reason = event.delta.stop_reason;
|
|
126
|
+
}
|
|
127
|
+
if (event.delta.stop_sequence !== undefined) {
|
|
128
|
+
message.stop_sequence = event.delta.stop_sequence;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (message && event.usage) {
|
|
132
|
+
message.usage = { ...message.usage, ...event.usage };
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
|
|
136
|
+
case 'message_stop':
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (message) {
|
|
142
|
+
message.content = contentBlocks.filter(block => block !== undefined);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return message;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function findRecentLog(dir, projectName) {
|
|
149
|
+
try {
|
|
150
|
+
const files = readdirSync(dir)
|
|
151
|
+
.filter(f => f.startsWith(projectName + '_') && f.endsWith('.jsonl'))
|
|
152
|
+
.sort()
|
|
153
|
+
.reverse();
|
|
154
|
+
if (files.length === 0) return null;
|
|
155
|
+
return join(dir, files[0]);
|
|
156
|
+
} catch { }
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function cleanupTempFiles(dir, projectName) {
|
|
161
|
+
try {
|
|
162
|
+
const tempFiles = readdirSync(dir)
|
|
163
|
+
.filter(f => f.startsWith(projectName + '_') && f.endsWith('_temp.jsonl'));
|
|
164
|
+
for (const f of tempFiles) {
|
|
165
|
+
try {
|
|
166
|
+
const tempPath = join(dir, f);
|
|
167
|
+
const newPath = tempPath.replace('_temp.jsonl', '.jsonl');
|
|
168
|
+
if (existsSync(newPath)) {
|
|
169
|
+
const tempContent = readFileSync(tempPath, 'utf-8');
|
|
170
|
+
if (tempContent.trim()) {
|
|
171
|
+
appendFileSync(newPath, tempContent);
|
|
172
|
+
}
|
|
173
|
+
unlinkSync(tempPath);
|
|
174
|
+
} else {
|
|
175
|
+
renameSync(tempPath, newPath);
|
|
176
|
+
}
|
|
177
|
+
} catch { }
|
|
178
|
+
}
|
|
179
|
+
} catch { }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function migrateConversationContext(oldFile, newFile) {
|
|
183
|
+
try {
|
|
184
|
+
const content = readFileSync(oldFile, 'utf-8');
|
|
185
|
+
if (!content.trim()) return;
|
|
186
|
+
|
|
187
|
+
const parts = content.split('\n---\n').filter(p => p.trim());
|
|
188
|
+
if (parts.length === 0) return;
|
|
189
|
+
|
|
190
|
+
let originIndex = -1;
|
|
191
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
192
|
+
if (!/"mainAgent"\s*:\s*true/.test(parts[i])) continue;
|
|
193
|
+
try {
|
|
194
|
+
const entry = JSON.parse(parts[i]);
|
|
195
|
+
if (entry.mainAgent) {
|
|
196
|
+
const msgs = entry.body?.messages;
|
|
197
|
+
if (Array.isArray(msgs) && msgs.length === 1) {
|
|
198
|
+
originIndex = i;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch { }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (originIndex < 0) return;
|
|
206
|
+
|
|
207
|
+
let migrationStart = originIndex;
|
|
208
|
+
if (originIndex > 0) {
|
|
209
|
+
try {
|
|
210
|
+
const prevContent = parts[originIndex - 1];
|
|
211
|
+
if (prevContent.trim().startsWith('{')) {
|
|
212
|
+
const prev = JSON.parse(prevContent);
|
|
213
|
+
if (isPreflightEntry(prev)) {
|
|
214
|
+
migrationStart = originIndex - 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} catch { }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const migratedParts = parts.slice(migrationStart);
|
|
221
|
+
writeFileSync(newFile, migratedParts.join('\n---\n') + '\n---\n');
|
|
222
|
+
|
|
223
|
+
const remainingParts = parts.slice(0, migrationStart);
|
|
224
|
+
if (remainingParts.length > 0) {
|
|
225
|
+
writeFileSync(oldFile, remainingParts.join('\n---\n') + '\n---\n');
|
|
226
|
+
} else {
|
|
227
|
+
writeFileSync(oldFile, '');
|
|
228
|
+
}
|
|
229
|
+
} catch { }
|
|
230
|
+
}
|
package/lib/plugin-loader.js
CHANGED
|
@@ -4,6 +4,7 @@ import { LOG_DIR } from '../findcc.js';
|
|
|
4
4
|
|
|
5
5
|
export const PLUGINS_DIR = join(LOG_DIR, 'plugins');
|
|
6
6
|
const PREFS_FILE = join(LOG_DIR, 'preferences.json');
|
|
7
|
+
const SHOULD_LOG = process.env.CCV_DEBUG_PLUGINS === '1';
|
|
7
8
|
|
|
8
9
|
// Hook 类型定义
|
|
9
10
|
const HOOK_TYPES = {
|
|
@@ -33,7 +34,7 @@ export async function loadPlugins() {
|
|
|
33
34
|
disabledPlugins = prefs.disabledPlugins;
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
|
-
} catch {}
|
|
37
|
+
} catch { }
|
|
37
38
|
|
|
38
39
|
let files;
|
|
39
40
|
try {
|
|
@@ -52,16 +53,16 @@ export async function loadPlugins() {
|
|
|
52
53
|
const name = plugin.name || file;
|
|
53
54
|
|
|
54
55
|
if (disabledPlugins.includes(name)) {
|
|
55
|
-
console.error(`[CC Viewer] Plugin "${name}" is disabled, skipping.`);
|
|
56
|
+
if (SHOULD_LOG) console.error(`[CC Viewer] Plugin "${name}" is disabled, skipping.`);
|
|
56
57
|
continue;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
if (plugin.hooks && typeof plugin.hooks === 'object') {
|
|
60
61
|
_plugins.push({ name, hooks: plugin.hooks, file });
|
|
61
|
-
console.error(`[CC Viewer] Plugin loaded: ${name} (${file})`);
|
|
62
|
+
if (SHOULD_LOG) console.error(`[CC Viewer] Plugin loaded: ${name} (${file})`);
|
|
62
63
|
}
|
|
63
64
|
} catch (err) {
|
|
64
|
-
console.error(`[CC Viewer] Failed to load plugin "${file}":`, err.message);
|
|
65
|
+
if (SHOULD_LOG) console.error(`[CC Viewer] Failed to load plugin "${file}":`, err.message);
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
}
|
|
@@ -80,7 +81,7 @@ export async function runWaterfallHook(name, initialValue) {
|
|
|
80
81
|
value = { ...value, ...result };
|
|
81
82
|
}
|
|
82
83
|
} catch (err) {
|
|
83
|
-
console.error(`[CC Viewer] Plugin "${plugin.name}" hook "${name}" error:`, err.message);
|
|
84
|
+
if (SHOULD_LOG) console.error(`[CC Viewer] Plugin "${plugin.name}" hook "${name}" error:`, err.message);
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
return value;
|
|
@@ -98,7 +99,7 @@ export async function runParallelHook(name, context = {}) {
|
|
|
98
99
|
Promise.resolve()
|
|
99
100
|
.then(() => hookFn(context))
|
|
100
101
|
.catch(err => {
|
|
101
|
-
console.error(`[CC Viewer] Plugin "${plugin.name}" hook "${name}" error:`, err.message);
|
|
102
|
+
if (SHOULD_LOG) console.error(`[CC Viewer] Plugin "${plugin.name}" hook "${name}" error:`, err.message);
|
|
102
103
|
})
|
|
103
104
|
);
|
|
104
105
|
}
|
|
@@ -119,7 +120,7 @@ export function getPluginsInfo() {
|
|
|
119
120
|
disabledPlugins = prefs.disabledPlugins;
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
|
-
} catch {}
|
|
123
|
+
} catch { }
|
|
123
124
|
|
|
124
125
|
let files;
|
|
125
126
|
try {
|
package/lib/updater.js
CHANGED
|
@@ -64,7 +64,10 @@ function saveCheckTime() {
|
|
|
64
64
|
} catch { }
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
export async function checkAndUpdate() {
|
|
67
|
+
export async function checkAndUpdate(options = {}) {
|
|
68
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
69
|
+
const execImpl = options.execImpl || execSync;
|
|
70
|
+
const dryRun = options.dryRun === true;
|
|
68
71
|
const currentVersion = getCurrentVersion();
|
|
69
72
|
|
|
70
73
|
// 跟随 Claude Code 全局配置
|
|
@@ -77,7 +80,7 @@ export async function checkAndUpdate() {
|
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
try {
|
|
80
|
-
const res = await
|
|
83
|
+
const res = await fetchImpl('https://registry.npmjs.org/cc-viewer');
|
|
81
84
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
82
85
|
const data = await res.json();
|
|
83
86
|
const remoteVersion = data['dist-tags']?.latest;
|
|
@@ -104,7 +107,9 @@ export async function checkAndUpdate() {
|
|
|
104
107
|
// 同大版本:自动更新
|
|
105
108
|
console.error(`[CC Viewer] ${t('update.updating', { version: remoteVersion })}`);
|
|
106
109
|
try {
|
|
107
|
-
|
|
110
|
+
if (!dryRun) {
|
|
111
|
+
execImpl(`npm install -g cc-viewer@${remoteVersion}`, { stdio: 'pipe', timeout: 60000 });
|
|
112
|
+
}
|
|
108
113
|
console.error(`[CC Viewer] ${t('update.completed', { version: remoteVersion })}`);
|
|
109
114
|
return { status: 'updated', currentVersion, remoteVersion };
|
|
110
115
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.23",
|
|
4
4
|
"description": "Claude Code Logger visualization management tool",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"dev": "vite",
|
|
19
19
|
"build": "node build.js",
|
|
20
20
|
"start": "node server.js",
|
|
21
|
-
"test": "node --test",
|
|
21
|
+
"test": "CCV_LOG_DIR=tmp node --test",
|
|
22
22
|
"prepublishOnly": "npm run build"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
package/pty-manager.js
CHANGED
|
@@ -19,6 +19,19 @@ let lastPtyRows = 30;
|
|
|
19
19
|
const MAX_BUFFER = 200000;
|
|
20
20
|
let batchBuffer = '';
|
|
21
21
|
let batchScheduled = false;
|
|
22
|
+
let _ptyImportForTests = null;
|
|
23
|
+
|
|
24
|
+
export function _setPtyImportForTests(fn) {
|
|
25
|
+
_ptyImportForTests = fn;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function getPty() {
|
|
29
|
+
if (typeof _ptyImportForTests === 'function') {
|
|
30
|
+
return _ptyImportForTests();
|
|
31
|
+
}
|
|
32
|
+
const ptyMod = await import('node-pty');
|
|
33
|
+
return ptyMod.default || ptyMod;
|
|
34
|
+
}
|
|
22
35
|
|
|
23
36
|
/**
|
|
24
37
|
* 在 outputBuffer 截断时,找到安全的截断位置,
|
|
@@ -65,7 +78,7 @@ function flushBatch() {
|
|
|
65
78
|
const chunk = batchBuffer;
|
|
66
79
|
batchBuffer = '';
|
|
67
80
|
for (const cb of dataListeners) {
|
|
68
|
-
try { cb(chunk); } catch {}
|
|
81
|
+
try { cb(chunk); } catch { }
|
|
69
82
|
}
|
|
70
83
|
}
|
|
71
84
|
|
|
@@ -78,7 +91,7 @@ function fixSpawnHelperPermissions() {
|
|
|
78
91
|
if (!(stat.mode & 0o111)) {
|
|
79
92
|
chmodSync(helperPath, stat.mode | 0o755);
|
|
80
93
|
}
|
|
81
|
-
} catch {}
|
|
94
|
+
} catch { }
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null) {
|
|
@@ -86,8 +99,7 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
|
|
|
86
99
|
killPty();
|
|
87
100
|
}
|
|
88
101
|
|
|
89
|
-
const
|
|
90
|
-
const pty = ptyMod.default || ptyMod;
|
|
102
|
+
const pty = await getPty();
|
|
91
103
|
|
|
92
104
|
fixSpawnHelperPermissions();
|
|
93
105
|
|
|
@@ -162,7 +174,7 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
|
|
|
162
174
|
// 保留 lastWorkspacePath,不清除,用于 respawn
|
|
163
175
|
currentWorkspacePath = null;
|
|
164
176
|
for (const cb of exitListeners) {
|
|
165
|
-
try { cb(exitCode); } catch {}
|
|
177
|
+
try { cb(exitCode); } catch { }
|
|
166
178
|
}
|
|
167
179
|
});
|
|
168
180
|
|
|
@@ -183,8 +195,7 @@ export async function spawnShell() {
|
|
|
183
195
|
if (ptyProcess) return false; // 已有进程在运行
|
|
184
196
|
const cwd = lastWorkspacePath || process.cwd();
|
|
185
197
|
|
|
186
|
-
const
|
|
187
|
-
const pty = ptyMod.default || ptyMod;
|
|
198
|
+
const pty = await getPty();
|
|
188
199
|
|
|
189
200
|
fixSpawnHelperPermissions();
|
|
190
201
|
|
|
@@ -221,7 +232,7 @@ export async function spawnShell() {
|
|
|
221
232
|
ptyProcess = null;
|
|
222
233
|
currentWorkspacePath = null;
|
|
223
234
|
for (const cb of exitListeners) {
|
|
224
|
-
try { cb(exitCode); } catch {}
|
|
235
|
+
try { cb(exitCode); } catch { }
|
|
225
236
|
}
|
|
226
237
|
});
|
|
227
238
|
|
|
@@ -232,7 +243,7 @@ export function resizePty(cols, rows) {
|
|
|
232
243
|
lastPtyCols = cols;
|
|
233
244
|
lastPtyRows = rows;
|
|
234
245
|
if (ptyProcess) {
|
|
235
|
-
try { ptyProcess.resize(cols, rows); } catch {}
|
|
246
|
+
try { ptyProcess.resize(cols, rows); } catch { }
|
|
236
247
|
}
|
|
237
248
|
}
|
|
238
249
|
|
|
@@ -241,7 +252,7 @@ export function killPty() {
|
|
|
241
252
|
flushBatch();
|
|
242
253
|
batchBuffer = '';
|
|
243
254
|
batchScheduled = false;
|
|
244
|
-
try { ptyProcess.kill(); } catch {}
|
|
255
|
+
try { ptyProcess.kill(); } catch { }
|
|
245
256
|
ptyProcess = null;
|
|
246
257
|
}
|
|
247
258
|
}
|
package/workspace-registry.js
CHANGED
|
@@ -1,10 +1,56 @@
|
|
|
1
1
|
// Workspace Registry - 工作区持久化管理
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync, openSync, closeSync, renameSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join, basename, resolve } from 'node:path';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
5
|
import { LOG_DIR } from './findcc.js';
|
|
6
6
|
|
|
7
7
|
const WORKSPACES_FILE = join(LOG_DIR, 'workspaces.json');
|
|
8
|
+
const LOCK_FILE = join(LOG_DIR, 'workspaces.lock');
|
|
9
|
+
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withLock(fn) {
|
|
15
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
16
|
+
const deadline = Date.now() + 2000;
|
|
17
|
+
// 如果锁文件超过 5 秒未更新,认为它是死锁(前一个进程崩溃)
|
|
18
|
+
const STALE_THRESHOLD = 5000;
|
|
19
|
+
|
|
20
|
+
while (true) {
|
|
21
|
+
try {
|
|
22
|
+
const fd = openSync(LOCK_FILE, 'wx');
|
|
23
|
+
closeSync(fd);
|
|
24
|
+
break;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err?.code === 'EEXIST') {
|
|
27
|
+
if (Date.now() < deadline) {
|
|
28
|
+
// 检查是否为陈旧锁
|
|
29
|
+
try {
|
|
30
|
+
const stats = statSync(LOCK_FILE);
|
|
31
|
+
if (Date.now() - stats.mtimeMs > STALE_THRESHOLD) {
|
|
32
|
+
// 尝试强制移除锁
|
|
33
|
+
try { unlinkSync(LOCK_FILE); } catch { }
|
|
34
|
+
// 立即重试获取
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// stat 失败可能意味着锁刚被释放,继续循环尝试获取
|
|
39
|
+
}
|
|
40
|
+
sleep(25);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return fn();
|
|
50
|
+
} finally {
|
|
51
|
+
try { unlinkSync(LOCK_FILE); } catch { }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
8
54
|
|
|
9
55
|
export function loadWorkspaces() {
|
|
10
56
|
try {
|
|
@@ -17,45 +63,67 @@ export function loadWorkspaces() {
|
|
|
17
63
|
}
|
|
18
64
|
|
|
19
65
|
export function saveWorkspaces(list) {
|
|
66
|
+
const tmpFile = `${WORKSPACES_FILE}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
20
67
|
try {
|
|
21
68
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
22
|
-
writeFileSync(
|
|
69
|
+
writeFileSync(tmpFile, JSON.stringify({ workspaces: list }, null, 2));
|
|
70
|
+
|
|
71
|
+
// Windows 上 renameSync 可能会因为目标文件存在或被占用而失败
|
|
72
|
+
// 简单的重试机制
|
|
73
|
+
let retries = 3;
|
|
74
|
+
while (retries > 0) {
|
|
75
|
+
try {
|
|
76
|
+
renameSync(tmpFile, WORKSPACES_FILE);
|
|
77
|
+
break;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (retries === 1) throw err;
|
|
80
|
+
retries--;
|
|
81
|
+
sleep(20);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
23
84
|
} catch (err) {
|
|
24
85
|
console.error('[CC Viewer] Failed to save workspaces:', err.message);
|
|
86
|
+
// 尝试清理临时文件
|
|
87
|
+
try { unlinkSync(tmpFile); } catch { }
|
|
25
88
|
}
|
|
26
89
|
}
|
|
27
90
|
|
|
28
91
|
export function registerWorkspace(absolutePath) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
92
|
+
return withLock(() => {
|
|
93
|
+
const resolvedPath = resolve(absolutePath);
|
|
94
|
+
const projectName = basename(resolvedPath).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
95
|
+
const list = loadWorkspaces();
|
|
96
|
+
const existing = list.find(w => w.path === resolvedPath);
|
|
97
|
+
if (existing) {
|
|
98
|
+
existing.lastUsed = new Date().toISOString();
|
|
99
|
+
existing.projectName = projectName;
|
|
100
|
+
saveWorkspaces(list);
|
|
101
|
+
return existing;
|
|
102
|
+
}
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
const entry = {
|
|
105
|
+
id: randomBytes(6).toString('hex'),
|
|
106
|
+
path: resolvedPath,
|
|
107
|
+
projectName,
|
|
108
|
+
lastUsed: now,
|
|
109
|
+
createdAt: now,
|
|
110
|
+
};
|
|
111
|
+
list.push(entry);
|
|
36
112
|
saveWorkspaces(list);
|
|
37
|
-
return
|
|
38
|
-
}
|
|
39
|
-
const entry = {
|
|
40
|
-
id: randomBytes(6).toString('hex'),
|
|
41
|
-
path: resolvedPath,
|
|
42
|
-
projectName,
|
|
43
|
-
lastUsed: new Date().toISOString(),
|
|
44
|
-
createdAt: new Date().toISOString(),
|
|
45
|
-
};
|
|
46
|
-
list.push(entry);
|
|
47
|
-
saveWorkspaces(list);
|
|
48
|
-
return entry;
|
|
113
|
+
return entry;
|
|
114
|
+
});
|
|
49
115
|
}
|
|
50
116
|
|
|
51
117
|
export function removeWorkspace(id) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
118
|
+
return withLock(() => {
|
|
119
|
+
const list = loadWorkspaces();
|
|
120
|
+
const filtered = list.filter(w => w.id !== id);
|
|
121
|
+
if (filtered.length !== list.length) {
|
|
122
|
+
saveWorkspaces(filtered);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
});
|
|
59
127
|
}
|
|
60
128
|
|
|
61
129
|
export function getWorkspaces() {
|
|
@@ -71,11 +139,11 @@ export function getWorkspaces() {
|
|
|
71
139
|
for (const f of files) {
|
|
72
140
|
if (f.endsWith('.jsonl')) {
|
|
73
141
|
logCount++;
|
|
74
|
-
try { totalSize += statSync(join(logDir, f)).size; } catch {}
|
|
142
|
+
try { totalSize += statSync(join(logDir, f)).size; } catch { }
|
|
75
143
|
}
|
|
76
144
|
}
|
|
77
145
|
}
|
|
78
|
-
} catch {}
|
|
146
|
+
} catch { }
|
|
79
147
|
return { ...w, logCount, totalSize };
|
|
80
148
|
})
|
|
81
149
|
.sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed));
|