codexmate 0.0.16 → 0.0.18

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/cli.js CHANGED
@@ -38,6 +38,7 @@ const {
38
38
  writeJsonAtomic,
39
39
  formatTimestampForFileName
40
40
  } = require('./lib/cli-file-utils');
41
+ const { buildLineDiff } = require('./lib/text-diff');
41
42
  const {
42
43
  extractModelNames,
43
44
  hasModelsListPayload,
@@ -78,6 +79,9 @@ const CURRENT_MODELS_FILE = path.join(CONFIG_DIR, 'provider-current-models.json'
78
79
  const INIT_MARK_FILE = path.join(CONFIG_DIR, 'codexmate-init.json');
79
80
  const BUILTIN_PROXY_SETTINGS_FILE = path.join(CONFIG_DIR, 'codexmate-proxy.json');
80
81
  const CODEX_SESSIONS_DIR = path.join(CONFIG_DIR, 'sessions');
82
+ const SESSION_TRASH_DIR = path.join(CONFIG_DIR, 'codexmate-session-trash');
83
+ const SESSION_TRASH_FILES_DIR = path.join(SESSION_TRASH_DIR, 'files');
84
+ const SESSION_TRASH_INDEX_FILE = path.join(SESSION_TRASH_DIR, 'index.json');
81
85
  const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
82
86
  const OPENCLAW_CONFIG_FILE = path.join(OPENCLAW_DIR, 'openclaw.json');
83
87
  const OPENCLAW_WORKSPACE_DIR = path.join(OPENCLAW_DIR, 'workspace');
@@ -94,6 +98,7 @@ const DEFAULT_MODELS = ['gpt-5.3-codex', 'gpt-5.1-codex-max', 'gpt-4-turbo', 'gp
94
98
  const SPEED_TEST_TIMEOUT_MS = 8000;
95
99
  const HEALTH_CHECK_TIMEOUT_MS = 6000;
96
100
  const MAX_SESSION_LIST_SIZE = 300;
101
+ const MAX_SESSION_TRASH_LIST_SIZE = 500;
97
102
  const MAX_EXPORT_MESSAGES = 1000;
98
103
  const DEFAULT_SESSION_DETAIL_MESSAGES = 300;
99
104
  const MAX_SESSION_DETAIL_MESSAGES = 1000;
@@ -102,6 +107,7 @@ const CODEXMATE_MANAGED_MARKER = '# codexmate-managed: true';
102
107
  const SESSION_LIST_CACHE_TTL_MS = 4000;
103
108
  const SESSION_SUMMARY_READ_BYTES = 256 * 1024;
104
109
  const SESSION_CONTENT_READ_BYTES = SESSION_SUMMARY_READ_BYTES;
110
+ const EXACT_MESSAGE_COUNT_CACHE_MAX_ENTRIES = 800;
105
111
  const DEFAULT_CONTENT_SCAN_LIMIT = 50;
106
112
  const SESSION_SCAN_FACTOR = 4;
107
113
  const SESSION_SCAN_MIN_FILES = 800;
@@ -121,6 +127,7 @@ const MODELS_RESPONSE_MAX_BYTES = 1024 * 1024;
121
127
  const MAX_RECENT_CONFIGS = 3;
122
128
  const MAX_UPLOAD_SIZE = 200 * 1024 * 1024;
123
129
  const MAX_SKILLS_ZIP_UPLOAD_SIZE = 20 * 1024 * 1024;
130
+ const MAX_API_BODY_SIZE = 4 * 1024 * 1024;
124
131
  const MAX_SKILLS_ZIP_ENTRY_COUNT = 2000;
125
132
  const MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
126
133
  const DEFAULT_EXTRACT_SUFFIXES = Object.freeze(['.json']);
@@ -202,6 +209,7 @@ stream_idle_timeout_ms = 300000
202
209
 
203
210
  let g_initNotice = '';
204
211
  let g_sessionListCache = new Map();
212
+ let g_exactMessageCountCache = new Map();
205
213
  let g_modelsCache = new Map();
206
214
  let g_modelsInFlight = new Map();
207
215
  let g_builtinProxyRuntime = null;
@@ -2218,6 +2226,15 @@ function readAgentsFile(params = {}) {
2218
2226
  };
2219
2227
  }
2220
2228
 
2229
+ if (params.metaOnly) {
2230
+ return {
2231
+ exists: true,
2232
+ path: filePath,
2233
+ content: '',
2234
+ lineEnding: os.EOL === '\r\n' ? '\r\n' : '\n'
2235
+ };
2236
+ }
2237
+
2221
2238
  try {
2222
2239
  const raw = fs.readFileSync(filePath, 'utf-8');
2223
2240
  return {
@@ -2251,6 +2268,48 @@ function applyAgentsFile(params = {}) {
2251
2268
  }
2252
2269
  }
2253
2270
 
2271
+ function normalizeDiffText(input) {
2272
+ const safe = typeof input === 'string' ? input : '';
2273
+ return normalizeLineEnding(stripUtf8Bom(safe), '\n');
2274
+ }
2275
+
2276
+ function buildAgentsDiff(params = {}) {
2277
+ const hasBaseContent = typeof params.baseContent === 'string';
2278
+ const contextRaw = typeof params.context === 'string' ? params.context.trim() : '';
2279
+ const context = contextRaw || 'codex';
2280
+ const metaOnly = hasBaseContent;
2281
+ let readResult;
2282
+ if (context === 'openclaw') {
2283
+ readResult = readOpenclawAgentsFile({ metaOnly });
2284
+ } else if (context === 'openclaw-workspace') {
2285
+ readResult = readOpenclawWorkspaceFile({ ...params, metaOnly });
2286
+ } else if (context === 'codex') {
2287
+ readResult = readAgentsFile({ ...params, metaOnly });
2288
+ } else {
2289
+ return { error: `Unsupported agents diff context: ${context}` };
2290
+ }
2291
+ if (readResult && readResult.error) {
2292
+ return { error: readResult.error };
2293
+ }
2294
+
2295
+ const beforeText = normalizeDiffText(
2296
+ hasBaseContent ? params.baseContent : (readResult && readResult.content ? readResult.content : '')
2297
+ );
2298
+ const afterText = normalizeDiffText(params.content);
2299
+ const diff = buildLineDiff(beforeText, afterText);
2300
+ const hasChanges = diff.truncated ? beforeText !== afterText : (diff.stats.added > 0 || diff.stats.removed > 0);
2301
+ return {
2302
+ diff: {
2303
+ ...diff,
2304
+ hasChanges
2305
+ },
2306
+ path: readResult && readResult.path ? readResult.path : '',
2307
+ exists: !!(readResult && readResult.exists),
2308
+ context,
2309
+ configError: readResult && readResult.configError ? readResult.configError : ''
2310
+ };
2311
+ }
2312
+
2254
2313
  function resolveOpenclawWorkspaceDir(config) {
2255
2314
  const workspace = config
2256
2315
  && config.agents
@@ -2348,7 +2407,7 @@ function getOpenclawWorkspaceInfo() {
2348
2407
  };
2349
2408
  }
2350
2409
 
2351
- function readOpenclawAgentsFile() {
2410
+ function readOpenclawAgentsFile(params = {}) {
2352
2411
  const workspaceInfo = getOpenclawWorkspaceInfo();
2353
2412
  const baseDir = workspaceInfo.workspaceDir;
2354
2413
  const filePath = path.join(baseDir, AGENTS_FILE_NAME);
@@ -2365,7 +2424,7 @@ function readOpenclawAgentsFile() {
2365
2424
  };
2366
2425
  }
2367
2426
 
2368
- const readResult = readAgentsFile({ baseDir });
2427
+ const readResult = readAgentsFile({ baseDir, metaOnly: !!params.metaOnly });
2369
2428
  return {
2370
2429
  ...readResult,
2371
2430
  workspaceDir: baseDir,
@@ -2420,6 +2479,17 @@ function readOpenclawWorkspaceFile(params = {}) {
2420
2479
  };
2421
2480
  }
2422
2481
 
2482
+ if (params.metaOnly) {
2483
+ return {
2484
+ exists: true,
2485
+ path: filePath,
2486
+ content: '',
2487
+ lineEnding: os.EOL === '\r\n' ? '\r\n' : '\n',
2488
+ workspaceDir: baseDir,
2489
+ configError: workspaceInfo.configError
2490
+ };
2491
+ }
2492
+
2423
2493
  try {
2424
2494
  const raw = fs.readFileSync(filePath, 'utf-8');
2425
2495
  return {
@@ -3656,6 +3726,102 @@ function parseJsonlHeadRecords(filePath, maxBytes = SESSION_SUMMARY_READ_BYTES)
3656
3726
  return parseJsonlContent(headText);
3657
3727
  }
3658
3728
 
3729
+ function buildClaudeStoredIndexMessageCount(messageCount) {
3730
+ const safeCount = Number.isFinite(Number(messageCount))
3731
+ ? Math.max(0, Math.floor(Number(messageCount)))
3732
+ : 0;
3733
+ return safeCount + 1;
3734
+ }
3735
+
3736
+ function getFileStatSafe(filePath) {
3737
+ try {
3738
+ return fs.statSync(filePath);
3739
+ } catch (e) {
3740
+ return null;
3741
+ }
3742
+ }
3743
+
3744
+ function getFileMtimeMs(filePath, stat = null) {
3745
+ const fileStat = stat || getFileStatSafe(filePath);
3746
+ if (!fileStat || !Number.isFinite(Number(fileStat.mtimeMs))) {
3747
+ return 0;
3748
+ }
3749
+ return Math.max(0, Math.floor(Number(fileStat.mtimeMs)));
3750
+ }
3751
+
3752
+ function isSessionSummaryMessageCountExact(stat, maxBytes = SESSION_SUMMARY_READ_BYTES) {
3753
+ if (!stat || !Number.isFinite(Number(stat.size))) {
3754
+ return false;
3755
+ }
3756
+ return Number(stat.size) <= maxBytes;
3757
+ }
3758
+
3759
+ function buildExactMessageCountCacheKey(filePath, source, stat = null) {
3760
+ const validSource = source === 'claude' ? 'claude' : (source === 'codex' ? 'codex' : '');
3761
+ if (!validSource || !filePath) {
3762
+ return '';
3763
+ }
3764
+ const mtimeMs = getFileMtimeMs(filePath, stat);
3765
+ if (!mtimeMs) {
3766
+ return '';
3767
+ }
3768
+ return `${validSource}:${path.resolve(filePath)}:${mtimeMs}`;
3769
+ }
3770
+
3771
+ function readExactMessageCountCache(filePath, source, stat = null) {
3772
+ const cacheKey = buildExactMessageCountCacheKey(filePath, source, stat);
3773
+ if (!cacheKey) {
3774
+ return null;
3775
+ }
3776
+ if (!g_exactMessageCountCache.has(cacheKey)) {
3777
+ return null;
3778
+ }
3779
+ const cached = g_exactMessageCountCache.get(cacheKey);
3780
+ g_exactMessageCountCache.delete(cacheKey);
3781
+ g_exactMessageCountCache.set(cacheKey, cached);
3782
+ return Number.isFinite(Number(cached)) ? Math.max(0, Math.floor(Number(cached))) : null;
3783
+ }
3784
+
3785
+ function writeExactMessageCountCache(filePath, source, messageCount, stat = null) {
3786
+ const cacheKey = buildExactMessageCountCacheKey(filePath, source, stat);
3787
+ const safeCount = Number.isFinite(Number(messageCount))
3788
+ ? Math.max(0, Math.floor(Number(messageCount)))
3789
+ : null;
3790
+ if (!cacheKey || safeCount === null) {
3791
+ return;
3792
+ }
3793
+ if (g_exactMessageCountCache.has(cacheKey)) {
3794
+ g_exactMessageCountCache.delete(cacheKey);
3795
+ }
3796
+ g_exactMessageCountCache.set(cacheKey, safeCount);
3797
+ if (g_exactMessageCountCache.size <= EXACT_MESSAGE_COUNT_CACHE_MAX_ENTRIES) {
3798
+ return;
3799
+ }
3800
+ const firstKey = g_exactMessageCountCache.keys().next().value;
3801
+ if (firstKey) {
3802
+ g_exactMessageCountCache.delete(firstKey);
3803
+ }
3804
+ }
3805
+
3806
+ async function mapWithConcurrency(items, concurrency, mapper) {
3807
+ const list = Array.isArray(items) ? items : [];
3808
+ if (list.length === 0) {
3809
+ return [];
3810
+ }
3811
+ const safeConcurrency = Math.max(1, Math.min(Math.floor(Number(concurrency)) || 1, list.length));
3812
+ const results = new Array(list.length);
3813
+ let nextIndex = 0;
3814
+ const workers = Array.from({ length: safeConcurrency }, async () => {
3815
+ while (nextIndex < list.length) {
3816
+ const currentIndex = nextIndex;
3817
+ nextIndex += 1;
3818
+ results[currentIndex] = await mapper(list[currentIndex], currentIndex);
3819
+ }
3820
+ });
3821
+ await Promise.all(workers);
3822
+ return results.filter((item) => item !== undefined);
3823
+ }
3824
+
3659
3825
  function isBootstrapLikeText(text) {
3660
3826
  if (!text || typeof text !== 'string') {
3661
3827
  return false;
@@ -3723,6 +3889,307 @@ function countConversationMessagesInRecords(records, source) {
3723
3889
  return removeLeadingSystemMessage(messages).length;
3724
3890
  }
3725
3891
 
3892
+ async function countConversationMessagesInFile(filePath, source) {
3893
+ const fileStat = getFileStatSafe(filePath);
3894
+ const cached = readExactMessageCountCache(filePath, source, fileStat);
3895
+ if (cached !== null) {
3896
+ return cached;
3897
+ }
3898
+
3899
+ let stream;
3900
+ let rl;
3901
+ let messageCount = 0;
3902
+ let leadingSystem = true;
3903
+
3904
+ try {
3905
+ stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
3906
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
3907
+
3908
+ for await (const line of rl) {
3909
+ const trimmed = line.trim();
3910
+ if (!trimmed) continue;
3911
+
3912
+ let record;
3913
+ try {
3914
+ record = JSON.parse(trimmed);
3915
+ } catch (e) {
3916
+ continue;
3917
+ }
3918
+
3919
+ let role = '';
3920
+ let text = '';
3921
+ if (source === 'codex') {
3922
+ if (record.type === 'response_item' && record.payload && record.payload.type === 'message') {
3923
+ role = normalizeRole(record.payload.role);
3924
+ text = extractMessageText(record.payload.content);
3925
+ }
3926
+ } else {
3927
+ role = normalizeRole(record.type);
3928
+ if (role === 'assistant' || role === 'user' || role === 'system') {
3929
+ const content = record.message ? record.message.content : '';
3930
+ text = extractMessageText(content);
3931
+ } else {
3932
+ role = '';
3933
+ }
3934
+ }
3935
+ if (!role) {
3936
+ continue;
3937
+ }
3938
+
3939
+ const hasText = text.length > 0;
3940
+ if (leadingSystem && (role === 'system' || (hasText && isBootstrapLikeText(text)))) {
3941
+ continue;
3942
+ }
3943
+
3944
+ leadingSystem = false;
3945
+ messageCount += 1;
3946
+ }
3947
+ const safeCount = Math.max(0, messageCount);
3948
+ writeExactMessageCountCache(filePath, source, safeCount, fileStat);
3949
+ return safeCount;
3950
+ } catch (e) {
3951
+ const safeCount = countConversationMessagesInRecords(readJsonlRecords(filePath), source);
3952
+ writeExactMessageCountCache(filePath, source, safeCount, fileStat);
3953
+ return safeCount;
3954
+ } finally {
3955
+ if (rl) {
3956
+ try { rl.close(); } catch (e) {}
3957
+ }
3958
+ if (stream && !stream.destroyed && stream.destroy) {
3959
+ try { stream.destroy(); } catch (e) {}
3960
+ }
3961
+ }
3962
+ }
3963
+
3964
+ function appendSessionDetailTailMessage(state, record, source, lineIndex = -1) {
3965
+ if (!state || typeof state !== 'object') {
3966
+ return;
3967
+ }
3968
+
3969
+ const message = extractMessageFromRecord(record, source);
3970
+ if (!message) {
3971
+ return;
3972
+ }
3973
+
3974
+ const role = normalizeRole(message.role);
3975
+ const text = typeof message.text === 'string' ? message.text : '';
3976
+ if (!role || !text) {
3977
+ return;
3978
+ }
3979
+
3980
+ if (state.leadingSystem && (role === 'system' || isBootstrapLikeText(text))) {
3981
+ return;
3982
+ }
3983
+
3984
+ state.leadingSystem = false;
3985
+ state.totalMessages += 1;
3986
+ if (!Number.isFinite(state.tailLimit) || state.tailLimit <= 0) {
3987
+ return;
3988
+ }
3989
+
3990
+ if (state.messages.length >= state.tailLimit) {
3991
+ state.messages.shift();
3992
+ }
3993
+ state.messages.push({
3994
+ role,
3995
+ text,
3996
+ timestamp: toIsoTime(record && record.timestamp, ''),
3997
+ recordLineIndex: Number.isInteger(lineIndex) ? lineIndex : -1
3998
+ });
3999
+ }
4000
+
4001
+ function applySessionDetailRecordMetadata(record, source, state) {
4002
+ if (!state || typeof state !== 'object' || !record) {
4003
+ return;
4004
+ }
4005
+
4006
+ if (record.timestamp) {
4007
+ state.updatedAt = toIsoTime(record.timestamp, state.updatedAt);
4008
+ }
4009
+
4010
+ if (source === 'codex') {
4011
+ if (record.type === 'session_meta' && record.payload) {
4012
+ state.sessionId = record.payload.id || state.sessionId;
4013
+ state.cwd = record.payload.cwd || state.cwd;
4014
+ }
4015
+ return;
4016
+ }
4017
+
4018
+ if (!state.sessionId && record.sessionId) {
4019
+ state.sessionId = record.sessionId;
4020
+ }
4021
+ if (!state.cwd && record.cwd) {
4022
+ state.cwd = record.cwd;
4023
+ }
4024
+ }
4025
+
4026
+ function extractSessionDetailPreviewFromRecords(records, source, messageLimit) {
4027
+ const safeMessageLimit = Number.isFinite(Number(messageLimit))
4028
+ ? Math.max(1, Math.floor(Number(messageLimit)))
4029
+ : DEFAULT_SESSION_DETAIL_MESSAGES;
4030
+ const state = {
4031
+ sessionId: '',
4032
+ cwd: '',
4033
+ updatedAt: '',
4034
+ messages: [],
4035
+ tailLimit: safeMessageLimit,
4036
+ totalMessages: 0,
4037
+ leadingSystem: true
4038
+ };
4039
+
4040
+ for (let lineIndex = 0; lineIndex < records.length; lineIndex++) {
4041
+ const record = records[lineIndex];
4042
+ applySessionDetailRecordMetadata(record, source, state);
4043
+ appendSessionDetailTailMessage(state, record, source, lineIndex);
4044
+ }
4045
+
4046
+ return state;
4047
+ }
4048
+
4049
+ async function extractSessionDetailPreviewFromFile(filePath, source, messageLimit) {
4050
+ const safeMessageLimit = Number.isFinite(Number(messageLimit))
4051
+ ? Math.max(1, Math.floor(Number(messageLimit)))
4052
+ : DEFAULT_SESSION_DETAIL_MESSAGES;
4053
+ const state = {
4054
+ sessionId: '',
4055
+ cwd: '',
4056
+ updatedAt: '',
4057
+ messages: [],
4058
+ tailLimit: safeMessageLimit,
4059
+ totalMessages: 0,
4060
+ leadingSystem: true
4061
+ };
4062
+
4063
+ let stream;
4064
+ let rl;
4065
+ try {
4066
+ stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
4067
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
4068
+
4069
+ let lineIndex = 0;
4070
+ for await (const line of rl) {
4071
+ const currentLineIndex = lineIndex;
4072
+ lineIndex += 1;
4073
+
4074
+ const trimmed = line.trim();
4075
+ if (!trimmed) {
4076
+ continue;
4077
+ }
4078
+
4079
+ let record;
4080
+ try {
4081
+ record = JSON.parse(trimmed);
4082
+ } catch (e) {
4083
+ continue;
4084
+ }
4085
+
4086
+ applySessionDetailRecordMetadata(record, source, state);
4087
+ appendSessionDetailTailMessage(state, record, source, currentLineIndex);
4088
+ }
4089
+ return state;
4090
+ } catch (e) {
4091
+ return extractSessionDetailPreviewFromRecords(readJsonlRecords(filePath), source, safeMessageLimit);
4092
+ } finally {
4093
+ if (rl) {
4094
+ try { rl.close(); } catch (e) {}
4095
+ }
4096
+ if (stream && !stream.destroyed && stream.destroy) {
4097
+ try { stream.destroy(); } catch (e) {}
4098
+ }
4099
+ }
4100
+ }
4101
+
4102
+ async function resolveSessionTrashEntryExactMessageCount(entry) {
4103
+ const normalizedEntry = normalizeSessionTrashEntry(entry);
4104
+ if (!normalizedEntry) {
4105
+ return null;
4106
+ }
4107
+ const trashFilePath = resolveSessionTrashFilePath(normalizedEntry);
4108
+ if (!trashFilePath || !fs.existsSync(trashFilePath)) {
4109
+ return normalizedEntry;
4110
+ }
4111
+ const trashFileStat = getFileStatSafe(trashFilePath);
4112
+ const trashFileMtimeMs = getFileMtimeMs(trashFilePath, trashFileStat);
4113
+ if (
4114
+ Number.isFinite(Number(normalizedEntry.messageCount))
4115
+ && normalizedEntry.messageCount >= 0
4116
+ && trashFileMtimeMs > 0
4117
+ && normalizedEntry.messageCountMtimeMs === trashFileMtimeMs
4118
+ ) {
4119
+ return normalizedEntry;
4120
+ }
4121
+
4122
+ const exactMessageCount = await countConversationMessagesInFile(trashFilePath, normalizedEntry.source);
4123
+ if (!Number.isFinite(Number(exactMessageCount))) {
4124
+ return normalizedEntry;
4125
+ }
4126
+
4127
+ const safeMessageCount = Math.max(0, Math.floor(Number(exactMessageCount)));
4128
+ if (
4129
+ normalizedEntry.messageCount === safeMessageCount
4130
+ && normalizedEntry.messageCountMtimeMs === trashFileMtimeMs
4131
+ ) {
4132
+ return normalizedEntry;
4133
+ }
4134
+
4135
+ return {
4136
+ ...normalizedEntry,
4137
+ messageCount: safeMessageCount,
4138
+ messageCountMtimeMs: trashFileMtimeMs
4139
+ };
4140
+ }
4141
+
4142
+ async function hydrateSessionTrashEntries(entries, options = {}) {
4143
+ const source = options.source === 'claude' ? 'claude' : (options.source === 'codex' ? 'codex' : 'all');
4144
+ const hydratedEntries = await mapWithConcurrency(Array.isArray(entries) ? entries : [], 8, async (entry) => {
4145
+ const normalizedEntry = normalizeSessionTrashEntry(entry);
4146
+ if (!normalizedEntry) {
4147
+ return undefined;
4148
+ }
4149
+ return await resolveSessionTrashEntryExactMessageCount(normalizedEntry);
4150
+ });
4151
+
4152
+ if (source === 'codex' || source === 'claude') {
4153
+ return hydratedEntries.filter((entry) => entry.source === source);
4154
+ }
4155
+ return hydratedEntries;
4156
+ }
4157
+
4158
+ async function hydrateSessionItemsExactMessageCount(items) {
4159
+ return await mapWithConcurrency(Array.isArray(items) ? items : [], 8, async (item) => {
4160
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
4161
+ return undefined;
4162
+ }
4163
+ if (item.__messageCountExact === true) {
4164
+ return item;
4165
+ }
4166
+ const source = item.source === 'claude' ? 'claude' : (item.source === 'codex' ? 'codex' : '');
4167
+ const filePath = typeof item.filePath === 'string' ? item.filePath : '';
4168
+ if (!source || !filePath || !fs.existsSync(filePath)) {
4169
+ return item;
4170
+ }
4171
+
4172
+ const exactMessageCount = await countConversationMessagesInFile(filePath, source);
4173
+ if (!Number.isFinite(Number(exactMessageCount))) {
4174
+ return item;
4175
+ }
4176
+
4177
+ const safeMessageCount = Math.max(0, Math.floor(Number(exactMessageCount)));
4178
+ if (Number(item.messageCount) === safeMessageCount) {
4179
+ return {
4180
+ ...item,
4181
+ __messageCountExact: true
4182
+ };
4183
+ }
4184
+
4185
+ return {
4186
+ ...item,
4187
+ messageCount: safeMessageCount,
4188
+ __messageCountExact: true
4189
+ };
4190
+ });
4191
+ }
4192
+
3726
4193
  function sortSessionsByUpdatedAt(items) {
3727
4194
  items.sort((a, b) => {
3728
4195
  const aTime = Date.parse(a.updatedAt || '') || 0;
@@ -4240,6 +4707,7 @@ function parseCodexSessionSummary(filePath) {
4240
4707
  createdAt,
4241
4708
  updatedAt,
4242
4709
  messageCount,
4710
+ __messageCountExact: isSessionSummaryMessageCountExact(stat),
4243
4711
  filePath,
4244
4712
  keywords: [],
4245
4713
  capabilities: {}
@@ -4329,6 +4797,7 @@ function parseClaudeSessionSummary(filePath) {
4329
4797
  createdAt,
4330
4798
  updatedAt,
4331
4799
  messageCount,
4800
+ __messageCountExact: isSessionSummaryMessageCountExact(stat),
4332
4801
  filePath,
4333
4802
  keywords: [],
4334
4803
  capabilities: { code: true }
@@ -4425,7 +4894,8 @@ function listClaudeSessions(limit, options = {}) {
4425
4894
  }
4426
4895
  filePath = filePath ? path.resolve(filePath) : '';
4427
4896
 
4428
- if (!fs.existsSync(filePath)) {
4897
+ const fileStat = getFileStatSafe(filePath);
4898
+ if (!fileStat) {
4429
4899
  continue;
4430
4900
  }
4431
4901
 
@@ -4434,7 +4904,7 @@ function listClaudeSessions(limit, options = {}) {
4434
4904
  let title = truncateText(entry.summary || entry.firstPrompt || sessionId, 120);
4435
4905
  let messageCount = Number.isFinite(entry.messageCount) ? Math.max(0, entry.messageCount - 1) : 0;
4436
4906
 
4437
- const quickRecords = parseJsonlHeadRecords(filePath, SESSION_TITLE_READ_BYTES);
4907
+ const quickRecords = parseJsonlHeadRecords(filePath, SESSION_SUMMARY_READ_BYTES);
4438
4908
  if (quickRecords.length > 0) {
4439
4909
  const filteredCount = countConversationMessagesInRecords(quickRecords, 'claude');
4440
4910
  if (filteredCount > 0 || messageCount === 0) {
@@ -4472,6 +4942,7 @@ function listClaudeSessions(limit, options = {}) {
4472
4942
  createdAt,
4473
4943
  updatedAt,
4474
4944
  messageCount,
4945
+ __messageCountExact: quickRecords.length > 0 && isSessionSummaryMessageCountExact(fileStat),
4475
4946
  filePath,
4476
4947
  keywords,
4477
4948
  capabilities
@@ -4566,8 +5037,44 @@ function listAllSessions(params = {}) {
4566
5037
  return result;
4567
5038
  }
4568
5039
 
4569
- function listSessionPaths(params = {}) {
4570
- const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
5040
+ async function listAllSessionsData(params = {}) {
5041
+ const source = params.source === 'codex' || params.source === 'claude'
5042
+ ? params.source
5043
+ : 'all';
5044
+ const rawLimit = Number(params.limit);
5045
+ const limit = Number.isFinite(rawLimit)
5046
+ ? Math.max(1, Math.min(rawLimit, MAX_SESSION_LIST_SIZE))
5047
+ : 120;
5048
+ const forceRefresh = !!params.forceRefresh;
5049
+ const normalizedPathFilter = normalizeSessionPathFilter(params.pathFilter);
5050
+ const queryTokens = expandSessionQueryTokens(normalizeQueryTokens(params.query));
5051
+ const hasQuery = queryTokens.length > 0;
5052
+ const cacheKey = hasQuery ? '' : `exact:${source}:${limit}:${normalizedPathFilter}`;
5053
+ if (!hasQuery) {
5054
+ const cached = getSessionListCache(cacheKey, forceRefresh);
5055
+ if (cached) {
5056
+ return cached;
5057
+ }
5058
+ }
5059
+
5060
+ const sessions = listAllSessions(params);
5061
+ const hydratedSessions = await hydrateSessionItemsExactMessageCount(sessions);
5062
+ const result = hydratedSessions.map((item) => {
5063
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
5064
+ return item;
5065
+ }
5066
+ const normalized = { ...item };
5067
+ delete normalized.__messageCountExact;
5068
+ return normalized;
5069
+ });
5070
+ if (!hasQuery) {
5071
+ setSessionListCache(cacheKey, result);
5072
+ }
5073
+ return result;
5074
+ }
5075
+
5076
+ function listSessionPaths(params = {}) {
5077
+ const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
4571
5078
  if (source && source !== 'codex' && source !== 'claude' && source !== 'all') {
4572
5079
  return [];
4573
5080
  }
@@ -4647,6 +5154,19 @@ function resolveSessionFilePath(source, filePath, sessionId) {
4647
5154
  return '';
4648
5155
  }
4649
5156
 
5157
+ function getSessionFileArg(params = {}) {
5158
+ if (!params || typeof params !== 'object') {
5159
+ return '';
5160
+ }
5161
+ if (typeof params.filePath === 'string' && params.filePath.trim()) {
5162
+ return params.filePath.trim();
5163
+ }
5164
+ if (typeof params.file === 'string' && params.file.trim()) {
5165
+ return params.file.trim();
5166
+ }
5167
+ return '';
5168
+ }
5169
+
4650
5170
  function findClaudeSessionIndexPath(sessionFilePath) {
4651
5171
  const root = getClaudeProjectsDir();
4652
5172
  if (!root || !sessionFilePath) {
@@ -5086,143 +5606,798 @@ function applyBuiltinProxyProvider(params = {}) {
5086
5606
  if (saveResult && saveResult.error) {
5087
5607
  return saveResult;
5088
5608
  }
5089
-
5090
- const switchToProxy = params.switchToProxy !== false;
5091
- let targetModel = '';
5092
- if (switchToProxy) {
5093
- try {
5094
- targetModel = cmdSwitch(BUILTIN_PROXY_PROVIDER_NAME, true) || '';
5095
- } catch (e) {
5096
- return { error: `写入代理 provider 成功,但切换失败: ${e.message}` };
5609
+
5610
+ const switchToProxy = params.switchToProxy !== false;
5611
+ let targetModel = '';
5612
+ if (switchToProxy) {
5613
+ try {
5614
+ targetModel = cmdSwitch(BUILTIN_PROXY_PROVIDER_NAME, true) || '';
5615
+ } catch (e) {
5616
+ return { error: `写入代理 provider 成功,但切换失败: ${e.message}` };
5617
+ }
5618
+ }
5619
+
5620
+ return {
5621
+ success: true,
5622
+ provider: BUILTIN_PROXY_PROVIDER_NAME,
5623
+ baseUrl,
5624
+ switched: switchToProxy,
5625
+ model: targetModel
5626
+ };
5627
+ }
5628
+
5629
+ async function ensureBuiltinProxyForCodexDefault(params = {}) {
5630
+ const payload = isPlainObject(params) ? { ...params } : {};
5631
+ const switchToProxy = payload.switchToProxy !== false;
5632
+ delete payload.switchToProxy;
5633
+ payload.enabled = true;
5634
+
5635
+ const saveResult = saveBuiltinProxySettings(payload);
5636
+ if (saveResult.error) {
5637
+ return { error: saveResult.error };
5638
+ }
5639
+ let nextSettings = saveResult.settings;
5640
+
5641
+ let upstreamResult = resolveBuiltinProxyUpstream(nextSettings);
5642
+ if (upstreamResult.error) {
5643
+ return { error: upstreamResult.error };
5644
+ }
5645
+
5646
+ const runtime = g_builtinProxyRuntime;
5647
+ const shouldRestart = !!runtime && (
5648
+ runtime.settings.host !== nextSettings.host
5649
+ || runtime.settings.port !== nextSettings.port
5650
+ || runtime.settings.authSource !== nextSettings.authSource
5651
+ || runtime.settings.timeoutMs !== nextSettings.timeoutMs
5652
+ || runtime.upstream.providerName !== upstreamResult.providerName
5653
+ || runtime.upstream.baseUrl !== upstreamResult.baseUrl
5654
+ || runtime.upstream.authHeader !== upstreamResult.authHeader
5655
+ );
5656
+
5657
+ if (shouldRestart) {
5658
+ await stopBuiltinProxyRuntime();
5659
+ }
5660
+
5661
+ if (!g_builtinProxyRuntime) {
5662
+ let startRes = await startBuiltinProxyRuntime(nextSettings);
5663
+ if (!startRes.success && /EADDRINUSE/i.test(String(startRes.error || ''))) {
5664
+ const fallbackPort = await findAvailablePort(nextSettings.host, nextSettings.port + 1, 30);
5665
+ if (fallbackPort > 0) {
5666
+ const retrySave = saveBuiltinProxySettings({
5667
+ ...nextSettings,
5668
+ port: fallbackPort,
5669
+ enabled: true
5670
+ });
5671
+ if (retrySave.success) {
5672
+ nextSettings = retrySave.settings;
5673
+ upstreamResult = resolveBuiltinProxyUpstream(nextSettings);
5674
+ if (upstreamResult.error) {
5675
+ return { error: upstreamResult.error };
5676
+ }
5677
+ startRes = await startBuiltinProxyRuntime(nextSettings);
5678
+ }
5679
+ }
5680
+ }
5681
+ if (!startRes.success) {
5682
+ return { error: startRes.error || '启动内建代理失败' };
5683
+ }
5684
+ }
5685
+
5686
+ let applyRes = {
5687
+ success: true,
5688
+ provider: BUILTIN_PROXY_PROVIDER_NAME,
5689
+ baseUrl: buildProxyListenUrl(nextSettings),
5690
+ switched: false,
5691
+ model: ''
5692
+ };
5693
+ if (switchToProxy) {
5694
+ applyRes = applyBuiltinProxyProvider({ switchToProxy: true });
5695
+ if (applyRes.error) {
5696
+ return applyRes;
5697
+ }
5698
+ }
5699
+
5700
+ const status = getBuiltinProxyStatus();
5701
+ return {
5702
+ success: true,
5703
+ provider: applyRes.provider,
5704
+ baseUrl: applyRes.baseUrl,
5705
+ switched: applyRes.switched,
5706
+ model: applyRes.model || '',
5707
+ settings: status.settings,
5708
+ runtime: status.runtime
5709
+ };
5710
+ }
5711
+
5712
+ function removeClaudeSessionIndexEntry(indexPath, sessionFilePath, sessionId) {
5713
+ if (!indexPath || !fs.existsSync(indexPath)) {
5714
+ return { removed: false, entry: null };
5715
+ }
5716
+ const index = readJsonFile(indexPath, null);
5717
+ if (!index || !Array.isArray(index.entries)) {
5718
+ return { removed: false, entry: null };
5719
+ }
5720
+ const ignoreCase = process.platform === 'win32';
5721
+ const resolvedFile = sessionFilePath
5722
+ ? normalizePathForCompare(sessionFilePath, { ignoreCase })
5723
+ : '';
5724
+ let removedEntry = null;
5725
+ const filtered = index.entries.filter((entry) => {
5726
+ if (!entry || typeof entry !== 'object') {
5727
+ return false;
5728
+ }
5729
+ if (entry.fullPath) {
5730
+ const expanded = expandHomePath(entry.fullPath);
5731
+ const entryPath = expanded
5732
+ ? normalizePathForCompare(expanded, { ignoreCase })
5733
+ : '';
5734
+ if (entryPath && resolvedFile && entryPath === resolvedFile) {
5735
+ if (!removedEntry) {
5736
+ removedEntry = entry;
5737
+ }
5738
+ return false;
5739
+ }
5740
+ }
5741
+ const entrySessionId = typeof entry.sessionId === 'string' ? entry.sessionId : '';
5742
+ if (!resolvedFile && sessionId && entrySessionId === sessionId) {
5743
+ if (!removedEntry) {
5744
+ removedEntry = entry;
5745
+ }
5746
+ return false;
5747
+ }
5748
+ return true;
5749
+ });
5750
+ if (filtered.length === index.entries.length) {
5751
+ return { removed: false, entry: null };
5752
+ }
5753
+ index.entries = filtered;
5754
+ writeJsonAtomic(indexPath, index);
5755
+ return {
5756
+ removed: true,
5757
+ entry: removedEntry && typeof removedEntry === 'object'
5758
+ ? JSON.parse(JSON.stringify(removedEntry))
5759
+ : null
5760
+ };
5761
+ }
5762
+
5763
+ function moveFileSync(sourcePath, targetPath) {
5764
+ ensureDir(path.dirname(targetPath));
5765
+ try {
5766
+ fs.renameSync(sourcePath, targetPath);
5767
+ return;
5768
+ } catch (error) {
5769
+ if (!error || error.code !== 'EXDEV') {
5770
+ throw error;
5771
+ }
5772
+ }
5773
+
5774
+ fs.copyFileSync(sourcePath, targetPath);
5775
+ try {
5776
+ fs.unlinkSync(sourcePath);
5777
+ } catch (error) {
5778
+ try {
5779
+ fs.unlinkSync(targetPath);
5780
+ } catch (_) {}
5781
+ throw error;
5782
+ }
5783
+ }
5784
+
5785
+ function buildSessionSummaryFallback(source, filePath, sessionId = '') {
5786
+ const resolvedSessionId = sessionId || path.basename(filePath, '.jsonl');
5787
+ const sourceLabel = source === 'claude' ? 'Claude Code' : 'Codex';
5788
+ return {
5789
+ source,
5790
+ sourceLabel,
5791
+ provider: source === 'claude' ? 'claude' : 'codex',
5792
+ sessionId: resolvedSessionId,
5793
+ title: resolvedSessionId,
5794
+ cwd: '',
5795
+ createdAt: '',
5796
+ updatedAt: '',
5797
+ messageCount: 0,
5798
+ filePath,
5799
+ keywords: [],
5800
+ capabilities: source === 'claude' ? { code: true } : {}
5801
+ };
5802
+ }
5803
+
5804
+ function generateSessionTrashId() {
5805
+ if (crypto.randomUUID) {
5806
+ return `trash-${crypto.randomUUID()}`;
5807
+ }
5808
+ return `trash-${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
5809
+ }
5810
+
5811
+ function allocateSessionTrashTarget() {
5812
+ ensureDir(SESSION_TRASH_FILES_DIR);
5813
+ for (let attempt = 0; attempt < 6; attempt += 1) {
5814
+ const trashId = generateSessionTrashId();
5815
+ const trashFileName = `${trashId}.jsonl`;
5816
+ const trashFilePath = path.join(SESSION_TRASH_FILES_DIR, trashFileName);
5817
+ if (!fs.existsSync(trashFilePath)) {
5818
+ return { trashId, trashFileName, trashFilePath };
5819
+ }
5820
+ }
5821
+ const fallbackId = `trash-${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
5822
+ return {
5823
+ trashId: fallbackId,
5824
+ trashFileName: `${fallbackId}.jsonl`,
5825
+ trashFilePath: path.join(SESSION_TRASH_FILES_DIR, `${fallbackId}.jsonl`)
5826
+ };
5827
+ }
5828
+
5829
+ function normalizeSessionTrashEntry(entry) {
5830
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
5831
+ return null;
5832
+ }
5833
+ const source = entry.source === 'claude' ? 'claude' : (entry.source === 'codex' ? 'codex' : '');
5834
+ const trashId = typeof entry.trashId === 'string' ? entry.trashId.trim() : '';
5835
+ if (!source || !trashId || trashId.includes('/') || trashId.includes('\\') || trashId.includes('\0')) {
5836
+ return null;
5837
+ }
5838
+ const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId.trim() : '';
5839
+ const trashFileNameRaw = typeof entry.trashFileName === 'string' ? entry.trashFileName.trim() : '';
5840
+ const trashFileName = path.basename(trashFileNameRaw || `${trashId}.jsonl`);
5841
+ if (!trashFileName || trashFileName === '.' || trashFileName === '..' || trashFileName.includes('\0')) {
5842
+ return null;
5843
+ }
5844
+ return {
5845
+ trashId,
5846
+ trashFileName,
5847
+ source,
5848
+ sourceLabel: source === 'claude' ? 'Claude Code' : 'Codex',
5849
+ sessionId: sessionId || trashId,
5850
+ title: typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : (sessionId || trashId),
5851
+ cwd: typeof entry.cwd === 'string' ? entry.cwd : '',
5852
+ createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : '',
5853
+ updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : '',
5854
+ deletedAt: typeof entry.deletedAt === 'string' ? entry.deletedAt : '',
5855
+ messageCount: Number.isFinite(Number(entry.messageCount))
5856
+ ? Math.max(0, Math.floor(Number(entry.messageCount)))
5857
+ : 0,
5858
+ messageCountMtimeMs: Number.isFinite(Number(entry.messageCountMtimeMs))
5859
+ ? Math.max(0, Math.floor(Number(entry.messageCountMtimeMs)))
5860
+ : 0,
5861
+ originalFilePath: typeof entry.originalFilePath === 'string' ? entry.originalFilePath : '',
5862
+ provider: typeof entry.provider === 'string' && entry.provider.trim()
5863
+ ? entry.provider.trim()
5864
+ : (source === 'claude' ? 'claude' : 'codex'),
5865
+ keywords: normalizeKeywords(entry.keywords),
5866
+ capabilities: normalizeCapabilities(entry.capabilities),
5867
+ claudeIndexPath: typeof entry.claudeIndexPath === 'string' ? entry.claudeIndexPath : '',
5868
+ claudeIndexEntry: entry.claudeIndexEntry && typeof entry.claudeIndexEntry === 'object' && !Array.isArray(entry.claudeIndexEntry)
5869
+ ? entry.claudeIndexEntry
5870
+ : null
5871
+ };
5872
+ }
5873
+
5874
+ function resolveSessionTrashFilePath(entry) {
5875
+ const normalized = normalizeSessionTrashEntry(entry);
5876
+ if (!normalized) {
5877
+ return '';
5878
+ }
5879
+ const filePath = path.join(SESSION_TRASH_FILES_DIR, normalized.trashFileName);
5880
+ return isPathInside(filePath, SESSION_TRASH_FILES_DIR) ? filePath : '';
5881
+ }
5882
+
5883
+ function writeSessionTrashEntries(entries) {
5884
+ writeJsonAtomic(SESSION_TRASH_INDEX_FILE, {
5885
+ version: 1,
5886
+ updatedAt: new Date().toISOString(),
5887
+ entries
5888
+ });
5889
+ }
5890
+
5891
+ function readSessionTrashEntries(options = {}) {
5892
+ const cleanup = options.cleanup !== false;
5893
+ const parsed = readJsonFile(SESSION_TRASH_INDEX_FILE, null);
5894
+ if (!parsed || !Array.isArray(parsed.entries)) {
5895
+ return [];
5896
+ }
5897
+
5898
+ const normalizedEntries = [];
5899
+ let dirty = false;
5900
+ for (const rawEntry of parsed.entries) {
5901
+ const entry = normalizeSessionTrashEntry(rawEntry);
5902
+ if (!entry) {
5903
+ dirty = true;
5904
+ continue;
5905
+ }
5906
+ const trashFilePath = resolveSessionTrashFilePath(entry);
5907
+ if (!trashFilePath || !fs.existsSync(trashFilePath)) {
5908
+ dirty = true;
5909
+ continue;
5910
+ }
5911
+ normalizedEntries.push(entry);
5912
+ }
5913
+
5914
+ if (dirty && cleanup) {
5915
+ writeSessionTrashEntries(normalizedEntries);
5916
+ }
5917
+
5918
+ return normalizedEntries;
5919
+ }
5920
+
5921
+ function buildSessionTrashEntry(summary, options = {}) {
5922
+ const source = options.source === 'claude' ? 'claude' : 'codex';
5923
+ const sessionId = options.sessionId || summary.sessionId || path.basename(options.originalFilePath || summary.filePath || '', '.jsonl');
5924
+ const claudeIndexEntry = options.claudeIndexEntry && typeof options.claudeIndexEntry === 'object' && !Array.isArray(options.claudeIndexEntry)
5925
+ ? options.claudeIndexEntry
5926
+ : null;
5927
+ const deletedAt = typeof options.deletedAt === 'string' && options.deletedAt
5928
+ ? options.deletedAt
5929
+ : new Date().toISOString();
5930
+ const sourceLabel = source === 'claude' ? 'Claude Code' : 'Codex';
5931
+ const fallbackTitle = truncateText(
5932
+ (claudeIndexEntry && (claudeIndexEntry.summary || claudeIndexEntry.firstPrompt)) || sessionId,
5933
+ 120
5934
+ );
5935
+ const rawFallbackMessageCount = claudeIndexEntry && claudeIndexEntry.messageCount;
5936
+ const fallbackMessageCount = Number.isFinite(Number(rawFallbackMessageCount))
5937
+ ? Math.max(0, Number(rawFallbackMessageCount))
5938
+ : 0;
5939
+ const resolvedMessageCount = Number.isFinite(Number(summary && summary.messageCount))
5940
+ ? Math.max(0, Math.floor(Number(summary.messageCount)))
5941
+ : fallbackMessageCount;
5942
+ const messageCountMtimeMs = getFileMtimeMs(options.trashFilePath);
5943
+ const normalizedClaudeKeywords = claudeIndexEntry && Array.isArray(claudeIndexEntry.keywords)
5944
+ ? normalizeKeywords(claudeIndexEntry.keywords)
5945
+ : [];
5946
+ const normalizedClaudeCapabilities = claudeIndexEntry
5947
+ ? normalizeCapabilities(claudeIndexEntry.capabilities)
5948
+ : {};
5949
+ const normalizedSummaryKeywords = normalizeKeywords(summary.keywords);
5950
+ const normalizedSummaryCapabilities = normalizeCapabilities(summary.capabilities);
5951
+ return {
5952
+ trashId: options.trashId,
5953
+ trashFileName: options.trashFileName,
5954
+ source,
5955
+ sourceLabel,
5956
+ sessionId,
5957
+ title: summary.title || fallbackTitle || sessionId,
5958
+ cwd: summary.cwd || (claudeIndexEntry && typeof claudeIndexEntry.projectPath === 'string' ? claudeIndexEntry.projectPath : ''),
5959
+ createdAt: summary.createdAt || toIsoTime(claudeIndexEntry && claudeIndexEntry.created, ''),
5960
+ updatedAt: summary.updatedAt || toIsoTime(claudeIndexEntry && (claudeIndexEntry.modified || claudeIndexEntry.fileMtime), ''),
5961
+ deletedAt,
5962
+ messageCount: resolvedMessageCount,
5963
+ messageCountMtimeMs,
5964
+ originalFilePath: options.originalFilePath || summary.filePath || '',
5965
+ provider: (claudeIndexEntry && typeof claudeIndexEntry.provider === 'string' && claudeIndexEntry.provider.trim())
5966
+ ? claudeIndexEntry.provider.trim()
5967
+ : (summary.provider || (source === 'claude' ? 'claude' : 'codex')),
5968
+ keywords: normalizedClaudeKeywords.length > 0 ? normalizedClaudeKeywords : normalizedSummaryKeywords,
5969
+ capabilities: Object.keys(normalizedClaudeCapabilities).length > 0
5970
+ ? normalizedClaudeCapabilities
5971
+ : normalizedSummaryCapabilities,
5972
+ claudeIndexPath: typeof options.claudeIndexPath === 'string' ? options.claudeIndexPath : '',
5973
+ claudeIndexEntry
5974
+ };
5975
+ }
5976
+
5977
+ function resolveSessionRestoreTarget(entry) {
5978
+ const normalized = normalizeSessionTrashEntry(entry);
5979
+ if (!normalized) {
5980
+ return '';
5981
+ }
5982
+ const root = normalized.source === 'claude' ? getClaudeProjectsDir() : getCodexSessionsDir();
5983
+ const originalFilePath = typeof normalized.originalFilePath === 'string' ? normalized.originalFilePath.trim() : '';
5984
+ if (!root || !originalFilePath) {
5985
+ return '';
5986
+ }
5987
+ const expanded = expandHomePath(originalFilePath);
5988
+ const resolved = expanded ? path.resolve(expanded) : '';
5989
+ if (!resolved || !isPathInside(resolved, root)) {
5990
+ return '';
5991
+ }
5992
+ return resolved;
5993
+ }
5994
+
5995
+ function resolveClaudeSessionRestoreIndexPath(entry, targetFilePath) {
5996
+ const fallbackIndexPath = findClaudeSessionIndexPath(targetFilePath) || path.join(path.dirname(targetFilePath), 'sessions-index.json');
5997
+ const fallbackResolved = fallbackIndexPath ? path.resolve(fallbackIndexPath) : '';
5998
+ const candidateRaw = entry && typeof entry.claudeIndexPath === 'string' ? entry.claudeIndexPath.trim() : '';
5999
+ if (!candidateRaw) {
6000
+ return fallbackResolved;
6001
+ }
6002
+ const claudeProjectsDir = getClaudeProjectsDir();
6003
+ if (!claudeProjectsDir) {
6004
+ return fallbackResolved;
6005
+ }
6006
+ const candidateIndexPath = path.resolve(candidateRaw);
6007
+ if (path.basename(candidateIndexPath).toLowerCase() !== 'sessions-index.json') {
6008
+ return fallbackResolved;
6009
+ }
6010
+ if (!isPathInside(candidateIndexPath, claudeProjectsDir)) {
6011
+ return fallbackResolved;
6012
+ }
6013
+ if (!isPathInside(targetFilePath, path.dirname(candidateIndexPath))) {
6014
+ return fallbackResolved;
6015
+ }
6016
+ return candidateIndexPath;
6017
+ }
6018
+
6019
+ function buildClaudeSessionIndexEntry(entry, sessionFilePath) {
6020
+ const normalized = normalizeSessionTrashEntry(entry);
6021
+ const stored = normalized && normalized.claudeIndexEntry && typeof normalized.claudeIndexEntry === 'object'
6022
+ ? JSON.parse(JSON.stringify(normalized.claudeIndexEntry))
6023
+ : {};
6024
+ const storedCapabilities = stored && stored.capabilities && typeof stored.capabilities === 'object' && !Array.isArray(stored.capabilities)
6025
+ ? stored.capabilities
6026
+ : null;
6027
+ const storedKeywords = Array.isArray(stored && stored.keywords)
6028
+ ? stored.keywords
6029
+ : null;
6030
+ const normalizedMessageCount = Number(normalized && normalized.messageCount);
6031
+ const storedMessageCount = Number(stored && stored.messageCount);
6032
+ let modifiedAt = '';
6033
+ try {
6034
+ modifiedAt = fs.statSync(sessionFilePath).mtime.toISOString();
6035
+ } catch (e) {
6036
+ modifiedAt = normalized && normalized.updatedAt ? normalized.updatedAt : new Date().toISOString();
6037
+ }
6038
+ const projectDir = path.dirname(sessionFilePath);
6039
+ return {
6040
+ ...stored,
6041
+ sessionId: normalized.sessionId,
6042
+ fullPath: sessionFilePath,
6043
+ projectPath: (stored && typeof stored.projectPath === 'string' && stored.projectPath.trim())
6044
+ ? stored.projectPath.trim()
6045
+ : projectDir,
6046
+ created: (stored && typeof stored.created === 'string' && stored.created.trim())
6047
+ ? stored.created.trim()
6048
+ : (normalized.createdAt || modifiedAt),
6049
+ modified: modifiedAt,
6050
+ summary: (stored && typeof stored.summary === 'string' && stored.summary.trim())
6051
+ ? stored.summary.trim()
6052
+ : (normalized.title || normalized.sessionId),
6053
+ provider: (stored && typeof stored.provider === 'string' && stored.provider.trim())
6054
+ ? stored.provider.trim()
6055
+ : (normalized.provider || 'claude'),
6056
+ capabilities: normalizeCapabilities(
6057
+ storedCapabilities && Object.keys(storedCapabilities).length > 0
6058
+ ? storedCapabilities
6059
+ : normalized.capabilities
6060
+ ),
6061
+ keywords: normalizeKeywords(
6062
+ storedKeywords && storedKeywords.length > 0
6063
+ ? storedKeywords
6064
+ : normalized.keywords
6065
+ ),
6066
+ messageCount: Number.isFinite(normalizedMessageCount)
6067
+ ? buildClaudeStoredIndexMessageCount(normalizedMessageCount)
6068
+ : (
6069
+ Number.isFinite(storedMessageCount)
6070
+ ? Math.max(0, Math.floor(storedMessageCount))
6071
+ : buildClaudeStoredIndexMessageCount(normalized && normalized.messageCount)
6072
+ )
6073
+ };
6074
+ }
6075
+
6076
+ function upsertClaudeSessionIndexEntry(indexPath, sessionFilePath, entry) {
6077
+ if (!indexPath) {
6078
+ return;
6079
+ }
6080
+ const parsed = readJsonFile(indexPath, null);
6081
+ const index = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
6082
+ ? parsed
6083
+ : {};
6084
+ const entries = Array.isArray(index.entries) ? index.entries : [];
6085
+ const ignoreCase = process.platform === 'win32';
6086
+ const resolvedFile = normalizePathForCompare(sessionFilePath, { ignoreCase });
6087
+ const normalizedEntry = normalizeSessionTrashEntry(entry);
6088
+ const filtered = entries.filter((item) => {
6089
+ if (!item || typeof item !== 'object') {
6090
+ return false;
6091
+ }
6092
+ if (typeof item.fullPath === 'string' && item.fullPath) {
6093
+ const expanded = expandHomePath(item.fullPath);
6094
+ const itemPath = expanded
6095
+ ? normalizePathForCompare(expanded, { ignoreCase })
6096
+ : '';
6097
+ if (itemPath && itemPath === resolvedFile) {
6098
+ return false;
6099
+ }
6100
+ }
6101
+ const itemSessionId = typeof item.sessionId === 'string' ? item.sessionId : '';
6102
+ if (!resolvedFile && normalizedEntry.sessionId && itemSessionId === normalizedEntry.sessionId) {
6103
+ return false;
5097
6104
  }
6105
+ return true;
6106
+ });
6107
+ filtered.unshift(buildClaudeSessionIndexEntry(normalizedEntry, sessionFilePath));
6108
+ index.entries = filtered;
6109
+ if (!index.originalPath) {
6110
+ index.originalPath = path.dirname(indexPath);
5098
6111
  }
6112
+ writeJsonAtomic(indexPath, index);
6113
+ }
5099
6114
 
6115
+ async function listSessionTrashItems(params = {}) {
6116
+ const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : 'all');
6117
+ const countOnly = params.countOnly === true;
6118
+ const rawLimit = Number(params.limit);
6119
+ const limit = Number.isFinite(rawLimit)
6120
+ ? Math.max(1, Math.min(rawLimit, MAX_SESSION_TRASH_LIST_SIZE))
6121
+ : 200;
6122
+ const allEntries = readSessionTrashEntries();
6123
+ let items = source === 'codex' || source === 'claude'
6124
+ ? allEntries.filter((entry) => entry.source === source)
6125
+ : allEntries.slice();
6126
+ items.sort((a, b) => {
6127
+ const aTime = Date.parse(a.deletedAt || a.updatedAt || '') || 0;
6128
+ const bTime = Date.parse(b.deletedAt || b.updatedAt || '') || 0;
6129
+ return bTime - aTime;
6130
+ });
6131
+ const totalCount = items.length;
6132
+ if (countOnly) {
6133
+ return {
6134
+ totalCount,
6135
+ items: []
6136
+ };
6137
+ }
6138
+ const visibleEntries = items.slice(0, limit);
6139
+ const hydratedVisibleEntries = await hydrateSessionTrashEntries(visibleEntries, { source });
6140
+ const updatedEntriesById = new Map();
6141
+ for (let index = 0; index < visibleEntries.length; index += 1) {
6142
+ const originalEntry = visibleEntries[index];
6143
+ const hydratedEntry = hydratedVisibleEntries[index];
6144
+ if (!originalEntry || !hydratedEntry) {
6145
+ continue;
6146
+ }
6147
+ if (
6148
+ originalEntry.messageCount !== hydratedEntry.messageCount
6149
+ || originalEntry.messageCountMtimeMs !== hydratedEntry.messageCountMtimeMs
6150
+ ) {
6151
+ updatedEntriesById.set(originalEntry.trashId, hydratedEntry);
6152
+ }
6153
+ }
6154
+ if (updatedEntriesById.size > 0) {
6155
+ const latestEntries = readSessionTrashEntries({ cleanup: false });
6156
+ writeSessionTrashEntries(latestEntries.map((entry) => updatedEntriesById.get(entry.trashId) || entry));
6157
+ }
5100
6158
  return {
5101
- success: true,
5102
- provider: BUILTIN_PROXY_PROVIDER_NAME,
5103
- baseUrl,
5104
- switched: switchToProxy,
5105
- model: targetModel
6159
+ totalCount,
6160
+ items: hydratedVisibleEntries.map((item) => ({
6161
+ ...item,
6162
+ trashFilePath: resolveSessionTrashFilePath(item)
6163
+ }))
5106
6164
  };
5107
6165
  }
5108
6166
 
5109
- async function ensureBuiltinProxyForCodexDefault(params = {}) {
5110
- const payload = isPlainObject(params) ? { ...params } : {};
5111
- const switchToProxy = payload.switchToProxy !== false;
5112
- delete payload.switchToProxy;
5113
- payload.enabled = true;
5114
-
5115
- const saveResult = saveBuiltinProxySettings(payload);
5116
- if (saveResult.error) {
5117
- return { error: saveResult.error };
6167
+ async function restoreSessionTrashItem(params = {}) {
6168
+ const trashId = typeof params.trashId === 'string' ? params.trashId.trim() : '';
6169
+ if (!trashId) {
6170
+ return { error: '请先选择要恢复的回收站记录' };
5118
6171
  }
5119
- let nextSettings = saveResult.settings;
5120
6172
 
5121
- let upstreamResult = resolveBuiltinProxyUpstream(nextSettings);
5122
- if (upstreamResult.error) {
5123
- return { error: upstreamResult.error };
6173
+ const entries = readSessionTrashEntries();
6174
+ const entry = entries.find((item) => item.trashId === trashId);
6175
+ if (!entry) {
6176
+ return { error: '回收站记录不存在' };
6177
+ }
6178
+ const hydratedEntry = await resolveSessionTrashEntryExactMessageCount(entry);
6179
+ if (!hydratedEntry) {
6180
+ return { error: '回收站记录不存在' };
5124
6181
  }
5125
6182
 
5126
- const runtime = g_builtinProxyRuntime;
5127
- const shouldRestart = !!runtime && (
5128
- runtime.settings.host !== nextSettings.host
5129
- || runtime.settings.port !== nextSettings.port
5130
- || runtime.settings.authSource !== nextSettings.authSource
5131
- || runtime.settings.timeoutMs !== nextSettings.timeoutMs
5132
- || runtime.upstream.providerName !== upstreamResult.providerName
5133
- || runtime.upstream.baseUrl !== upstreamResult.baseUrl
5134
- || runtime.upstream.authHeader !== upstreamResult.authHeader
5135
- );
6183
+ const trashFilePath = resolveSessionTrashFilePath(hydratedEntry);
6184
+ if (!trashFilePath || !fs.existsSync(trashFilePath)) {
6185
+ return { error: '回收站文件不存在' };
6186
+ }
5136
6187
 
5137
- if (shouldRestart) {
5138
- await stopBuiltinProxyRuntime();
6188
+ const targetFilePath = resolveSessionRestoreTarget(hydratedEntry);
6189
+ if (!targetFilePath) {
6190
+ return { error: '原始会话路径非法,无法恢复' };
6191
+ }
6192
+ if (fs.existsSync(targetFilePath)) {
6193
+ return { error: '原始会话路径已存在同名文件,请先手动处理冲突' };
5139
6194
  }
5140
6195
 
5141
- if (!g_builtinProxyRuntime) {
5142
- let startRes = await startBuiltinProxyRuntime(nextSettings);
5143
- if (!startRes.success && /EADDRINUSE/i.test(String(startRes.error || ''))) {
5144
- const fallbackPort = await findAvailablePort(nextSettings.host, nextSettings.port + 1, 30);
5145
- if (fallbackPort > 0) {
5146
- const retrySave = saveBuiltinProxySettings({
5147
- ...nextSettings,
5148
- port: fallbackPort,
5149
- enabled: true
5150
- });
5151
- if (retrySave.success) {
5152
- nextSettings = retrySave.settings;
5153
- upstreamResult = resolveBuiltinProxyUpstream(nextSettings);
5154
- if (upstreamResult.error) {
5155
- return { error: upstreamResult.error };
5156
- }
5157
- startRes = await startBuiltinProxyRuntime(nextSettings);
5158
- }
5159
- }
6196
+ let claudeIndexPath = '';
6197
+ try {
6198
+ const latestEntries = readSessionTrashEntries({ cleanup: false });
6199
+ const latestEntry = latestEntries.find((item) => item && item.trashId === trashId);
6200
+ if (!latestEntry) {
6201
+ return { error: '回收站记录不存在' };
6202
+ }
6203
+ const remainingEntries = latestEntries.filter((item) => item.trashId !== trashId);
6204
+ moveFileSync(trashFilePath, targetFilePath);
6205
+ if (hydratedEntry.source === 'claude') {
6206
+ claudeIndexPath = resolveClaudeSessionRestoreIndexPath(hydratedEntry, targetFilePath);
6207
+ upsertClaudeSessionIndexEntry(claudeIndexPath, targetFilePath, hydratedEntry);
6208
+ }
6209
+ writeSessionTrashEntries(remainingEntries);
6210
+ } catch (e) {
6211
+ let rollbackSucceeded = false;
6212
+ if (fs.existsSync(targetFilePath) && !fs.existsSync(trashFilePath)) {
6213
+ try {
6214
+ moveFileSync(targetFilePath, trashFilePath);
6215
+ rollbackSucceeded = true;
6216
+ } catch (_) {}
5160
6217
  }
5161
- if (!startRes.success) {
5162
- return { error: startRes.error || '启动内建代理失败' };
6218
+ if (rollbackSucceeded && entry.source === 'claude' && claudeIndexPath && fs.existsSync(claudeIndexPath)) {
6219
+ try {
6220
+ removeClaudeSessionIndexEntry(claudeIndexPath, targetFilePath, entry.sessionId);
6221
+ } catch (_) {}
5163
6222
  }
6223
+ return { error: `恢复会话失败: ${e.message}` };
5164
6224
  }
5165
6225
 
5166
- let applyRes = {
6226
+ invalidateSessionListCache();
6227
+
6228
+ return {
5167
6229
  success: true,
5168
- provider: BUILTIN_PROXY_PROVIDER_NAME,
5169
- baseUrl: buildProxyListenUrl(nextSettings),
5170
- switched: false,
5171
- model: ''
6230
+ restored: true,
6231
+ trashId,
6232
+ source: entry.source,
6233
+ sessionId: entry.sessionId,
6234
+ filePath: targetFilePath
5172
6235
  };
5173
- if (switchToProxy) {
5174
- applyRes = applyBuiltinProxyProvider({ switchToProxy: true });
5175
- if (applyRes.error) {
5176
- return applyRes;
6236
+ }
6237
+
6238
+ async function purgeSessionTrashItems(params = {}) {
6239
+ const entries = readSessionTrashEntries();
6240
+ if (entries.length === 0) {
6241
+ return { success: true, purged: [], count: 0 };
6242
+ }
6243
+
6244
+ const all = params.all === true;
6245
+ const trashIds = Array.isArray(params.trashIds)
6246
+ ? params.trashIds
6247
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
6248
+ .filter(Boolean)
6249
+ : [];
6250
+ const singleTrashId = typeof params.trashId === 'string' ? params.trashId.trim() : '';
6251
+ const targetIds = all
6252
+ ? new Set(entries.map((item) => item.trashId))
6253
+ : new Set(singleTrashId ? [singleTrashId, ...trashIds] : trashIds);
6254
+
6255
+ if (targetIds.size === 0) {
6256
+ return { error: '请先选择要彻底删除的回收站记录' };
6257
+ }
6258
+
6259
+ const purged = [];
6260
+ const remaining = [];
6261
+ let purgeError = null;
6262
+ for (let index = 0; index < entries.length; index += 1) {
6263
+ const entry = entries[index];
6264
+ if (!targetIds.has(entry.trashId)) {
6265
+ remaining.push(entry);
6266
+ continue;
6267
+ }
6268
+ const trashFilePath = resolveSessionTrashFilePath(entry);
6269
+ if (trashFilePath && fs.existsSync(trashFilePath)) {
6270
+ try {
6271
+ fs.unlinkSync(trashFilePath);
6272
+ } catch (e) {
6273
+ if (!purgeError) purgeError = e;
6274
+ remaining.push(entry);
6275
+ continue;
6276
+ }
5177
6277
  }
6278
+ purged.push({
6279
+ trashId: entry.trashId,
6280
+ source: entry.source,
6281
+ sessionId: entry.sessionId
6282
+ });
6283
+ }
6284
+
6285
+ try {
6286
+ writeSessionTrashEntries(remaining);
6287
+ } catch (e) {
6288
+ return { error: `回收站索引更新失败: ${e.message}` };
6289
+ }
6290
+
6291
+ if (purgeError) {
6292
+ return { error: `彻底删除失败: ${purgeError.message}` };
5178
6293
  }
5179
6294
 
5180
- const status = getBuiltinProxyStatus();
5181
6295
  return {
5182
6296
  success: true,
5183
- provider: applyRes.provider,
5184
- baseUrl: applyRes.baseUrl,
5185
- switched: applyRes.switched,
5186
- model: applyRes.model || '',
5187
- settings: status.settings,
5188
- runtime: status.runtime
6297
+ purged,
6298
+ count: purged.length
5189
6299
  };
5190
6300
  }
5191
6301
 
5192
- function updateClaudeSessionIndex(indexPath, sessionFilePath, sessionId) {
5193
- if (!indexPath || !fs.existsSync(indexPath)) {
5194
- return;
6302
+ async function trashSessionData(params = {}) {
6303
+ const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
6304
+ if (!source) {
6305
+ return { error: 'Invalid source' };
5195
6306
  }
5196
- const index = readJsonFile(indexPath, null);
5197
- if (!index || !Array.isArray(index.entries)) {
5198
- return;
6307
+
6308
+ const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
6309
+ if (!filePath) {
6310
+ return { error: 'Session file not found' };
5199
6311
  }
5200
- const resolvedFile = sessionFilePath ? path.resolve(sessionFilePath) : '';
5201
- const resolvedLower = resolvedFile ? resolvedFile.toLowerCase() : '';
5202
- const filtered = index.entries.filter((entry) => {
5203
- if (!entry || typeof entry !== 'object') {
5204
- return false;
6312
+
6313
+ const summary = (source === 'claude' ? parseClaudeSessionSummary(filePath) : parseCodexSessionSummary(filePath))
6314
+ || buildSessionSummaryFallback(source, filePath, params.sessionId);
6315
+ const exactMessageCount = await countConversationMessagesInFile(filePath, source);
6316
+ if (Number.isFinite(Number(exactMessageCount))) {
6317
+ summary.messageCount = Math.max(0, Math.floor(Number(exactMessageCount)));
6318
+ }
6319
+ const sessionId = summary.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
6320
+ const { trashId, trashFileName, trashFilePath } = allocateSessionTrashTarget();
6321
+ const deletedAt = new Date().toISOString();
6322
+ const claudeIndexPath = source === 'claude' ? findClaudeSessionIndexPath(filePath) : '';
6323
+ let removedClaudeIndexEntry = null;
6324
+
6325
+ try {
6326
+ moveFileSync(filePath, trashFilePath);
6327
+ } catch (e) {
6328
+ return { error: `移入回收站失败: ${e.message}` };
6329
+ }
6330
+
6331
+ try {
6332
+ if (source === 'claude' && claudeIndexPath) {
6333
+ const removal = removeClaudeSessionIndexEntry(claudeIndexPath, filePath, sessionId);
6334
+ removedClaudeIndexEntry = removal && removal.entry ? removal.entry : null;
6335
+ }
6336
+ const entry = buildSessionTrashEntry(summary, {
6337
+ trashId,
6338
+ trashFileName,
6339
+ trashFilePath,
6340
+ source,
6341
+ sessionId,
6342
+ deletedAt,
6343
+ originalFilePath: filePath,
6344
+ claudeIndexPath,
6345
+ claudeIndexEntry: removedClaudeIndexEntry
6346
+ });
6347
+ const entries = readSessionTrashEntries({ cleanup: false });
6348
+ const totalCount = entries.length + 1;
6349
+ const nextEntries = [entry, ...entries].slice(0, MAX_SESSION_TRASH_LIST_SIZE);
6350
+ writeSessionTrashEntries(nextEntries);
6351
+ summary.totalCount = Math.min(totalCount, MAX_SESSION_TRASH_LIST_SIZE);
6352
+ } catch (e) {
6353
+ let rollbackSucceeded = false;
6354
+ if (fs.existsSync(trashFilePath) && !fs.existsSync(filePath)) {
6355
+ try {
6356
+ moveFileSync(trashFilePath, filePath);
6357
+ rollbackSucceeded = true;
6358
+ } catch (_) {}
5205
6359
  }
5206
- const entrySessionId = typeof entry.sessionId === 'string' ? entry.sessionId : '';
5207
- if (sessionId && entrySessionId === sessionId) {
5208
- return false;
6360
+ if (rollbackSucceeded && source === 'claude' && claudeIndexPath && removedClaudeIndexEntry) {
6361
+ try {
6362
+ upsertClaudeSessionIndexEntry(claudeIndexPath, filePath, {
6363
+ source,
6364
+ sessionId,
6365
+ title: summary.title,
6366
+ messageCount: summary.messageCount,
6367
+ capabilities: summary.capabilities,
6368
+ keywords: summary.keywords,
6369
+ updatedAt: summary.updatedAt,
6370
+ createdAt: summary.createdAt,
6371
+ claudeIndexEntry: removedClaudeIndexEntry,
6372
+ originalFilePath: filePath,
6373
+ trashId,
6374
+ trashFileName
6375
+ });
6376
+ } catch (_) {}
5209
6377
  }
5210
- if (entry.fullPath) {
5211
- const expanded = expandHomePath(entry.fullPath);
5212
- const entryPath = expanded ? path.resolve(expanded) : '';
5213
- if (entryPath && resolvedLower && entryPath.toLowerCase() === resolvedLower) {
5214
- return false;
5215
- }
6378
+ if (!rollbackSucceeded && fs.existsSync(trashFilePath)) {
6379
+ try { fs.unlinkSync(trashFilePath); } catch (_) {}
5216
6380
  }
5217
- return true;
5218
- });
5219
- if (filtered.length === index.entries.length) {
5220
- return;
6381
+ return { error: `移入回收站失败: ${e.message}` };
5221
6382
  }
5222
- index.entries = filtered;
5223
- try {
5224
- fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf-8');
5225
- } catch (e) {}
6383
+
6384
+ invalidateSessionListCache();
6385
+
6386
+ return {
6387
+ success: true,
6388
+ source,
6389
+ sessionId,
6390
+ filePath,
6391
+ trashed: true,
6392
+ trashId,
6393
+ deletedAt,
6394
+ totalCount: Number.isFinite(Number(summary && summary.totalCount))
6395
+ ? Math.max(0, Math.floor(Number(summary.totalCount)))
6396
+ : undefined,
6397
+ messageCount: Number.isFinite(Number(summary && summary.messageCount))
6398
+ ? Math.max(0, Math.floor(Number(summary.messageCount)))
6399
+ : 0
6400
+ };
5226
6401
  }
5227
6402
 
5228
6403
  async function deleteSessionData(params = {}) {
@@ -5231,14 +6406,16 @@ async function deleteSessionData(params = {}) {
5231
6406
  return { error: 'Invalid source' };
5232
6407
  }
5233
6408
 
5234
- const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
6409
+ const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
5235
6410
  if (!filePath) {
5236
6411
  return { error: 'Session file not found' };
5237
6412
  }
5238
6413
 
5239
6414
  const sessionId = params.sessionId || path.basename(filePath, '.jsonl');
6415
+ let fileDeleted = false;
5240
6416
  try {
5241
6417
  fs.unlinkSync(filePath);
6418
+ fileDeleted = true;
5242
6419
  } catch (e) {
5243
6420
  return { error: `删除会话失败: ${e.message}` };
5244
6421
  }
@@ -5246,7 +6423,14 @@ async function deleteSessionData(params = {}) {
5246
6423
  if (source === 'claude') {
5247
6424
  const indexPath = findClaudeSessionIndexPath(filePath);
5248
6425
  if (indexPath) {
5249
- updateClaudeSessionIndex(indexPath, filePath, sessionId);
6426
+ try {
6427
+ removeClaudeSessionIndexEntry(indexPath, filePath, sessionId);
6428
+ } catch (e) {
6429
+ console.warn('删除会话索引失败:', e && e.message ? e.message : e);
6430
+ if (!fileDeleted) {
6431
+ return { error: `删除会话失败: ${e.message || e}` };
6432
+ }
6433
+ }
5250
6434
  }
5251
6435
  }
5252
6436
 
@@ -5256,7 +6440,8 @@ async function deleteSessionData(params = {}) {
5256
6440
  success: true,
5257
6441
  source,
5258
6442
  sessionId,
5259
- filePath
6443
+ filePath,
6444
+ deleted: true
5260
6445
  };
5261
6446
  }
5262
6447
 
@@ -5311,7 +6496,7 @@ async function cloneCodexSession(params = {}) {
5311
6496
  return { error: '仅支持 Codex 会话克隆' };
5312
6497
  }
5313
6498
 
5314
- const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
6499
+ const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
5315
6500
  if (!filePath) {
5316
6501
  return { error: 'Session file not found' };
5317
6502
  }
@@ -5690,26 +6875,26 @@ async function readSessionDetail(params = {}) {
5690
6875
  return { error: 'Invalid source' };
5691
6876
  }
5692
6877
 
5693
- const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
6878
+ const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
5694
6879
  if (!filePath) {
5695
6880
  return { error: 'Session file not found' };
5696
6881
  }
5697
6882
 
5698
- const rawLimit = Number(params.messageLimit);
6883
+ const rawMaxMessages = Number(params.maxMessages);
6884
+ const rawLimit = Number.isFinite(rawMaxMessages) ? rawMaxMessages : Number(params.messageLimit);
5699
6885
  const messageLimit = Number.isFinite(rawLimit)
5700
6886
  ? Math.max(1, Math.min(rawLimit, MAX_SESSION_DETAIL_MESSAGES))
5701
6887
  : DEFAULT_SESSION_DETAIL_MESSAGES;
5702
6888
 
5703
- const extracted = await extractMessagesFromFile(filePath, source);
6889
+ const extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit);
5704
6890
  const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
5705
6891
  const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code';
5706
- const allMessages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : [])
5707
- .map((message, messageIndex) => ({
5708
- ...message,
5709
- messageIndex
5710
- }));
5711
- const startIndex = Math.max(0, allMessages.length - messageLimit);
5712
- const clippedMessages = allMessages.slice(startIndex);
6892
+ const clippedMessages = Array.isArray(extracted.messages) ? extracted.messages : [];
6893
+ const startIndex = Math.max(0, extracted.totalMessages - clippedMessages.length);
6894
+ const indexedMessages = clippedMessages.map((message, messageIndex) => ({
6895
+ ...message,
6896
+ messageIndex: startIndex + messageIndex
6897
+ }));
5713
6898
 
5714
6899
  return {
5715
6900
  source,
@@ -5717,10 +6902,10 @@ async function readSessionDetail(params = {}) {
5717
6902
  sessionId,
5718
6903
  cwd: extracted.cwd || '',
5719
6904
  updatedAt: extracted.updatedAt || '',
5720
- totalMessages: allMessages.length,
5721
- clipped: allMessages.length > clippedMessages.length,
6905
+ totalMessages: extracted.totalMessages,
6906
+ clipped: extracted.totalMessages > indexedMessages.length,
5722
6907
  messageLimit,
5723
- messages: clippedMessages,
6908
+ messages: indexedMessages,
5724
6909
  filePath
5725
6910
  };
5726
6911
  }
@@ -5731,7 +6916,7 @@ async function readSessionPlain(params = {}) {
5731
6916
  return { error: 'Invalid source' };
5732
6917
  }
5733
6918
 
5734
- const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
6919
+ const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
5735
6920
  if (!filePath) {
5736
6921
  return { error: 'Session file not found' };
5737
6922
  }
@@ -5777,7 +6962,7 @@ async function exportSessionData(params = {}) {
5777
6962
  }
5778
6963
 
5779
6964
  const maxMessages = resolveMaxMessagesValue(params.maxMessages, MAX_EXPORT_MESSAGES);
5780
- const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
6965
+ const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
5781
6966
  if (!filePath) {
5782
6967
  return { error: 'Session file not found' };
5783
6968
  }
@@ -5891,8 +7076,15 @@ function buildProviderSharePayload(params = {}) {
5891
7076
 
5892
7077
  const baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : '';
5893
7078
  const apiKey = typeof provider.preferred_auth_method === 'string'
5894
- ? provider.preferred_auth_method
7079
+ ? provider.preferred_auth_method.trim()
7080
+ : '';
7081
+ const currentModels = readCurrentModels();
7082
+ const savedModel = currentModels && typeof currentModels[name] === 'string'
7083
+ ? currentModels[name].trim()
5895
7084
  : '';
7085
+ const activeProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : '';
7086
+ const activeModel = typeof config.model === 'string' ? config.model.trim() : '';
7087
+ const model = savedModel || (activeProvider === name ? activeModel : '');
5896
7088
 
5897
7089
  if (!baseUrl) {
5898
7090
  return { error: `提供商 ${name} 缺少 base_url` };
@@ -5902,7 +7094,8 @@ function buildProviderSharePayload(params = {}) {
5902
7094
  payload: {
5903
7095
  name,
5904
7096
  baseUrl,
5905
- apiKey
7097
+ apiKey,
7098
+ model
5906
7099
  }
5907
7100
  };
5908
7101
  }
@@ -6929,6 +8122,8 @@ function readClaudeSettingsInfo() {
6929
8122
  exists: !!readResult.exists,
6930
8123
  targetPath: CLAUDE_SETTINGS_FILE,
6931
8124
  apiKey: typeof env.ANTHROPIC_API_KEY === 'string' ? env.ANTHROPIC_API_KEY : '',
8125
+ authToken: typeof env.ANTHROPIC_AUTH_TOKEN === 'string' ? env.ANTHROPIC_AUTH_TOKEN : '',
8126
+ useKey: typeof env.CLAUDE_CODE_USE_KEY === 'string' ? env.CLAUDE_CODE_USE_KEY : '',
6932
8127
  baseUrl: typeof env.ANTHROPIC_BASE_URL === 'string' ? env.ANTHROPIC_BASE_URL : '',
6933
8128
  model: typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : '',
6934
8129
  env
@@ -8473,8 +9668,23 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8473
9668
  }
8474
9669
  if (requestPath === '/api') {
8475
9670
  let body = '';
8476
- req.on('data', chunk => body += chunk);
9671
+ let bodySize = 0;
9672
+ let bodyTooLarge = false;
9673
+ req.on('data', chunk => {
9674
+ if (bodyTooLarge) return;
9675
+ bodySize += chunk.length;
9676
+ if (bodySize > MAX_API_BODY_SIZE) {
9677
+ bodyTooLarge = true;
9678
+ writeJsonResponse(res, 413, {
9679
+ error: `请求体过大(>${Math.floor(MAX_API_BODY_SIZE / 1024 / 1024)}MB)`
9680
+ });
9681
+ req.destroy();
9682
+ return;
9683
+ }
9684
+ body += chunk;
9685
+ });
8477
9686
  req.on('end', async () => {
9687
+ if (bodyTooLarge) return;
8478
9688
  try {
8479
9689
  const { action, params } = JSON.parse(body);
8480
9690
  let result;
@@ -8582,6 +9792,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8582
9792
  case 'apply-agents-file':
8583
9793
  result = applyAgentsFile(params || {});
8584
9794
  break;
9795
+ case 'preview-agents-diff':
9796
+ result = buildAgentsDiff(params || {});
9797
+ break;
8585
9798
  case 'list-codex-skills':
8586
9799
  result = listCodexSkills();
8587
9800
  break;
@@ -8669,7 +9882,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8669
9882
  result = { error: 'Invalid source. Must be codex, claude, or all' };
8670
9883
  } else {
8671
9884
  result = {
8672
- sessions: listAllSessions(params),
9885
+ sessions: await listAllSessionsData(params),
8673
9886
  source: source || 'all'
8674
9887
  };
8675
9888
  }
@@ -8687,6 +9900,18 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8687
9900
  }
8688
9901
  }
8689
9902
  break;
9903
+ case 'list-session-trash':
9904
+ result = await listSessionTrashItems(params || {});
9905
+ break;
9906
+ case 'restore-session-trash':
9907
+ result = await restoreSessionTrashItem(params || {});
9908
+ break;
9909
+ case 'purge-session-trash':
9910
+ result = await purgeSessionTrashItems(params || {});
9911
+ break;
9912
+ case 'trash-session':
9913
+ result = await trashSessionData(params || {});
9914
+ break;
8690
9915
  case 'export-session':
8691
9916
  result = await exportSessionData(params);
8692
9917
  break;
@@ -8985,6 +10210,69 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8985
10210
  return { server, stop };
8986
10211
  }
8987
10212
 
10213
+ // Region markers are used by unit tests that extract these helpers directly.
10214
+ // #region createSerializedWebUiRestartHandler
10215
+ function createSerializedWebUiRestartHandler(runRestart) {
10216
+ let restartQueued = false;
10217
+ let latestRestartInfo = null;
10218
+ let restartInFlight = null;
10219
+
10220
+ const drainRestartQueue = async () => {
10221
+ try {
10222
+ while (restartQueued) {
10223
+ restartQueued = false;
10224
+ await runRestart(latestRestartInfo);
10225
+ }
10226
+ } finally {
10227
+ restartInFlight = null;
10228
+ if (restartQueued) {
10229
+ restartInFlight = drainRestartQueue();
10230
+ return restartInFlight;
10231
+ }
10232
+ }
10233
+ };
10234
+
10235
+ return (info) => {
10236
+ latestRestartInfo = info;
10237
+ restartQueued = true;
10238
+ if (!restartInFlight) {
10239
+ restartInFlight = drainRestartQueue();
10240
+ }
10241
+ return restartInFlight;
10242
+ };
10243
+ }
10244
+ // #endregion createSerializedWebUiRestartHandler
10245
+
10246
+ // #region restartWebUiServerAfterFrontendChange
10247
+ async function restartWebUiServerAfterFrontendChange({
10248
+ serverHandle,
10249
+ serverOptions,
10250
+ createServer = createWebServer,
10251
+ delayMs = 3000,
10252
+ wait = setTimeout,
10253
+ logger = console
10254
+ }) {
10255
+ logger.log(' 正在停止旧服务...');
10256
+ try {
10257
+ await serverHandle.stop();
10258
+ logger.log(' 旧服务已停止');
10259
+ } catch (e) {
10260
+ logger.warn('! 停止旧服务失败:', e.message || e);
10261
+ }
10262
+
10263
+ await new Promise((resolve) => wait(resolve, delayMs));
10264
+
10265
+ try {
10266
+ const nextServerHandle = await createServer(serverOptions);
10267
+ logger.log('✓ 已重启 Web UI 服务\n');
10268
+ return nextServerHandle;
10269
+ } catch (e) {
10270
+ logger.error('! 重启失败:', e.message || e);
10271
+ return serverHandle;
10272
+ }
10273
+ }
10274
+ // #endregion restartWebUiServerAfterFrontendChange
10275
+
8988
10276
  // 打开 Web UI
8989
10277
  function cmdStart(options = {}) {
8990
10278
  const webDir = path.join(__dirname, 'web-ui');
@@ -9028,32 +10316,28 @@ function cmdStart(options = {}) {
9028
10316
  });
9029
10317
  }
9030
10318
 
9031
- const stopWatch = watchPathsForRestart(
9032
- [webDir, legacyHtmlPath],
9033
- async (info) => {
10319
+ const requestWebUiRestart = createSerializedWebUiRestartHandler(async (info) => {
9034
10320
  const fileLabel = info && info.filename ? info.filename : (info && info.target ? path.basename(info.target) : 'unknown');
9035
10321
  console.log(`\n~ 侦测到前端变更 (${fileLabel}),重启中...`);
9036
- console.log(' 正在停止旧服务...');
9037
- try {
9038
- await serverHandle.stop();
9039
- console.log(' 旧服务已停止');
9040
- } catch (e) {
9041
- console.warn('! 停止旧服务失败:', e.message || e);
9042
- }
9043
- await new Promise((resolve) => setTimeout(resolve, 80));
9044
- try {
9045
- serverHandle = createWebServer({
10322
+ serverHandle = await restartWebUiServerAfterFrontendChange({
10323
+ serverHandle,
10324
+ serverOptions: {
9046
10325
  htmlPath,
9047
10326
  assetsDir,
9048
10327
  webDir,
9049
10328
  host,
9050
10329
  port,
9051
10330
  openBrowser: false
9052
- });
9053
- console.log('✓ 已重启 Web UI 服务\n');
9054
- } catch (e) {
9055
- console.error('! 重启失败:', e.message || e);
9056
- }
10331
+ }
10332
+ });
10333
+ });
10334
+
10335
+ const stopWatch = watchPathsForRestart(
10336
+ [webDir, legacyHtmlPath],
10337
+ (info) => {
10338
+ void requestWebUiRestart(info).catch((err) => {
10339
+ console.error('! 重启 Web UI 失败:', err && err.message ? err.message : err);
10340
+ });
9057
10341
  }
9058
10342
  );
9059
10343
 
@@ -10379,7 +11663,7 @@ function createWorkflowToolCatalog() {
10379
11663
  }
10380
11664
  return {
10381
11665
  source: source || 'all',
10382
- sessions: listAllSessions({
11666
+ sessions: await listAllSessionsData({
10383
11667
  ...args,
10384
11668
  source: source || 'all'
10385
11669
  })
@@ -10746,7 +12030,7 @@ function createMcpTools(options = {}) {
10746
12030
  source: source || 'all'
10747
12031
  };
10748
12032
  return {
10749
- sessions: listAllSessions(normalizedInput),
12033
+ sessions: await listAllSessionsData(normalizedInput),
10750
12034
  source: source || 'all'
10751
12035
  };
10752
12036
  }
@@ -10982,18 +12266,34 @@ function createMcpTools(options = {}) {
10982
12266
  handler: async (args = {}) => applyOpenclawConfig(args || {})
10983
12267
  });
10984
12268
 
12269
+ pushTool({
12270
+ name: 'codexmate.session.trash',
12271
+ description: 'Move one entire session file into session trash.',
12272
+ readOnly: false,
12273
+ inputSchema: {
12274
+ type: 'object',
12275
+ properties: {
12276
+ source: { type: 'string' },
12277
+ sessionId: { type: 'string' },
12278
+ filePath: { type: 'string' },
12279
+ file: { type: 'string' }
12280
+ },
12281
+ additionalProperties: true
12282
+ },
12283
+ handler: async (args = {}) => trashSessionData(args || {})
12284
+ });
12285
+
10985
12286
  pushTool({
10986
12287
  name: 'codexmate.session.delete',
10987
- description: 'Delete one session or selected records in a session.',
12288
+ description: 'Permanently delete one entire session file.',
10988
12289
  readOnly: false,
10989
12290
  inputSchema: {
10990
12291
  type: 'object',
10991
12292
  properties: {
10992
12293
  source: { type: 'string' },
10993
12294
  sessionId: { type: 'string' },
10994
- file: { type: 'string' },
10995
- recordLineIndex: { type: 'number' },
10996
- recordLineIndices: { type: 'array', items: { type: 'number' } }
12295
+ filePath: { type: 'string' },
12296
+ file: { type: 'string' }
10997
12297
  },
10998
12298
  additionalProperties: true
10999
12299
  },
@@ -11143,7 +12443,7 @@ function createMcpResources() {
11143
12443
  }
11144
12444
  const payload = {
11145
12445
  source: normalizedSource || 'all',
11146
- sessions: listAllSessions({
12446
+ sessions: await listAllSessionsData({
11147
12447
  source: normalizedSource || 'all',
11148
12448
  query,
11149
12449
  pathFilter,