codexmate 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +32 -9
  2. package/README.zh.md +33 -9
  3. package/cli/auth-profiles.js +23 -7
  4. package/cli/builtin-proxy.js +35 -0
  5. package/cli/claude-proxy.js +24 -0
  6. package/cli/doctor-core.js +903 -0
  7. package/cli/import-skills-url.js +356 -0
  8. package/cli/openai-bridge.js +51 -4
  9. package/cli/session-usage.js +8 -2
  10. package/cli.js +1921 -399
  11. package/lib/automation.js +404 -0
  12. package/lib/cli-models-utils.js +0 -40
  13. package/lib/cli-network-utils.js +28 -2
  14. package/lib/cli-path-utils.js +21 -5
  15. package/lib/cli-sessions.js +32 -1
  16. package/lib/download-artifacts.js +17 -2
  17. package/lib/mcp-stdio.js +13 -0
  18. package/package.json +3 -3
  19. package/plugins/README.md +20 -0
  20. package/plugins/README.zh-CN.md +20 -0
  21. package/plugins/prompt-templates/comment-polish/index.mjs +25 -0
  22. package/plugins/prompt-templates/computed.mjs +253 -0
  23. package/plugins/prompt-templates/index.mjs +8 -0
  24. package/plugins/prompt-templates/manifest.mjs +15 -0
  25. package/plugins/prompt-templates/methods.mjs +619 -0
  26. package/plugins/prompt-templates/overview.mjs +90 -0
  27. package/plugins/prompt-templates/ownership.mjs +19 -0
  28. package/plugins/prompt-templates/rule-ack/index.mjs +21 -0
  29. package/plugins/prompt-templates/storage.mjs +64 -0
  30. package/plugins/registry.mjs +16 -0
  31. package/web-ui/app.js +21 -35
  32. package/web-ui/index.html +4 -3
  33. package/web-ui/logic.sessions.mjs +2 -2
  34. package/web-ui/modules/app.computed.dashboard.mjs +24 -22
  35. package/web-ui/modules/app.computed.main-tabs.mjs +3 -0
  36. package/web-ui/modules/app.computed.session.mjs +17 -0
  37. package/web-ui/modules/app.methods.agents.mjs +91 -3
  38. package/web-ui/modules/app.methods.codex-config.mjs +153 -164
  39. package/web-ui/modules/app.methods.install.mjs +28 -0
  40. package/web-ui/modules/app.methods.navigation.mjs +34 -1
  41. package/web-ui/modules/app.methods.runtime.mjs +24 -2
  42. package/web-ui/modules/app.methods.session-actions.mjs +8 -1
  43. package/web-ui/modules/app.methods.session-browser.mjs +37 -6
  44. package/web-ui/modules/app.methods.session-trash.mjs +4 -2
  45. package/web-ui/modules/config-mode.computed.mjs +1 -3
  46. package/web-ui/modules/i18n.dict.mjs +2055 -0
  47. package/web-ui/modules/i18n.mjs +2 -1769
  48. package/web-ui/partials/index/layout-header.html +48 -34
  49. package/web-ui/partials/index/modal-config-template-agents.html +3 -4
  50. package/web-ui/partials/index/modal-health-check.html +33 -60
  51. package/web-ui/partials/index/panel-config-claude.html +35 -15
  52. package/web-ui/partials/index/panel-config-codex.html +47 -19
  53. package/web-ui/partials/index/panel-config-openclaw.html +8 -3
  54. package/web-ui/partials/index/panel-dashboard.html +186 -0
  55. package/web-ui/partials/index/panel-docs.html +1 -1
  56. package/web-ui/partials/index/panel-market.html +3 -0
  57. package/web-ui/partials/index/panel-orchestration.html +3 -0
  58. package/web-ui/partials/index/panel-plugins.html +16 -10
  59. package/web-ui/partials/index/panel-sessions.html +8 -3
  60. package/web-ui/partials/index/panel-settings.html +1 -1
  61. package/web-ui/partials/index/panel-usage.html +9 -1
  62. package/web-ui/res/logo-pack.webp +0 -0
  63. package/web-ui/styles/controls-forms.css +58 -4
  64. package/web-ui/styles/dashboard.css +274 -0
  65. package/web-ui/styles/layout-shell.css +3 -2
  66. package/web-ui/styles/responsive.css +0 -2
  67. package/web-ui/styles/sessions-list.css +5 -7
  68. package/web-ui/styles/sessions-toolbar-trash.css +4 -4
  69. package/web-ui/styles/sessions-usage.css +33 -0
  70. package/web-ui/styles.css +1 -0
  71. package/res/logo.png +0 -0
  72. /package/{res → web-ui/res}/json5.min.js +0 -0
  73. /package/{res → web-ui/res}/vue.global.prod.js +0 -0
package/cli.js CHANGED
@@ -10,6 +10,7 @@ const yauzl = require('yauzl');
10
10
  const { exec, execSync, spawn, spawnSync } = require('child_process');
11
11
  const http = require('http');
12
12
  const https = require('https');
13
+ const net = require('net');
13
14
  const readline = require('readline');
14
15
  const {
15
16
  expandHomePath,
@@ -42,8 +43,9 @@ const {
42
43
  extractModelNames,
43
44
  hasModelsListPayload,
44
45
  buildModelsCacheKey,
46
+ buildApiProbeUrlCandidates,
45
47
  buildModelProbeSpec,
46
- buildModelConversationSpecs,
48
+ buildModelProbeSpecs,
47
49
  extractModelResponseText,
48
50
  normalizeWireApi,
49
51
  getSupplementalModelsForBaseUrl,
@@ -73,7 +75,16 @@ const {
73
75
  validateTaskPlan,
74
76
  executeTaskPlan
75
77
  } = require('./lib/task-orchestrator');
78
+ const {
79
+ readAutomationConfig,
80
+ matchAutomationRule,
81
+ buildAutomationEventKey,
82
+ isCronMatch,
83
+ dispatchAutomationNotifiers,
84
+ formatTaskRunNotificationPayload
85
+ } = require('./lib/automation');
76
86
  const { buildConfigHealthReport: buildConfigHealthReportCore } = require('./cli/config-health');
87
+ const { buildDoctorReport, buildDoctorLegacyPayload, renderDoctorMarkdown } = require('./cli/doctor-core');
77
88
  const {
78
89
  createAuthProfileController
79
90
  } = require('./cli/auth-profiles');
@@ -125,6 +136,7 @@ const {
125
136
  deleteSkills,
126
137
  deleteCodexSkills
127
138
  } = require('./cli/skills');
139
+ const { cmdImportSkills: cmdImportSkillsFromUrl } = require('./cli/import-skills-url');
128
140
  const {
129
141
  getFileStatSafe,
130
142
  isBootstrapLikeText,
@@ -182,6 +194,10 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
182
194
  const CLAUDE_SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
183
195
  const CLAUDE_MD_FILE_NAME = 'CLAUDE.md';
184
196
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
197
+ const CODEBUDDY_DIR = path.join(os.homedir(), '.codebuddy');
198
+ const CODEBUDDY_PROJECTS_DIR = path.join(CODEBUDDY_DIR, 'projects');
199
+ const GEMINI_DIR = path.join(os.homedir(), '.gemini');
200
+ const GEMINI_TMP_DIR = path.join(GEMINI_DIR, 'tmp');
185
201
  const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json');
186
202
  const WORKFLOW_DEFINITIONS_FILE = path.join(CONFIG_DIR, 'codexmate-workflows.json');
187
203
  const WORKFLOW_RUNS_FILE = path.join(CONFIG_DIR, 'codexmate-workflow-runs.jsonl');
@@ -189,6 +205,8 @@ const TASK_QUEUE_FILE = path.join(CONFIG_DIR, 'codexmate-task-queue.json');
189
205
  const TASK_RUNS_FILE = path.join(CONFIG_DIR, 'codexmate-task-runs.jsonl');
190
206
  const TASK_RUN_DETAILS_DIR = path.join(CONFIG_DIR, 'codexmate-task-runs');
191
207
  const TASK_QUEUE_WORKER_FILE = path.join(CONFIG_DIR, 'codexmate-task-queue-worker.json');
208
+ const TASK_ARTIFACTS_DIR = path.join(CONFIG_DIR, 'codexmate-task-artifacts');
209
+ const AUTOMATION_CONFIG_FILE = path.join(CONFIG_DIR, 'codexmate-automation.json');
192
210
  const DEFAULT_CLAUDE_MODEL = 'glm-4.7';
193
211
  const DEFAULT_MODEL_CONTEXT_WINDOW = 190000;
194
212
  const DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT = 185000;
@@ -259,6 +277,18 @@ const CLI_INSTALL_TARGETS = Object.freeze([
259
277
  packageName: '@anthropic-ai/claude-code',
260
278
  bins: ['claude']
261
279
  },
280
+ {
281
+ id: 'codebuddy',
282
+ name: 'CodeBuddy Code',
283
+ packageName: '@tencent-ai/codebuddy-code',
284
+ bins: ['codebuddy']
285
+ },
286
+ {
287
+ id: 'gemini',
288
+ name: 'Gemini CLI',
289
+ packageName: '@google/gemini-cli',
290
+ bins: ['gemini']
291
+ },
262
292
  {
263
293
  id: 'codex',
264
294
  name: 'Codex CLI',
@@ -272,7 +302,7 @@ const HTTPS_KEEP_ALIVE_AGENT = new https.Agent({ keepAlive: true });
272
302
 
273
303
  const openaiBridgeHandler = createOpenaiBridgeHttpHandler({
274
304
  settingsFile: OPENAI_BRIDGE_SETTINGS_FILE,
275
- expectedToken: 'codexmate',
305
+ expectedToken: typeof process.env.CODEXMATE_HTTP_TOKEN === 'string' ? process.env.CODEXMATE_HTTP_TOKEN.trim() : '',
276
306
  maxBodySize: MAX_API_BODY_SIZE,
277
307
  httpAgent: HTTP_KEEP_ALIVE_AGENT,
278
308
  httpsAgent: HTTPS_KEEP_ALIVE_AGENT
@@ -556,7 +586,9 @@ let g_sessionListCache = new Map();
556
586
  let g_sessionInventoryCache = new Map();
557
587
  let g_sessionFileLookupCache = {
558
588
  codex: new Map(),
559
- claude: new Map()
589
+ claude: new Map(),
590
+ gemini: new Map(),
591
+ codebuddy: new Map()
560
592
  };
561
593
  let g_exactMessageCountCache = new Map();
562
594
  let g_modelsCache = new Map();
@@ -1251,6 +1283,31 @@ function getClaudeProjectsDir() {
1251
1283
  return resolveExistingDir(candidates, CLAUDE_PROJECTS_DIR);
1252
1284
  }
1253
1285
 
1286
+ function getGeminiTmpDir() {
1287
+ const candidates = [];
1288
+ const envGeminiHome = process.env.GEMINI_HOME;
1289
+ if (envGeminiHome) {
1290
+ candidates.push(path.join(envGeminiHome, 'tmp'));
1291
+ }
1292
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
1293
+ if (xdgConfig) {
1294
+ candidates.push(path.join(xdgConfig, 'gemini', 'tmp'));
1295
+ }
1296
+ candidates.push(path.join(os.homedir(), '.config', 'gemini', 'tmp'));
1297
+ candidates.push(GEMINI_TMP_DIR);
1298
+ return resolveExistingDir(candidates, GEMINI_TMP_DIR);
1299
+ }
1300
+
1301
+ function getCodeBuddyProjectsDir() {
1302
+ const candidates = [];
1303
+ const envHome = process.env.CODEBUDDY_CODE_HOME_DIR || process.env.CODEBUDDY_HOME;
1304
+ if (envHome) {
1305
+ candidates.push(path.join(envHome, 'projects'));
1306
+ }
1307
+ candidates.push(CODEBUDDY_PROJECTS_DIR);
1308
+ return resolveExistingDir(candidates, CODEBUDDY_PROJECTS_DIR);
1309
+ }
1310
+
1254
1311
  function readModelsCacheEntry(cacheKey) {
1255
1312
  if (!cacheKey) return null;
1256
1313
  const entry = g_modelsCache.get(cacheKey);
@@ -2498,6 +2555,19 @@ function countConversationMessagesInRecords(records, source) {
2498
2555
  }
2499
2556
  continue;
2500
2557
  }
2558
+ if (source === 'codebuddy') {
2559
+ if (record && record.type === 'message') {
2560
+ const role = normalizeRole(record.role);
2561
+ if (role === 'assistant' || role === 'user' || role === 'system') {
2562
+ const content = record.message?.content ?? record.content ?? '';
2563
+ messages.push({
2564
+ role,
2565
+ text: extractMessageText(content)
2566
+ });
2567
+ }
2568
+ }
2569
+ continue;
2570
+ }
2501
2571
 
2502
2572
  const role = normalizeRole(record.type);
2503
2573
  if (role === 'assistant' || role === 'user' || role === 'system') {
@@ -2519,6 +2589,28 @@ async function countConversationMessagesInFile(filePath, source) {
2519
2589
  return cached;
2520
2590
  }
2521
2591
 
2592
+ if (source === 'gemini') {
2593
+ let json;
2594
+ try {
2595
+ json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
2596
+ } catch (_) {
2597
+ json = null;
2598
+ }
2599
+ const rawMessages = json && Array.isArray(json.messages) ? json.messages : [];
2600
+ const messages = [];
2601
+ for (const entry of rawMessages) {
2602
+ if (!entry || typeof entry !== 'object') continue;
2603
+ const role = normalizeGeminiMessageRole(entry.type);
2604
+ if (!role) continue;
2605
+ const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text));
2606
+ if (!text && role !== 'system') continue;
2607
+ messages.push({ role, text });
2608
+ }
2609
+ const safeCount = removeLeadingSystemMessage(messages).length;
2610
+ writeExactMessageCountCache(filePath, source, safeCount, fileStat);
2611
+ return safeCount;
2612
+ }
2613
+
2522
2614
  let stream;
2523
2615
  let rl;
2524
2616
  let messageCount = 0;
@@ -2546,6 +2638,15 @@ async function countConversationMessagesInFile(filePath, source) {
2546
2638
  role = normalizeRole(record.payload.role);
2547
2639
  text = extractMessageText(record.payload.content);
2548
2640
  }
2641
+ } else if (source === 'codebuddy') {
2642
+ if (record && record.type === 'message') {
2643
+ role = normalizeRole(record.role);
2644
+ if (role === 'assistant' || role === 'user' || role === 'system') {
2645
+ text = extractMessageText(record.message?.content ?? record.content ?? '');
2646
+ } else {
2647
+ role = '';
2648
+ }
2649
+ }
2549
2650
  } else {
2550
2651
  role = normalizeRole(record.type);
2551
2652
  if (role === 'assistant' || role === 'user' || role === 'system') {
@@ -2708,7 +2809,13 @@ async function resolveSessionTrashEntryExactMessageCount(entry) {
2708
2809
  }
2709
2810
 
2710
2811
  async function hydrateSessionTrashEntries(entries, options = {}) {
2711
- const source = options.source === 'claude' ? 'claude' : (options.source === 'codex' ? 'codex' : 'all');
2812
+ const source = options.source === 'claude'
2813
+ ? 'claude'
2814
+ : (options.source === 'codex'
2815
+ ? 'codex'
2816
+ : (options.source === 'gemini'
2817
+ ? 'gemini'
2818
+ : (options.source === 'codebuddy' ? 'codebuddy' : 'all')));
2712
2819
  const hydratedEntries = await mapWithConcurrency(Array.isArray(entries) ? entries : [], 8, async (entry) => {
2713
2820
  const normalizedEntry = normalizeSessionTrashEntry(entry);
2714
2821
  if (!normalizedEntry) {
@@ -2717,7 +2824,7 @@ async function hydrateSessionTrashEntries(entries, options = {}) {
2717
2824
  return await resolveSessionTrashEntryExactMessageCount(normalizedEntry);
2718
2825
  });
2719
2826
 
2720
- if (source === 'codex' || source === 'claude') {
2827
+ if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy') {
2721
2828
  return hydratedEntries.filter((entry) => entry.source === source);
2722
2829
  }
2723
2830
  return hydratedEntries;
@@ -2731,7 +2838,11 @@ async function hydrateSessionItemsExactMessageCount(items) {
2731
2838
  if (item.__messageCountExact === true) {
2732
2839
  return item;
2733
2840
  }
2734
- const source = item.source === 'claude' ? 'claude' : (item.source === 'codex' ? 'codex' : '');
2841
+ const source = item.source === 'claude'
2842
+ ? 'claude'
2843
+ : (item.source === 'codex'
2844
+ ? 'codex'
2845
+ : (item.source === 'gemini' ? 'gemini' : (item.source === 'codebuddy' ? 'codebuddy' : '')));
2735
2846
  const filePath = typeof item.filePath === 'string' ? item.filePath : '';
2736
2847
  if (!source || !filePath || !fs.existsSync(filePath)) {
2737
2848
  return item;
@@ -2958,6 +3069,54 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
2958
3069
  ? Math.max(1024, rawMaxBytes)
2959
3070
  : 0;
2960
3071
  const state = createSessionQueryScanState(tokens, options);
3072
+ if (session.source === 'gemini') {
3073
+ if (state.roleFilter !== 'all') {
3074
+ let json;
3075
+ try {
3076
+ json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
3077
+ } catch (_) {
3078
+ json = null;
3079
+ }
3080
+ const rawMessages = json && Array.isArray(json.messages) ? json.messages : [];
3081
+ for (const entry of rawMessages) {
3082
+ if (!entry || typeof entry !== 'object') continue;
3083
+ const role = normalizeGeminiMessageRole(entry.type);
3084
+ if (!role) continue;
3085
+ const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text));
3086
+ if (!text) continue;
3087
+ if (consumeSessionQueryMessage(state, { role, text })) {
3088
+ break;
3089
+ }
3090
+ }
3091
+ return buildSessionQueryScanResult(state);
3092
+ }
3093
+
3094
+ let text = '';
3095
+ try {
3096
+ const stat = fs.statSync(filePath);
3097
+ const targetBytes = maxBytes > 0 ? Math.min(maxBytes, stat.size || 0) : Math.min(stat.size || 0, 512 * 1024);
3098
+ const fd = fs.openSync(filePath, 'r');
3099
+ const buf = Buffer.alloc(targetBytes);
3100
+ const bytes = fs.readSync(fd, buf, 0, targetBytes, 0);
3101
+ fs.closeSync(fd);
3102
+ text = bytes > 0 ? buf.slice(0, bytes).toString('utf-8') : '';
3103
+ } catch (_) {
3104
+ try {
3105
+ text = fs.readFileSync(filePath, 'utf-8');
3106
+ } catch (_) {
3107
+ text = '';
3108
+ }
3109
+ }
3110
+
3111
+ if (!matchTokensInText(text, state.tokens, state.mode)) {
3112
+ return buildSessionQueryScanResult(state);
3113
+ }
3114
+ state.count = 1;
3115
+ if (state.snippetLimit > 0) {
3116
+ state.snippets.push(truncateText(text));
3117
+ }
3118
+ return buildSessionQueryScanResult(state);
3119
+ }
2961
3120
  let stream;
2962
3121
  let rl;
2963
3122
  try {
@@ -3240,7 +3399,9 @@ function getSessionInventoryCache(cacheKey, forceRefresh = false) {
3240
3399
  }
3241
3400
 
3242
3401
  function registerSessionFileLookupEntries(source, sessions = []) {
3243
- const normalizedSource = source === 'claude' ? 'claude' : 'codex';
3402
+ const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy'
3403
+ ? source
3404
+ : 'codex';
3244
3405
  const store = g_sessionFileLookupCache[normalizedSource];
3245
3406
  if (!(store instanceof Map) || !Array.isArray(sessions)) {
3246
3407
  return;
@@ -3279,7 +3440,9 @@ function setSessionInventoryCache(cacheKey, source, value) {
3279
3440
  }
3280
3441
 
3281
3442
  function listSessionInventoryBySource(source, limit, scanOptions = {}, options = {}) {
3282
- const normalizedSource = source === 'claude' ? 'claude' : 'codex';
3443
+ const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy'
3444
+ ? source
3445
+ : 'codex';
3283
3446
  const forceRefresh = !!options.forceRefresh;
3284
3447
  const cacheKey = buildSessionInventoryCacheKey(normalizedSource, limit, scanOptions);
3285
3448
  const cached = getSessionInventoryCache(cacheKey, forceRefresh);
@@ -3289,7 +3452,11 @@ function listSessionInventoryBySource(source, limit, scanOptions = {}, options =
3289
3452
 
3290
3453
  const sessions = normalizedSource === 'claude'
3291
3454
  ? listClaudeSessions(limit, scanOptions)
3292
- : listCodexSessions(limit, scanOptions);
3455
+ : (normalizedSource === 'gemini'
3456
+ ? listGeminiSessions(limit, scanOptions)
3457
+ : (normalizedSource === 'codebuddy'
3458
+ ? listCodeBuddySessions(limit, scanOptions)
3459
+ : listCodexSessions(limit, scanOptions)));
3293
3460
  setSessionInventoryCache(cacheKey, normalizedSource, sessions);
3294
3461
  return sessions;
3295
3462
  }
@@ -3299,7 +3466,9 @@ function invalidateSessionListCache() {
3299
3466
  g_sessionInventoryCache.clear();
3300
3467
  g_sessionFileLookupCache = {
3301
3468
  codex: new Map(),
3302
- claude: new Map()
3469
+ claude: new Map(),
3470
+ gemini: new Map(),
3471
+ codebuddy: new Map()
3303
3472
  };
3304
3473
  }
3305
3474
 
@@ -3891,88 +4060,399 @@ function parseClaudeSessionSummary(filePath, options = {}) {
3891
4060
  };
3892
4061
  }
3893
4062
 
3894
- function listCodexSessions(limit, options = {}) {
3895
- const codexSessionsDir = getCodexSessionsDir();
3896
- const scanFactor = Number.isFinite(Number(options.scanFactor))
3897
- ? Math.max(1, Number(options.scanFactor))
3898
- : SESSION_SCAN_FACTOR;
3899
- const minFiles = Number.isFinite(Number(options.minFiles))
3900
- ? Math.max(1, Number(options.minFiles))
3901
- : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR);
3902
- const targetCount = Number.isFinite(Number(options.targetCount))
3903
- ? Math.max(1, Math.floor(Number(options.targetCount)))
3904
- : Math.max(1, Math.floor(limit * scanFactor));
3905
- const scanCount = Number.isFinite(Number(options.scanCount))
3906
- ? Math.max(targetCount, Math.floor(Number(options.scanCount)))
3907
- : Math.max(targetCount, minFiles);
3908
- const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
3909
- ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
3910
- : Math.max(scanCount * 2, minFiles);
4063
+ function parseCodeBuddySessionSummary(filePath, options = {}) {
3911
4064
  const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes))
3912
4065
  ? Math.max(1024, Math.floor(Number(options.summaryReadBytes)))
3913
4066
  : SESSION_SUMMARY_READ_BYTES;
3914
4067
  const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
3915
4068
  ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
3916
4069
  : SESSION_TITLE_READ_BYTES;
3917
- const files = collectRecentJsonlFiles(codexSessionsDir, {
3918
- returnCount: scanCount,
3919
- maxFilesScanned
3920
- });
3921
- const sessions = [];
4070
+ const records = parseJsonlHeadRecords(filePath, summaryReadBytes);
4071
+ if (records.length === 0) {
4072
+ return null;
4073
+ }
3922
4074
 
3923
- for (const filePath of files) {
3924
- const summary = parseCodexSessionSummary(filePath, {
3925
- summaryReadBytes,
3926
- titleReadBytes
3927
- });
3928
- if (summary) {
3929
- sessions.push(summary);
4075
+ let stat;
4076
+ try {
4077
+ stat = fs.statSync(filePath);
4078
+ } catch (_) {
4079
+ return null;
4080
+ }
4081
+
4082
+ let sessionId = path.basename(filePath, '.jsonl');
4083
+ let cwd = '';
4084
+ let firstPrompt = '';
4085
+ let messageCount = 0;
4086
+ let totalTokens = 0;
4087
+ let contextWindow = 0;
4088
+ let inputTokens = 0;
4089
+ let cachedInputTokens = 0;
4090
+ let outputTokens = 0;
4091
+ let reasoningOutputTokens = 0;
4092
+ let provider = 'codebuddy';
4093
+ let model = '';
4094
+ const models = [];
4095
+ const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens };
4096
+ const previewMessages = [];
4097
+ let createdAt = '';
4098
+ let updatedAt = stat.mtime.toISOString();
4099
+
4100
+ for (const record of records) {
4101
+ if (!createdAt && record && record.timestamp) {
4102
+ createdAt = toIsoTime(record.timestamp, createdAt);
4103
+ }
4104
+ if (record && record.timestamp) {
4105
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
3930
4106
  }
3931
4107
 
3932
- if (sessions.length >= targetCount) {
3933
- break;
4108
+ applySessionUsageSummaryFromRecord(usageState, record, 'codebuddy');
4109
+ totalTokens = usageState.totalTokens || 0;
4110
+ contextWindow = usageState.contextWindow || 0;
4111
+ inputTokens = usageState.inputTokens || 0;
4112
+ cachedInputTokens = usageState.cachedInputTokens || 0;
4113
+ outputTokens = usageState.outputTokens || 0;
4114
+ reasoningOutputTokens = usageState.reasoningOutputTokens || 0;
4115
+
4116
+ if (record && typeof record.sessionId === 'string' && record.sessionId.trim()) {
4117
+ sessionId = record.sessionId.trim();
4118
+ }
4119
+ if (!cwd && record && typeof record.cwd === 'string' && record.cwd.trim()) {
4120
+ cwd = record.cwd.trim();
4121
+ }
4122
+
4123
+ provider = readExplicitSessionProviderFromRecord(record) || provider;
4124
+ const recordModels = readSessionModelsFromRecord(record);
4125
+ for (const recordModel of recordModels) {
4126
+ if (!models.includes(recordModel)) {
4127
+ models.push(recordModel);
4128
+ }
4129
+ }
4130
+ model = recordModels[0] || model;
4131
+
4132
+ if (record && record.type === 'message') {
4133
+ const role = normalizeRole(record.role);
4134
+ if (role === 'assistant' || role === 'user' || role === 'system') {
4135
+ const content = record.message?.content ?? record.content ?? '';
4136
+ previewMessages.push({
4137
+ role,
4138
+ text: extractMessageText(content)
4139
+ });
4140
+ }
3934
4141
  }
3935
4142
  }
3936
4143
 
3937
- return mergeAndLimitSessions(sessions, limit);
4144
+ const tailRecords = parseJsonlTailRecords(filePath, summaryReadBytes);
4145
+ for (const record of tailRecords) {
4146
+ applySessionUsageSummaryFromRecord(usageState, record, 'codebuddy');
4147
+ totalTokens = usageState.totalTokens || 0;
4148
+ contextWindow = usageState.contextWindow || 0;
4149
+ inputTokens = usageState.inputTokens || 0;
4150
+ cachedInputTokens = usageState.cachedInputTokens || 0;
4151
+ outputTokens = usageState.outputTokens || 0;
4152
+ reasoningOutputTokens = usageState.reasoningOutputTokens || 0;
4153
+ provider = readExplicitSessionProviderFromRecord(record) || provider;
4154
+ const recordModels = readSessionModelsFromRecord(record);
4155
+ for (const recordModel of recordModels) {
4156
+ if (!models.includes(recordModel)) {
4157
+ models.push(recordModel);
4158
+ }
4159
+ }
4160
+ model = recordModels[0] || model;
4161
+ }
4162
+
4163
+ const filteredPreviewMessages = removeLeadingSystemMessage(previewMessages);
4164
+ messageCount = filteredPreviewMessages.length;
4165
+ const firstUser = filteredPreviewMessages.find(item => item.role === 'user' && item.text);
4166
+ if (firstUser) {
4167
+ firstPrompt = truncateText(firstUser.text);
4168
+ }
4169
+
4170
+ if (!firstPrompt) {
4171
+ const titleRecords = parseJsonlHeadRecords(filePath, titleReadBytes);
4172
+ const titleMessages = [];
4173
+ for (const record of titleRecords) {
4174
+ if (record && record.type === 'message') {
4175
+ const role = normalizeRole(record.role);
4176
+ if (role === 'assistant' || role === 'user' || role === 'system') {
4177
+ const content = record.message?.content ?? record.content ?? '';
4178
+ titleMessages.push({
4179
+ role,
4180
+ text: extractMessageText(content)
4181
+ });
4182
+ }
4183
+ }
4184
+ }
4185
+
4186
+ const filteredTitleMessages = removeLeadingSystemMessage(titleMessages);
4187
+ const titleUser = filteredTitleMessages.find(item => item.role === 'user' && item.text);
4188
+ if (titleUser) {
4189
+ firstPrompt = truncateText(titleUser.text);
4190
+ }
4191
+ }
4192
+
4193
+ messageCount = Math.max(0, messageCount);
4194
+
4195
+ return {
4196
+ source: 'codebuddy',
4197
+ sourceLabel: 'CodeBuddy Code',
4198
+ provider,
4199
+ model,
4200
+ models,
4201
+ sessionId,
4202
+ title: firstPrompt || sessionId,
4203
+ cwd,
4204
+ createdAt,
4205
+ updatedAt,
4206
+ messageCount,
4207
+ totalTokens,
4208
+ contextWindow,
4209
+ inputTokens,
4210
+ cachedInputTokens,
4211
+ outputTokens,
4212
+ reasoningOutputTokens,
4213
+ __messageCountExact: isSessionSummaryMessageCountExact(stat, summaryReadBytes),
4214
+ filePath,
4215
+ keywords: [],
4216
+ capabilities: { code: true }
4217
+ };
3938
4218
  }
3939
4219
 
3940
- function listClaudeSessions(limit, options = {}) {
3941
- const claudeProjectsDir = getClaudeProjectsDir();
3942
- if (!fs.existsSync(claudeProjectsDir)) {
3943
- return [];
4220
+ function extractGeminiMessageText(content) {
4221
+ if (typeof content === 'string') {
4222
+ return content;
4223
+ }
4224
+ if (Array.isArray(content)) {
4225
+ const parts = [];
4226
+ for (const item of content) {
4227
+ if (!item) continue;
4228
+ if (typeof item === 'string') {
4229
+ parts.push(item);
4230
+ continue;
4231
+ }
4232
+ if (typeof item.text === 'string' && item.text.trim()) {
4233
+ parts.push(item.text);
4234
+ continue;
4235
+ }
4236
+ if (typeof item.content === 'string' && item.content.trim()) {
4237
+ parts.push(item.content);
4238
+ }
4239
+ }
4240
+ return parts.filter(Boolean).join('\n');
4241
+ }
4242
+ if (content && typeof content === 'object') {
4243
+ if (typeof content.text === 'string') {
4244
+ return content.text;
4245
+ }
4246
+ if (typeof content.content === 'string') {
4247
+ return content.content;
4248
+ }
4249
+ if (Array.isArray(content.parts)) {
4250
+ return extractGeminiMessageText(content.parts);
4251
+ }
4252
+ if (Array.isArray(content.content)) {
4253
+ return extractGeminiMessageText(content.content);
4254
+ }
3944
4255
  }
4256
+ return '';
4257
+ }
3945
4258
 
3946
- const scanFactor = Number.isFinite(Number(options.scanFactor))
3947
- ? Math.max(1, Number(options.scanFactor))
3948
- : SESSION_SCAN_FACTOR;
3949
- const minFiles = Number.isFinite(Number(options.minFiles))
3950
- ? Math.max(1, Number(options.minFiles))
3951
- : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR);
3952
- const targetCount = Number.isFinite(Number(options.targetCount))
3953
- ? Math.max(1, Math.floor(Number(options.targetCount)))
3954
- : Math.max(1, Math.floor(limit * scanFactor));
3955
- const scanCount = Number.isFinite(Number(options.scanCount))
3956
- ? Math.max(targetCount, Math.floor(Number(options.scanCount)))
3957
- : Math.max(targetCount, minFiles);
3958
- const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
3959
- ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
3960
- : Math.max(scanCount * 2, minFiles);
4259
+ function normalizeGeminiMessageRole(type) {
4260
+ const t = typeof type === 'string' ? type.trim().toLowerCase() : '';
4261
+ if (t === 'user') return 'user';
4262
+ if (t === 'gemini' || t === 'assistant' || t === 'model') return 'assistant';
4263
+ if (t === 'system' || t === 'info' || t === 'warning' || t === 'error') return 'system';
4264
+ return '';
4265
+ }
4266
+
4267
+ function parseGeminiSessionSummary(filePath, options = {}) {
3961
4268
  const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes))
3962
4269
  ? Math.max(1024, Math.floor(Number(options.summaryReadBytes)))
3963
4270
  : SESSION_SUMMARY_READ_BYTES;
3964
4271
  const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
3965
4272
  ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
3966
4273
  : SESSION_TITLE_READ_BYTES;
3967
-
3968
- const sessions = [];
3969
- let projectDirs = [];
4274
+ let stat;
3970
4275
  try {
3971
- projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true })
3972
- .filter(entry => entry.isDirectory())
3973
- .map(entry => path.join(claudeProjectsDir, entry.name));
3974
- } catch (e) {
3975
- projectDirs = [];
4276
+ stat = fs.statSync(filePath);
4277
+ } catch (_) {
4278
+ return null;
4279
+ }
4280
+
4281
+ const fileName = path.basename(filePath);
4282
+ const projectHash = path.basename(path.dirname(path.dirname(filePath)));
4283
+ let sessionId = path.basename(filePath, '.json');
4284
+ let createdAt = '';
4285
+ let updatedAt = stat.mtime.toISOString();
4286
+ let provider = 'gemini';
4287
+ let model = '';
4288
+ const models = [];
4289
+ let firstPrompt = '';
4290
+ let messageCount = 0;
4291
+
4292
+ let headText = '';
4293
+ try {
4294
+ const fd = fs.openSync(filePath, 'r');
4295
+ const buf = Buffer.alloc(summaryReadBytes);
4296
+ const bytes = fs.readSync(fd, buf, 0, summaryReadBytes, 0);
4297
+ fs.closeSync(fd);
4298
+ headText = bytes > 0 ? buf.slice(0, bytes).toString('utf-8') : '';
4299
+ } catch (_) {
4300
+ headText = '';
4301
+ }
4302
+
4303
+ if (headText) {
4304
+ const sessionIdMatch = headText.match(/"sessionId"\s*:\s*"([^"]+)"/);
4305
+ if (sessionIdMatch) {
4306
+ sessionId = sessionIdMatch[1] || sessionId;
4307
+ }
4308
+ const startMatch = headText.match(/"startTime"\s*:\s*"([^"]+)"/);
4309
+ if (startMatch) {
4310
+ createdAt = toIsoTime(startMatch[1], createdAt);
4311
+ }
4312
+ const updatedMatch = headText.match(/"lastUpdated"\s*:\s*"([^"]+)"/);
4313
+ if (updatedMatch) {
4314
+ updatedAt = toIsoTime(updatedMatch[1], updatedAt);
4315
+ }
4316
+ const modelMatch = headText.match(/"model"\s*:\s*"([^"]+)"/);
4317
+ if (modelMatch && modelMatch[1]) {
4318
+ model = modelMatch[1];
4319
+ models.push(model);
4320
+ }
4321
+ const summaryMatch = headText.match(/"summary"\s*:\s*"([^"]+)"/);
4322
+ if (summaryMatch && summaryMatch[1]) {
4323
+ firstPrompt = truncateText(summaryMatch[1]);
4324
+ }
4325
+ if (!firstPrompt) {
4326
+ const userIdx = headText.search(/"type"\s*:\s*"user"/);
4327
+ if (userIdx >= 0) {
4328
+ const slice = headText.slice(userIdx, Math.min(headText.length, userIdx + titleReadBytes));
4329
+ const contentStringMatch = slice.match(/"content"\s*:\s*"((?:\\\\.|[^\"\\\\])*)"/);
4330
+ const textMatch = slice.match(/"text"\s*:\s*"((?:\\\\.|[^\"\\\\])*)"/);
4331
+ const raw = (contentStringMatch && contentStringMatch[1]) || (textMatch && textMatch[1]) || '';
4332
+ if (raw) {
4333
+ try {
4334
+ firstPrompt = truncateText(JSON.parse(`"${raw}"`));
4335
+ } catch (_) {
4336
+ firstPrompt = truncateText(raw);
4337
+ }
4338
+ }
4339
+ }
4340
+ }
4341
+ }
4342
+
4343
+ if (!createdAt) {
4344
+ createdAt = stat.mtime.toISOString();
4345
+ }
4346
+
4347
+ const cwd = projectHash ? path.join(getGeminiTmpDir(), projectHash) : '';
4348
+
4349
+ return {
4350
+ source: 'gemini',
4351
+ sourceLabel: 'Gemini CLI',
4352
+ provider,
4353
+ model,
4354
+ models,
4355
+ sessionId,
4356
+ title: firstPrompt || sessionId || fileName,
4357
+ cwd,
4358
+ createdAt,
4359
+ updatedAt,
4360
+ messageCount,
4361
+ totalTokens: 0,
4362
+ contextWindow: 0,
4363
+ inputTokens: 0,
4364
+ cachedInputTokens: 0,
4365
+ outputTokens: 0,
4366
+ reasoningOutputTokens: 0,
4367
+ __messageCountExact: false,
4368
+ filePath,
4369
+ keywords: [],
4370
+ capabilities: { code: true }
4371
+ };
4372
+ }
4373
+
4374
+ function listCodexSessions(limit, options = {}) {
4375
+ const codexSessionsDir = getCodexSessionsDir();
4376
+ const scanFactor = Number.isFinite(Number(options.scanFactor))
4377
+ ? Math.max(1, Number(options.scanFactor))
4378
+ : SESSION_SCAN_FACTOR;
4379
+ const minFiles = Number.isFinite(Number(options.minFiles))
4380
+ ? Math.max(1, Number(options.minFiles))
4381
+ : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR);
4382
+ const targetCount = Number.isFinite(Number(options.targetCount))
4383
+ ? Math.max(1, Math.floor(Number(options.targetCount)))
4384
+ : Math.max(1, Math.floor(limit * scanFactor));
4385
+ const scanCount = Number.isFinite(Number(options.scanCount))
4386
+ ? Math.max(targetCount, Math.floor(Number(options.scanCount)))
4387
+ : Math.max(targetCount, minFiles);
4388
+ const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
4389
+ ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
4390
+ : Math.max(scanCount * 2, minFiles);
4391
+ const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes))
4392
+ ? Math.max(1024, Math.floor(Number(options.summaryReadBytes)))
4393
+ : SESSION_SUMMARY_READ_BYTES;
4394
+ const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
4395
+ ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
4396
+ : SESSION_TITLE_READ_BYTES;
4397
+ const files = collectRecentJsonlFiles(codexSessionsDir, {
4398
+ returnCount: scanCount,
4399
+ maxFilesScanned
4400
+ });
4401
+ const sessions = [];
4402
+
4403
+ for (const filePath of files) {
4404
+ const summary = parseCodexSessionSummary(filePath, {
4405
+ summaryReadBytes,
4406
+ titleReadBytes
4407
+ });
4408
+ if (summary) {
4409
+ sessions.push(summary);
4410
+ }
4411
+
4412
+ if (sessions.length >= targetCount) {
4413
+ break;
4414
+ }
4415
+ }
4416
+
4417
+ return mergeAndLimitSessions(sessions, limit);
4418
+ }
4419
+
4420
+ function listClaudeSessions(limit, options = {}) {
4421
+ const claudeProjectsDir = getClaudeProjectsDir();
4422
+ if (!fs.existsSync(claudeProjectsDir)) {
4423
+ return [];
4424
+ }
4425
+
4426
+ const scanFactor = Number.isFinite(Number(options.scanFactor))
4427
+ ? Math.max(1, Number(options.scanFactor))
4428
+ : SESSION_SCAN_FACTOR;
4429
+ const minFiles = Number.isFinite(Number(options.minFiles))
4430
+ ? Math.max(1, Number(options.minFiles))
4431
+ : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR);
4432
+ const targetCount = Number.isFinite(Number(options.targetCount))
4433
+ ? Math.max(1, Math.floor(Number(options.targetCount)))
4434
+ : Math.max(1, Math.floor(limit * scanFactor));
4435
+ const scanCount = Number.isFinite(Number(options.scanCount))
4436
+ ? Math.max(targetCount, Math.floor(Number(options.scanCount)))
4437
+ : Math.max(targetCount, minFiles);
4438
+ const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
4439
+ ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
4440
+ : Math.max(scanCount * 2, minFiles);
4441
+ const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes))
4442
+ ? Math.max(1024, Math.floor(Number(options.summaryReadBytes)))
4443
+ : SESSION_SUMMARY_READ_BYTES;
4444
+ const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
4445
+ ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
4446
+ : SESSION_TITLE_READ_BYTES;
4447
+
4448
+ const sessions = [];
4449
+ let projectDirs = [];
4450
+ try {
4451
+ projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true })
4452
+ .filter(entry => entry.isDirectory())
4453
+ .map(entry => path.join(claudeProjectsDir, entry.name));
4454
+ } catch (e) {
4455
+ projectDirs = [];
3976
4456
  }
3977
4457
 
3978
4458
  for (const projectDir of projectDirs) {
@@ -4141,8 +4621,144 @@ function listClaudeSessions(limit, options = {}) {
4141
4621
  return mergeAndLimitSessions(sessions, limit);
4142
4622
  }
4143
4623
 
4624
+ function listGeminiSessions(limit, options = {}) {
4625
+ const geminiTmpDir = getGeminiTmpDir();
4626
+ if (!fs.existsSync(geminiTmpDir)) {
4627
+ return [];
4628
+ }
4629
+
4630
+ const scanFactor = Number.isFinite(Number(options.scanFactor))
4631
+ ? Math.max(1, Number(options.scanFactor))
4632
+ : SESSION_SCAN_FACTOR;
4633
+ const minFiles = Number.isFinite(Number(options.minFiles))
4634
+ ? Math.max(1, Number(options.minFiles))
4635
+ : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR);
4636
+ const targetCount = Number.isFinite(Number(options.targetCount))
4637
+ ? Math.max(1, Math.floor(Number(options.targetCount)))
4638
+ : Math.max(1, Math.floor(limit * scanFactor));
4639
+ const scanCount = Number.isFinite(Number(options.scanCount))
4640
+ ? Math.max(targetCount, Math.floor(Number(options.scanCount)))
4641
+ : Math.max(targetCount, minFiles);
4642
+ const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
4643
+ ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
4644
+ : Math.max(scanCount * 2, minFiles);
4645
+ const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes))
4646
+ ? Math.max(1024, Math.floor(Number(options.summaryReadBytes)))
4647
+ : SESSION_SUMMARY_READ_BYTES;
4648
+ const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
4649
+ ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
4650
+ : SESSION_TITLE_READ_BYTES;
4651
+
4652
+ const sessions = [];
4653
+ const filesMeta = [];
4654
+ let scanned = 0;
4655
+ let projectDirs = [];
4656
+ try {
4657
+ projectDirs = fs.readdirSync(geminiTmpDir, { withFileTypes: true })
4658
+ .filter(entry => entry.isDirectory())
4659
+ .map(entry => path.join(geminiTmpDir, entry.name));
4660
+ } catch (_) {
4661
+ projectDirs = [];
4662
+ }
4663
+
4664
+ for (const projectDir of projectDirs) {
4665
+ const chatsDir = path.join(projectDir, 'chats');
4666
+ if (!fs.existsSync(chatsDir)) {
4667
+ continue;
4668
+ }
4669
+ let entries = [];
4670
+ try {
4671
+ entries = fs.readdirSync(chatsDir, { withFileTypes: true });
4672
+ } catch (_) {
4673
+ entries = [];
4674
+ }
4675
+ for (const entry of entries) {
4676
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
4677
+ continue;
4678
+ }
4679
+ const fullPath = path.join(chatsDir, entry.name);
4680
+ try {
4681
+ const stat = fs.statSync(fullPath);
4682
+ filesMeta.push({ filePath: fullPath, mtimeMs: stat.mtimeMs || 0 });
4683
+ } catch (_) {}
4684
+ scanned += 1;
4685
+ if (scanned >= maxFilesScanned) {
4686
+ break;
4687
+ }
4688
+ }
4689
+ if (scanned >= maxFilesScanned) {
4690
+ break;
4691
+ }
4692
+ }
4693
+
4694
+ filesMeta.sort((a, b) => b.mtimeMs - a.mtimeMs);
4695
+ for (const item of filesMeta.slice(0, scanCount)) {
4696
+ const summary = parseGeminiSessionSummary(item.filePath, { summaryReadBytes, titleReadBytes });
4697
+ if (summary) {
4698
+ sessions.push(summary);
4699
+ }
4700
+ if (sessions.length >= targetCount) {
4701
+ break;
4702
+ }
4703
+ }
4704
+
4705
+ return mergeAndLimitSessions(sessions, limit);
4706
+ }
4707
+
4708
+ function listCodeBuddySessions(limit, options = {}) {
4709
+ const projectsDir = getCodeBuddyProjectsDir();
4710
+ if (!fs.existsSync(projectsDir)) {
4711
+ return [];
4712
+ }
4713
+
4714
+ const scanFactor = Number.isFinite(Number(options.scanFactor))
4715
+ ? Math.max(1, Number(options.scanFactor))
4716
+ : SESSION_SCAN_FACTOR;
4717
+ const minFiles = Number.isFinite(Number(options.minFiles))
4718
+ ? Math.max(1, Number(options.minFiles))
4719
+ : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR);
4720
+ const targetCount = Number.isFinite(Number(options.targetCount))
4721
+ ? Math.max(1, Math.floor(Number(options.targetCount)))
4722
+ : Math.max(1, Math.floor(limit * scanFactor));
4723
+ const scanCount = Number.isFinite(Number(options.scanCount))
4724
+ ? Math.max(targetCount, Math.floor(Number(options.scanCount)))
4725
+ : Math.max(targetCount, minFiles);
4726
+ const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
4727
+ ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
4728
+ : Math.max(scanCount * 2, minFiles);
4729
+ const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes))
4730
+ ? Math.max(1024, Math.floor(Number(options.summaryReadBytes)))
4731
+ : SESSION_SUMMARY_READ_BYTES;
4732
+ const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
4733
+ ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
4734
+ : SESSION_TITLE_READ_BYTES;
4735
+
4736
+ const files = collectRecentJsonlFiles(projectsDir, {
4737
+ returnCount: scanCount,
4738
+ maxFilesScanned,
4739
+ ignoreSubPath: `${path.sep}subagents${path.sep}`
4740
+ });
4741
+ const sessions = [];
4742
+ for (const filePath of files) {
4743
+ if (path.basename(filePath) === 'history.jsonl') {
4744
+ continue;
4745
+ }
4746
+ const summary = parseCodeBuddySessionSummary(filePath, {
4747
+ summaryReadBytes,
4748
+ titleReadBytes
4749
+ });
4750
+ if (summary) {
4751
+ sessions.push(summary);
4752
+ }
4753
+ if (sessions.length >= targetCount) {
4754
+ break;
4755
+ }
4756
+ }
4757
+ return mergeAndLimitSessions(sessions, limit);
4758
+ }
4759
+
4144
4760
  async function listAllSessions(params = {}) {
4145
- const source = params.source === 'codex' || params.source === 'claude'
4761
+ const source = params.source === 'codex' || params.source === 'claude' || params.source === 'gemini' || params.source === 'codebuddy'
4146
4762
  ? params.source
4147
4763
  : 'all';
4148
4764
  const rawLimit = Number(params.limit);
@@ -4155,12 +4771,14 @@ async function listAllSessions(params = {}) {
4155
4771
  const queryTokens = expandSessionQueryTokens(normalizeQueryTokens(params.query));
4156
4772
  const hasQuery = queryTokens.length > 0;
4157
4773
  const browseLightweight = params.browseLightweight === true && !hasQuery && !hasPathFilter;
4158
- const cacheKey = hasQuery ? '' : `${browseLightweight ? 'browse' : 'default'}:${source}:${limit}:${normalizedPathFilter}`;
4159
- if (!hasQuery) {
4160
- const cached = getSessionListCache(cacheKey, forceRefresh);
4161
- if (cached) {
4162
- return cached;
4163
- }
4774
+ const queryKeyRaw = typeof params.query === 'string' ? params.query.trim() : '';
4775
+ const queryKey = queryKeyRaw.length > 240 ? queryKeyRaw.slice(0, 240) : queryKeyRaw;
4776
+ const cacheKey = hasQuery
4777
+ ? `query:${source}:${limit}:${normalizedPathFilter}:${params.queryMode || ''}:${params.queryScope || ''}:${params.roleFilter || ''}:${Number(params.contentScanLimit) || ''}:${Number(params.contentScanBytes) || ''}:${queryKey}`
4778
+ : `${browseLightweight ? 'browse' : 'default'}:${source}:${limit}:${normalizedPathFilter}`;
4779
+ const cached = getSessionListCache(cacheKey, forceRefresh);
4780
+ if (cached) {
4781
+ return cached;
4164
4782
  }
4165
4783
 
4166
4784
  const scanOptions = hasPathFilter
@@ -4184,6 +4802,12 @@ async function listAllSessions(params = {}) {
4184
4802
  if (source === 'all' || source === 'claude') {
4185
4803
  sessions = sessions.concat(listSessionInventoryBySource('claude', limit, scanOptions, { forceRefresh }));
4186
4804
  }
4805
+ if (source === 'all' || source === 'gemini') {
4806
+ sessions = sessions.concat(listSessionInventoryBySource('gemini', limit, scanOptions, { forceRefresh }));
4807
+ }
4808
+ if (source === 'all' || source === 'codebuddy') {
4809
+ sessions = sessions.concat(listSessionInventoryBySource('codebuddy', limit, scanOptions, { forceRefresh }));
4810
+ }
4187
4811
 
4188
4812
  if (hasPathFilter) {
4189
4813
  sessions = sessions.filter(item => matchesSessionPathFilter(item, normalizedPathFilter));
@@ -4201,9 +4825,7 @@ async function listAllSessions(params = {}) {
4201
4825
  });
4202
4826
  }
4203
4827
  result = mergeAndLimitSessions(result, limit);
4204
- if (!hasQuery) {
4205
- setSessionListCache(cacheKey, result);
4206
- }
4828
+ setSessionListCache(cacheKey, result);
4207
4829
  return result;
4208
4830
  }
4209
4831
 
@@ -4266,6 +4888,8 @@ async function listSessionUsage(params = {}) {
4266
4888
  listSessionBrowse,
4267
4889
  parseCodexSessionSummary,
4268
4890
  parseClaudeSessionSummary,
4891
+ parseCodeBuddySessionSummary,
4892
+ parseGeminiSessionSummary,
4269
4893
  MAX_SESSION_USAGE_LIST_SIZE,
4270
4894
  SESSION_BROWSE_SUMMARY_READ_BYTES
4271
4895
  });
@@ -4273,10 +4897,10 @@ async function listSessionUsage(params = {}) {
4273
4897
 
4274
4898
  function listSessionPaths(params = {}) {
4275
4899
  const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
4276
- if (source && source !== 'codex' && source !== 'claude' && source !== 'all') {
4900
+ if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') {
4277
4901
  return [];
4278
4902
  }
4279
- const validSource = source === 'codex' || source === 'claude' ? source : 'all';
4903
+ const validSource = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' ? source : 'all';
4280
4904
  const rawLimit = Number(params.limit);
4281
4905
  const limit = Number.isFinite(rawLimit)
4282
4906
  ? Math.max(1, Math.min(rawLimit, MAX_SESSION_PATH_LIST_SIZE))
@@ -4304,6 +4928,12 @@ function listSessionPaths(params = {}) {
4304
4928
  if (validSource === 'all' || validSource === 'claude') {
4305
4929
  sessions = sessions.concat(listSessionInventoryBySource('claude', gatherLimit, scanOptions, { forceRefresh }));
4306
4930
  }
4931
+ if (validSource === 'all' || validSource === 'gemini') {
4932
+ sessions = sessions.concat(listSessionInventoryBySource('gemini', gatherLimit, scanOptions, { forceRefresh }));
4933
+ }
4934
+ if (validSource === 'all' || validSource === 'codebuddy') {
4935
+ sessions = sessions.concat(listSessionInventoryBySource('codebuddy', gatherLimit, scanOptions, { forceRefresh }));
4936
+ }
4307
4937
 
4308
4938
  const dedupedPaths = [];
4309
4939
  const seen = new Set();
@@ -4329,7 +4959,14 @@ function listSessionPaths(params = {}) {
4329
4959
  }
4330
4960
 
4331
4961
  function resolveSessionFilePath(source, filePath, sessionId) {
4332
- const root = source === 'claude' ? getClaudeProjectsDir() : getCodexSessionsDir();
4962
+ const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy'
4963
+ ? source
4964
+ : 'codex';
4965
+ const root = normalizedSource === 'claude'
4966
+ ? getClaudeProjectsDir()
4967
+ : (normalizedSource === 'gemini'
4968
+ ? getGeminiTmpDir()
4969
+ : (normalizedSource === 'codebuddy' ? getCodeBuddyProjectsDir() : getCodexSessionsDir()));
4333
4970
  if (!root || !fs.existsSync(root)) {
4334
4971
  return '';
4335
4972
  }
@@ -4344,7 +4981,7 @@ function resolveSessionFilePath(source, filePath, sessionId) {
4344
4981
 
4345
4982
  if (typeof sessionId === 'string' && sessionId.trim()) {
4346
4983
  const targetId = sessionId.trim().toLowerCase();
4347
- const lookupStore = g_sessionFileLookupCache[source === 'claude' ? 'claude' : 'codex'];
4984
+ const lookupStore = g_sessionFileLookupCache[normalizedSource];
4348
4985
  if (lookupStore instanceof Map && lookupStore.has(targetId)) {
4349
4986
  const cachedPath = lookupStore.get(targetId);
4350
4987
  if (cachedPath && fs.existsSync(cachedPath) && isPathInside(cachedPath, root)) {
@@ -4352,8 +4989,39 @@ function resolveSessionFilePath(source, filePath, sessionId) {
4352
4989
  }
4353
4990
  lookupStore.delete(targetId);
4354
4991
  }
4355
- const files = collectJsonlFiles(root, 5000);
4356
- const matchedFile = files.find(item => path.basename(item, '.jsonl').toLowerCase() === targetId);
4992
+ let matchedFile = '';
4993
+ if (normalizedSource === 'gemini') {
4994
+ const filesMeta = [];
4995
+ let projectDirs = [];
4996
+ try {
4997
+ projectDirs = fs.readdirSync(root, { withFileTypes: true })
4998
+ .filter(entry => entry.isDirectory())
4999
+ .map(entry => path.join(root, entry.name));
5000
+ } catch (_) {
5001
+ projectDirs = [];
5002
+ }
5003
+ for (const projectDir of projectDirs) {
5004
+ const chatsDir = path.join(projectDir, 'chats');
5005
+ if (!fs.existsSync(chatsDir)) continue;
5006
+ let entries = [];
5007
+ try {
5008
+ entries = fs.readdirSync(chatsDir, { withFileTypes: true });
5009
+ } catch (_) {
5010
+ entries = [];
5011
+ }
5012
+ for (const entry of entries) {
5013
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
5014
+ const fullPath = path.join(chatsDir, entry.name);
5015
+ filesMeta.push(fullPath);
5016
+ if (filesMeta.length >= 5000) break;
5017
+ }
5018
+ if (filesMeta.length >= 5000) break;
5019
+ }
5020
+ matchedFile = filesMeta.find(item => path.basename(item, '.json').toLowerCase() === targetId) || '';
5021
+ } else {
5022
+ const files = collectJsonlFiles(root, 5000);
5023
+ matchedFile = files.find(item => path.basename(item, '.jsonl').toLowerCase() === targetId) || '';
5024
+ }
4357
5025
  if (matchedFile && fs.existsSync(matchedFile)) {
4358
5026
  return matchedFile;
4359
5027
  }
@@ -4530,11 +5198,15 @@ function moveFileSync(sourcePath, targetPath) {
4530
5198
 
4531
5199
  function buildSessionSummaryFallback(source, filePath, sessionId = '') {
4532
5200
  const resolvedSessionId = sessionId || path.basename(filePath, '.jsonl');
4533
- const sourceLabel = source === 'claude' ? 'Claude Code' : 'Codex';
5201
+ const sourceLabel = source === 'claude'
5202
+ ? 'Claude Code'
5203
+ : (source === 'gemini' ? 'Gemini CLI' : (source === 'codebuddy' ? 'CodeBuddy Code' : 'Codex'));
4534
5204
  return {
4535
5205
  source,
4536
5206
  sourceLabel,
4537
- provider: source === 'claude' ? 'claude' : 'codex',
5207
+ provider: source === 'claude'
5208
+ ? 'claude'
5209
+ : (source === 'gemini' ? 'gemini' : (source === 'codebuddy' ? 'codebuddy' : 'codex')),
4538
5210
  sessionId: resolvedSessionId,
4539
5211
  title: resolvedSessionId,
4540
5212
  cwd: '',
@@ -4545,7 +5217,7 @@ function buildSessionSummaryFallback(source, filePath, sessionId = '') {
4545
5217
  contextWindow: 0,
4546
5218
  filePath,
4547
5219
  keywords: [],
4548
- capabilities: source === 'claude' ? { code: true } : {}
5220
+ capabilities: source === 'claude' || source === 'gemini' || source === 'codebuddy' ? { code: true } : {}
4549
5221
  };
4550
5222
  }
4551
5223
 
@@ -4556,11 +5228,14 @@ function generateSessionTrashId() {
4556
5228
  return `trash-${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
4557
5229
  }
4558
5230
 
4559
- function allocateSessionTrashTarget() {
5231
+ function allocateSessionTrashTarget(extension = 'jsonl') {
4560
5232
  ensureDir(SESSION_TRASH_FILES_DIR);
5233
+ const safeExt = typeof extension === 'string' && extension.trim()
5234
+ ? extension.trim().replace(/^\./, '')
5235
+ : 'jsonl';
4561
5236
  for (let attempt = 0; attempt < 6; attempt += 1) {
4562
5237
  const trashId = generateSessionTrashId();
4563
- const trashFileName = `${trashId}.jsonl`;
5238
+ const trashFileName = `${trashId}.${safeExt}`;
4564
5239
  const trashFilePath = path.join(SESSION_TRASH_FILES_DIR, trashFileName);
4565
5240
  if (!fs.existsSync(trashFilePath)) {
4566
5241
  return { trashId, trashFileName, trashFilePath };
@@ -4569,8 +5244,8 @@ function allocateSessionTrashTarget() {
4569
5244
  const fallbackId = `trash-${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
4570
5245
  return {
4571
5246
  trashId: fallbackId,
4572
- trashFileName: `${fallbackId}.jsonl`,
4573
- trashFilePath: path.join(SESSION_TRASH_FILES_DIR, `${fallbackId}.jsonl`)
5247
+ trashFileName: `${fallbackId}.${safeExt}`,
5248
+ trashFilePath: path.join(SESSION_TRASH_FILES_DIR, `${fallbackId}.${safeExt}`)
4574
5249
  };
4575
5250
  }
4576
5251
 
@@ -4578,7 +5253,13 @@ function normalizeSessionTrashEntry(entry) {
4578
5253
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
4579
5254
  return null;
4580
5255
  }
4581
- const source = entry.source === 'claude' ? 'claude' : (entry.source === 'codex' ? 'codex' : '');
5256
+ const source = entry.source === 'claude'
5257
+ ? 'claude'
5258
+ : (entry.source === 'codex'
5259
+ ? 'codex'
5260
+ : (entry.source === 'gemini'
5261
+ ? 'gemini'
5262
+ : (entry.source === 'codebuddy' ? 'codebuddy' : '')));
4582
5263
  const trashId = typeof entry.trashId === 'string' ? entry.trashId.trim() : '';
4583
5264
  if (!source || !trashId || trashId.includes('/') || trashId.includes('\\') || trashId.includes('\0')) {
4584
5265
  return null;
@@ -4593,7 +5274,9 @@ function normalizeSessionTrashEntry(entry) {
4593
5274
  trashId,
4594
5275
  trashFileName,
4595
5276
  source,
4596
- sourceLabel: source === 'claude' ? 'Claude Code' : 'Codex',
5277
+ sourceLabel: source === 'claude'
5278
+ ? 'Claude Code'
5279
+ : (source === 'gemini' ? 'Gemini CLI' : (source === 'codebuddy' ? 'CodeBuddy Code' : 'Codex')),
4597
5280
  sessionId: sessionId || trashId,
4598
5281
  title: typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : (sessionId || trashId),
4599
5282
  cwd: typeof entry.cwd === 'string' ? entry.cwd : '',
@@ -4609,7 +5292,7 @@ function normalizeSessionTrashEntry(entry) {
4609
5292
  originalFilePath: typeof entry.originalFilePath === 'string' ? entry.originalFilePath : '',
4610
5293
  provider: typeof entry.provider === 'string' && entry.provider.trim()
4611
5294
  ? entry.provider.trim()
4612
- : (source === 'claude' ? 'claude' : 'codex'),
5295
+ : (source === 'claude' ? 'claude' : (source === 'gemini' ? 'gemini' : (source === 'codebuddy' ? 'codebuddy' : 'codex'))),
4613
5296
  keywords: normalizeKeywords(entry.keywords),
4614
5297
  capabilities: normalizeCapabilities(entry.capabilities),
4615
5298
  claudeIndexPath: typeof entry.claudeIndexPath === 'string' ? entry.claudeIndexPath : '',
@@ -4727,7 +5410,11 @@ function resolveSessionRestoreTarget(entry) {
4727
5410
  if (!normalized) {
4728
5411
  return '';
4729
5412
  }
4730
- const root = normalized.source === 'claude' ? getClaudeProjectsDir() : getCodexSessionsDir();
5413
+ const root = normalized.source === 'claude'
5414
+ ? getClaudeProjectsDir()
5415
+ : (normalized.source === 'gemini'
5416
+ ? getGeminiTmpDir()
5417
+ : (normalized.source === 'codebuddy' ? getCodeBuddyProjectsDir() : getCodexSessionsDir()));
4731
5418
  const originalFilePath = typeof normalized.originalFilePath === 'string' ? normalized.originalFilePath.trim() : '';
4732
5419
  if (!root || !originalFilePath) {
4733
5420
  return '';
@@ -4861,14 +5548,20 @@ function upsertClaudeSessionIndexEntry(indexPath, sessionFilePath, entry) {
4861
5548
  }
4862
5549
 
4863
5550
  async function listSessionTrashItems(params = {}) {
4864
- const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : 'all');
5551
+ const source = params.source === 'claude'
5552
+ ? 'claude'
5553
+ : (params.source === 'codex'
5554
+ ? 'codex'
5555
+ : (params.source === 'gemini'
5556
+ ? 'gemini'
5557
+ : (params.source === 'codebuddy' ? 'codebuddy' : 'all')));
4865
5558
  const countOnly = params.countOnly === true;
4866
5559
  const rawLimit = Number(params.limit);
4867
5560
  const limit = Number.isFinite(rawLimit)
4868
5561
  ? Math.max(1, Math.min(rawLimit, MAX_SESSION_TRASH_LIST_SIZE))
4869
5562
  : 200;
4870
5563
  const allEntries = readSessionTrashEntries();
4871
- let items = source === 'codex' || source === 'claude'
5564
+ let items = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy'
4872
5565
  ? allEntries.filter((entry) => entry.source === source)
4873
5566
  : allEntries.slice();
4874
5567
  items.sort((a, b) => {
@@ -5048,7 +5741,13 @@ async function purgeSessionTrashItems(params = {}) {
5048
5741
  }
5049
5742
 
5050
5743
  async function trashSessionData(params = {}) {
5051
- const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
5744
+ const source = params.source === 'claude'
5745
+ ? 'claude'
5746
+ : (params.source === 'codex'
5747
+ ? 'codex'
5748
+ : (params.source === 'gemini'
5749
+ ? 'gemini'
5750
+ : (params.source === 'codebuddy' ? 'codebuddy' : '')));
5052
5751
  if (!source) {
5053
5752
  return { error: 'Invalid source' };
5054
5753
  }
@@ -5058,14 +5757,18 @@ async function trashSessionData(params = {}) {
5058
5757
  return { error: 'Session file not found' };
5059
5758
  }
5060
5759
 
5061
- const summary = (source === 'claude' ? parseClaudeSessionSummary(filePath) : parseCodexSessionSummary(filePath))
5760
+ const summary = (source === 'claude'
5761
+ ? parseClaudeSessionSummary(filePath)
5762
+ : (source === 'gemini'
5763
+ ? parseGeminiSessionSummary(filePath)
5764
+ : (source === 'codebuddy' ? parseCodeBuddySessionSummary(filePath) : parseCodexSessionSummary(filePath))))
5062
5765
  || buildSessionSummaryFallback(source, filePath, params.sessionId);
5063
5766
  const exactMessageCount = await countConversationMessagesInFile(filePath, source);
5064
5767
  if (Number.isFinite(Number(exactMessageCount))) {
5065
5768
  summary.messageCount = Math.max(0, Math.floor(Number(exactMessageCount)));
5066
5769
  }
5067
- const sessionId = summary.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
5068
- const { trashId, trashFileName, trashFilePath } = allocateSessionTrashTarget();
5770
+ const sessionId = summary.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl');
5771
+ const { trashId, trashFileName, trashFilePath } = allocateSessionTrashTarget(source === 'gemini' ? 'json' : 'jsonl');
5069
5772
  const deletedAt = new Date().toISOString();
5070
5773
  const claudeIndexPath = source === 'claude' ? findClaudeSessionIndexPath(filePath) : '';
5071
5774
  let removedClaudeIndexEntry = null;
@@ -5149,7 +5852,13 @@ async function trashSessionData(params = {}) {
5149
5852
  }
5150
5853
 
5151
5854
  async function deleteSessionData(params = {}) {
5152
- const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
5855
+ const source = params.source === 'claude'
5856
+ ? 'claude'
5857
+ : (params.source === 'codex'
5858
+ ? 'codex'
5859
+ : (params.source === 'gemini'
5860
+ ? 'gemini'
5861
+ : (params.source === 'codebuddy' ? 'codebuddy' : '')));
5153
5862
  if (!source) {
5154
5863
  return { error: 'Invalid source' };
5155
5864
  }
@@ -5159,7 +5868,7 @@ async function deleteSessionData(params = {}) {
5159
5868
  return { error: 'Session file not found' };
5160
5869
  }
5161
5870
 
5162
- const sessionId = params.sessionId || path.basename(filePath, '.jsonl');
5871
+ const sessionId = params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl');
5163
5872
  let fileDeleted = false;
5164
5873
  try {
5165
5874
  fs.unlinkSync(filePath);
@@ -5484,6 +6193,38 @@ function extractClaudeMessageFromRecord(record, state, lineIndex = -1) {
5484
6193
  }
5485
6194
  }
5486
6195
 
6196
+ function extractCodeBuddyMessageFromRecord(record, state, lineIndex = -1) {
6197
+ if (record && record.timestamp) {
6198
+ state.updatedAt = toIsoTime(record.timestamp, state.updatedAt);
6199
+ }
6200
+
6201
+ if (record && typeof record.sessionId === 'string' && record.sessionId.trim()) {
6202
+ state.sessionId = record.sessionId.trim();
6203
+ }
6204
+
6205
+ if (!state.cwd && record && typeof record.cwd === 'string' && record.cwd.trim()) {
6206
+ state.cwd = record.cwd.trim();
6207
+ }
6208
+
6209
+ if (!record || record.type !== 'message') {
6210
+ return;
6211
+ }
6212
+
6213
+ const role = normalizeRole(record.role);
6214
+ if (role === 'user' || role === 'assistant' || role === 'system') {
6215
+ const content = record.message?.content ?? record.content ?? '';
6216
+ const text = extractMessageText(content);
6217
+ if (text && canAppendMessage(state)) {
6218
+ state.messages.push({
6219
+ role,
6220
+ text,
6221
+ timestamp: toIsoTime(record.timestamp, ''),
6222
+ recordLineIndex: Number.isInteger(lineIndex) ? lineIndex : -1
6223
+ });
6224
+ }
6225
+ }
6226
+ }
6227
+
5487
6228
  function recordHasCodexMessage(record) {
5488
6229
  if (!record || record.type !== 'response_item' || !record.payload) {
5489
6230
  return false;
@@ -5512,10 +6253,23 @@ function recordHasClaudeMessage(record) {
5512
6253
  return !!text;
5513
6254
  }
5514
6255
 
6256
+ function recordHasCodeBuddyMessage(record) {
6257
+ if (!record || record.type !== 'message') {
6258
+ return false;
6259
+ }
6260
+ const role = normalizeRole(record.role);
6261
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') {
6262
+ return false;
6263
+ }
6264
+ const content = record.message?.content ?? record.content ?? '';
6265
+ const text = extractMessageText(content);
6266
+ return !!text;
6267
+ }
6268
+
5515
6269
  function recordHasMessage(record, source) {
5516
- return source === 'codex'
5517
- ? recordHasCodexMessage(record)
5518
- : recordHasClaudeMessage(record);
6270
+ if (source === 'codex') return recordHasCodexMessage(record);
6271
+ if (source === 'codebuddy') return recordHasCodeBuddyMessage(record);
6272
+ return recordHasClaudeMessage(record);
5519
6273
  }
5520
6274
 
5521
6275
  function extractMessagesFromRecords(records, source, options = {}) {
@@ -5533,6 +6287,8 @@ function extractMessagesFromRecords(records, source, options = {}) {
5533
6287
  const record = records[lineIndex];
5534
6288
  if (source === 'codex') {
5535
6289
  extractCodexMessageFromRecord(record, state, lineIndex);
6290
+ } else if (source === 'codebuddy') {
6291
+ extractCodeBuddyMessageFromRecord(record, state, lineIndex);
5536
6292
  } else {
5537
6293
  extractClaudeMessageFromRecord(record, state, lineIndex);
5538
6294
  }
@@ -5594,6 +6350,8 @@ async function extractMessagesFromFile(filePath, source, options = {}) {
5594
6350
 
5595
6351
  if (source === 'codex') {
5596
6352
  extractCodexMessageFromRecord(record, state, currentLineIndex);
6353
+ } else if (source === 'codebuddy') {
6354
+ extractCodeBuddyMessageFromRecord(record, state, currentLineIndex);
5597
6355
  } else {
5598
6356
  extractClaudeMessageFromRecord(record, state, currentLineIndex);
5599
6357
  }
@@ -5618,7 +6376,13 @@ async function extractMessagesFromFile(filePath, source, options = {}) {
5618
6376
  }
5619
6377
 
5620
6378
  async function readSessionDetail(params = {}) {
5621
- const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
6379
+ const source = params.source === 'claude'
6380
+ ? 'claude'
6381
+ : (params.source === 'codex'
6382
+ ? 'codex'
6383
+ : (params.source === 'gemini'
6384
+ ? 'gemini'
6385
+ : (params.source === 'codebuddy' ? 'codebuddy' : '')));
5622
6386
  if (!source) {
5623
6387
  return { error: 'Invalid source' };
5624
6388
  }
@@ -5635,9 +6399,52 @@ async function readSessionDetail(params = {}) {
5635
6399
  : DEFAULT_SESSION_DETAIL_MESSAGES;
5636
6400
  const preview = params.preview === true || params.preview === 'true';
5637
6401
 
5638
- const extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview });
5639
- const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
5640
- const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code';
6402
+ let extracted;
6403
+ if (source === 'gemini') {
6404
+ let json;
6405
+ try {
6406
+ json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
6407
+ } catch (_) {
6408
+ json = null;
6409
+ }
6410
+ if (!json || typeof json !== 'object') {
6411
+ return { error: 'Failed to parse session file' };
6412
+ }
6413
+ const rawMessages = Array.isArray(json.messages) ? json.messages : [];
6414
+ const messages = [];
6415
+ for (const entry of rawMessages) {
6416
+ if (!entry || typeof entry !== 'object') continue;
6417
+ const role = normalizeGeminiMessageRole(entry.type);
6418
+ if (!role) continue;
6419
+ const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text));
6420
+ if (!text && role !== 'system') continue;
6421
+ messages.push({
6422
+ role,
6423
+ text,
6424
+ timestamp: toIsoTime(entry.timestamp ?? entry.time ?? entry.at, '')
6425
+ });
6426
+ }
6427
+ const filtered = removeLeadingSystemMessage(messages);
6428
+ const totalMessages = filtered.length;
6429
+ const clipped = totalMessages > messageLimit;
6430
+ const sliced = clipped ? filtered.slice(Math.max(0, totalMessages - messageLimit)) : filtered;
6431
+ extracted = {
6432
+ sessionId: typeof json.sessionId === 'string' && json.sessionId.trim() ? json.sessionId.trim() : path.basename(filePath, '.json'),
6433
+ cwd: typeof json.projectRoot === 'string' ? json.projectRoot : (typeof json.cwd === 'string' ? json.cwd : ''),
6434
+ updatedAt: toIsoTime(json.lastUpdated ?? json.updatedAt, ''),
6435
+ totalMessages,
6436
+ clipped,
6437
+ messages: sliced
6438
+ };
6439
+ } else {
6440
+ extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview });
6441
+ }
6442
+ const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl');
6443
+ const sourceLabel = source === 'codex'
6444
+ ? 'Codex'
6445
+ : (source === 'claude'
6446
+ ? 'Claude Code'
6447
+ : (source === 'gemini' ? 'Gemini CLI' : 'CodeBuddy Code'));
5641
6448
  const clippedMessages = Array.isArray(extracted.messages) ? extracted.messages : [];
5642
6449
  const hasExactTotalMessages = Number.isFinite(extracted.totalMessages);
5643
6450
  const startIndex = hasExactTotalMessages
@@ -5671,7 +6478,13 @@ async function readSessionDetail(params = {}) {
5671
6478
  }
5672
6479
 
5673
6480
  async function readSessionPlain(params = {}) {
5674
- const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
6481
+ const source = params.source === 'claude'
6482
+ ? 'claude'
6483
+ : (params.source === 'codex'
6484
+ ? 'codex'
6485
+ : (params.source === 'gemini'
6486
+ ? 'gemini'
6487
+ : (params.source === 'codebuddy' ? 'codebuddy' : '')));
5675
6488
  if (!source) {
5676
6489
  return { error: 'Invalid source' };
5677
6490
  }
@@ -5682,26 +6495,57 @@ async function readSessionPlain(params = {}) {
5682
6495
  }
5683
6496
 
5684
6497
  let extracted;
5685
- try {
5686
- extracted = await extractMessagesFromFile(filePath, source, { maxMessages: Infinity });
5687
- } catch (e) {
5688
- extracted = null;
5689
- }
6498
+ if (source === 'gemini') {
6499
+ let json;
6500
+ try {
6501
+ json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
6502
+ } catch (_) {
6503
+ json = null;
6504
+ }
6505
+ if (!json || typeof json !== 'object') {
6506
+ return { error: 'Failed to parse session file' };
6507
+ }
6508
+ const rawMessages = Array.isArray(json.messages) ? json.messages : [];
6509
+ const messages = [];
6510
+ for (const entry of rawMessages) {
6511
+ if (!entry || typeof entry !== 'object') continue;
6512
+ const role = normalizeGeminiMessageRole(entry.type);
6513
+ if (!role) continue;
6514
+ const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text));
6515
+ if (!text && role !== 'system') continue;
6516
+ messages.push({ role, text });
6517
+ }
6518
+ extracted = {
6519
+ sessionId: typeof json.sessionId === 'string' && json.sessionId.trim() ? json.sessionId.trim() : path.basename(filePath, '.json'),
6520
+ cwd: typeof json.projectRoot === 'string' ? json.projectRoot : '',
6521
+ messages
6522
+ };
6523
+ } else {
6524
+ try {
6525
+ extracted = await extractMessagesFromFile(filePath, source, { maxMessages: Infinity });
6526
+ } catch (e) {
6527
+ extracted = null;
6528
+ }
5690
6529
 
5691
- if (!extracted) {
5692
- return { error: 'Failed to parse session file' };
5693
- }
6530
+ if (!extracted) {
6531
+ return { error: 'Failed to parse session file' };
6532
+ }
5694
6533
 
5695
- if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) {
5696
- const fallbackRecords = readJsonlRecords(filePath);
5697
- if (fallbackRecords.length === 0) {
5698
- return { error: 'Session file is empty' };
6534
+ if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) {
6535
+ const fallbackRecords = readJsonlRecords(filePath);
6536
+ if (fallbackRecords.length === 0) {
6537
+ return { error: 'Session file is empty' };
6538
+ }
6539
+ extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages: Infinity });
5699
6540
  }
5700
- extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages: Infinity });
5701
6541
  }
5702
6542
 
5703
- const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
5704
- const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code';
6543
+ const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl');
6544
+ const sourceLabel = source === 'codex'
6545
+ ? 'Codex'
6546
+ : (source === 'claude'
6547
+ ? 'Claude Code'
6548
+ : (source === 'gemini' ? 'Gemini CLI' : 'CodeBuddy Code'));
5705
6549
  const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
5706
6550
  const text = buildSessionPlainText(messages);
5707
6551
 
@@ -5716,7 +6560,13 @@ async function readSessionPlain(params = {}) {
5716
6560
  }
5717
6561
 
5718
6562
  async function exportSessionData(params = {}) {
5719
- const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
6563
+ const source = params.source === 'claude'
6564
+ ? 'claude'
6565
+ : (params.source === 'codex'
6566
+ ? 'codex'
6567
+ : (params.source === 'gemini'
6568
+ ? 'gemini'
6569
+ : (params.source === 'codebuddy' ? 'codebuddy' : '')));
5720
6570
  if (!source) {
5721
6571
  return { error: 'Invalid source' };
5722
6572
  }
@@ -5728,22 +6578,51 @@ async function exportSessionData(params = {}) {
5728
6578
  }
5729
6579
 
5730
6580
  let extracted;
5731
- try {
5732
- extracted = await extractMessagesFromFile(filePath, source, { maxMessages });
5733
- } catch (e) {
5734
- extracted = null;
5735
- }
6581
+ if (source === 'gemini') {
6582
+ let json;
6583
+ try {
6584
+ json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
6585
+ } catch (_) {
6586
+ json = null;
6587
+ }
6588
+ if (!json || typeof json !== 'object') {
6589
+ return { error: 'Failed to parse session file' };
6590
+ }
6591
+ const rawMessages = Array.isArray(json.messages) ? json.messages : [];
6592
+ const messages = [];
6593
+ for (const entry of rawMessages) {
6594
+ if (!entry || typeof entry !== 'object') continue;
6595
+ const role = normalizeGeminiMessageRole(entry.type);
6596
+ if (!role) continue;
6597
+ const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text));
6598
+ if (!text && role !== 'system') continue;
6599
+ messages.push({ role, text, timestamp: toIsoTime(entry.timestamp ?? entry.time ?? entry.at, '') });
6600
+ }
6601
+ extracted = {
6602
+ sessionId: typeof json.sessionId === 'string' && json.sessionId.trim() ? json.sessionId.trim() : path.basename(filePath, '.json'),
6603
+ cwd: typeof json.projectRoot === 'string' ? json.projectRoot : '',
6604
+ updatedAt: toIsoTime(json.lastUpdated ?? json.updatedAt, ''),
6605
+ messages: maxMessages === Infinity ? messages : messages.slice(-maxMessages),
6606
+ truncated: maxMessages !== Infinity && messages.length > maxMessages
6607
+ };
6608
+ } else {
6609
+ try {
6610
+ extracted = await extractMessagesFromFile(filePath, source, { maxMessages });
6611
+ } catch (e) {
6612
+ extracted = null;
6613
+ }
5736
6614
 
5737
- if (!extracted) {
5738
- return { error: 'Failed to parse session file' };
5739
- }
6615
+ if (!extracted) {
6616
+ return { error: 'Failed to parse session file' };
6617
+ }
5740
6618
 
5741
- if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) {
5742
- const fallbackRecords = readJsonlRecords(filePath);
5743
- if (fallbackRecords.length === 0) {
5744
- return { error: 'Session file is empty' };
6619
+ if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) {
6620
+ const fallbackRecords = readJsonlRecords(filePath);
6621
+ if (fallbackRecords.length === 0) {
6622
+ return { error: 'Session file is empty' };
6623
+ }
6624
+ extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages });
5745
6625
  }
5746
- extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages });
5747
6626
  }
5748
6627
 
5749
6628
  extracted.messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
@@ -5755,9 +6634,13 @@ async function exportSessionData(params = {}) {
5755
6634
  }
5756
6635
  }
5757
6636
 
5758
- const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
6637
+ const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl');
5759
6638
  const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
5760
- const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code';
6639
+ const sourceLabel = source === 'codex'
6640
+ ? 'Codex'
6641
+ : (source === 'claude'
6642
+ ? 'Claude Code'
6643
+ : (source === 'gemini' ? 'Gemini CLI' : 'CodeBuddy Code'));
5761
6644
  const truncated = !!extracted.truncated;
5762
6645
  const maxMessagesLabel = maxMessages === Infinity ? 'all' : maxMessages;
5763
6646
  const markdown = buildSessionMarkdown({
@@ -6003,6 +6886,60 @@ function importConfigData(payload, options = {}) {
6003
6886
  function resolveSpeedTestTarget(params) {
6004
6887
  if (!params) return { error: 'Missing params' };
6005
6888
 
6889
+ if (typeof params.kind === 'string' && params.kind.trim() === 'claude') {
6890
+ const baseUrl = typeof params.url === 'string' ? params.url.trim() : '';
6891
+ const apiKey = typeof params.apiKey === 'string' ? params.apiKey.trim() : '';
6892
+ const model = typeof params.model === 'string' ? params.model.trim() : '';
6893
+ if (!baseUrl) {
6894
+ return { error: 'Missing url' };
6895
+ }
6896
+ if (!apiKey) {
6897
+ return { error: 'Missing apiKey' };
6898
+ }
6899
+ if (!model) {
6900
+ return { error: 'Missing model' };
6901
+ }
6902
+ const normalizedBase = baseUrl.replace(/\/+$/, '');
6903
+ let parsed = null;
6904
+ try {
6905
+ parsed = new URL(normalizedBase);
6906
+ } catch (_) {
6907
+ return { error: 'Invalid URL' };
6908
+ }
6909
+ const pathname = typeof parsed.pathname === 'string' ? parsed.pathname : '';
6910
+ const trimmedPath = pathname.replace(/\/+$/, '');
6911
+ const isRootPath = !trimmedPath || trimmedPath === '/';
6912
+ const endsWithV1 = trimmedPath.endsWith('/v1');
6913
+ const makeCandidate = (url) => ({
6914
+ method: 'POST',
6915
+ url,
6916
+ body: {
6917
+ model,
6918
+ max_tokens: 16,
6919
+ messages: [{ role: 'user', content: 'ping' }]
6920
+ }
6921
+ });
6922
+ const candidates = [];
6923
+ if (endsWithV1) {
6924
+ candidates.push(makeCandidate(`${normalizedBase}/messages`));
6925
+ } else if (isRootPath) {
6926
+ candidates.push(makeCandidate(`${normalizedBase}/v1/messages`));
6927
+ candidates.push(makeCandidate(`${normalizedBase}/messages`));
6928
+ } else {
6929
+ candidates.push(makeCandidate(`${normalizedBase}/messages`));
6930
+ candidates.push(makeCandidate(`${normalizedBase}/v1/messages`));
6931
+ }
6932
+ return {
6933
+ kind: 'claude',
6934
+ candidates,
6935
+ apiKey,
6936
+ apiKeyHeader: 'x-api-key',
6937
+ headers: {
6938
+ 'anthropic-version': '2023-06-01'
6939
+ }
6940
+ };
6941
+ }
6942
+
6006
6943
  if (params.name) {
6007
6944
  const { config } = readConfigOrVirtualDefault();
6008
6945
  const providers = config.model_providers || {};
@@ -6013,20 +6950,32 @@ function resolveSpeedTestTarget(params) {
6013
6950
  if (!provider.base_url) {
6014
6951
  return { error: 'Provider missing URL' };
6015
6952
  }
6016
- const currentModel = typeof config.model === 'string' ? config.model.trim() : '';
6017
- const probeSpec = buildModelProbeSpec(provider, currentModel, provider.base_url);
6018
- if (probeSpec && probeSpec.url) {
6019
- return {
6020
- method: 'POST',
6021
- url: probeSpec.url,
6022
- body: probeSpec.body,
6023
- apiKey: provider.preferred_auth_method || ''
6024
- };
6953
+ const providerName = String(params.name).trim();
6954
+ const currentModels = readCurrentModels();
6955
+ const selectedModel = typeof currentModels[providerName] === 'string' && currentModels[providerName].trim()
6956
+ ? currentModels[providerName].trim()
6957
+ : (typeof config.model === 'string' ? config.model.trim() : '');
6958
+
6959
+ const apiKey = typeof provider.preferred_auth_method === 'string'
6960
+ ? provider.preferred_auth_method.trim()
6961
+ : '';
6962
+
6963
+ const candidates = [];
6964
+ for (const spec of buildModelProbeSpecs(provider, selectedModel, provider.base_url)) {
6965
+ if (!spec || !spec.url) continue;
6966
+ candidates.push({ method: 'POST', url: spec.url, body: spec.body });
6967
+ }
6968
+ for (const url of buildApiProbeUrlCandidates(provider.base_url, 'models')) {
6969
+ candidates.push({ method: 'GET', url });
6970
+ }
6971
+ if (candidates.length === 0) {
6972
+ candidates.push({ method: 'GET', url: provider.base_url });
6025
6973
  }
6974
+
6026
6975
  return {
6027
- method: 'GET',
6028
- url: provider.base_url,
6029
- apiKey: provider.preferred_auth_method || ''
6976
+ kind: 'provider',
6977
+ candidates,
6978
+ apiKey
6030
6979
  };
6031
6980
  }
6032
6981
 
@@ -6041,155 +6990,6 @@ function resolveSpeedTestTarget(params) {
6041
6990
  return { error: 'Missing name or url' };
6042
6991
  }
6043
6992
 
6044
- function extractApiPayloadErrorMessage(payload) {
6045
- if (!payload || typeof payload !== 'object') {
6046
- return '';
6047
- }
6048
- if (typeof payload.error === 'string' && payload.error.trim()) {
6049
- return payload.error.trim();
6050
- }
6051
- if (!payload.error || typeof payload.error !== 'object') {
6052
- return '';
6053
- }
6054
- if (typeof payload.error.message === 'string' && payload.error.message.trim()) {
6055
- return payload.error.message.trim();
6056
- }
6057
- if (typeof payload.error.code === 'string' && payload.error.code.trim()) {
6058
- return payload.error.code.trim();
6059
- }
6060
- return '';
6061
- }
6062
-
6063
- function resolveProviderChatTarget(params) {
6064
- const providerName = typeof (params && params.name) === 'string' ? params.name.trim() : '';
6065
- const prompt = typeof (params && params.prompt) === 'string' ? params.prompt.trim() : '';
6066
- if (!providerName) {
6067
- return { error: 'Provider name is required' };
6068
- }
6069
- if (!prompt) {
6070
- return { error: 'Prompt is required' };
6071
- }
6072
-
6073
- const { config } = readConfigOrVirtualDefault();
6074
- const providers = config.model_providers || {};
6075
- const provider = providers[providerName];
6076
- if (!provider || typeof provider !== 'object') {
6077
- return { error: `Provider not found: ${providerName}` };
6078
- }
6079
-
6080
- const baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : '';
6081
- if (!baseUrl) {
6082
- return { error: `Provider ${providerName} missing URL` };
6083
- }
6084
-
6085
- const currentModels = readCurrentModels();
6086
- const savedModel = currentModels && typeof currentModels[providerName] === 'string'
6087
- ? currentModels[providerName].trim()
6088
- : '';
6089
- const activeProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : '';
6090
- const activeModel = typeof config.model === 'string' ? config.model.trim() : '';
6091
- const model = savedModel || (activeProvider === providerName ? activeModel : '');
6092
- if (!model) {
6093
- return { error: `Provider ${providerName} missing current model` };
6094
- }
6095
-
6096
- const specs = buildModelConversationSpecs(provider, model, baseUrl, prompt, {
6097
- maxOutputTokens: 256
6098
- });
6099
- if (!specs.length) {
6100
- return { error: `Provider ${providerName} missing available conversation endpoint` };
6101
- }
6102
-
6103
- return {
6104
- providerName,
6105
- provider,
6106
- model,
6107
- prompt,
6108
- specs,
6109
- apiKey: typeof provider.preferred_auth_method === 'string'
6110
- ? provider.preferred_auth_method.trim()
6111
- : ''
6112
- };
6113
- }
6114
-
6115
- async function runProviderChatCheck(params = {}) {
6116
- const target = resolveProviderChatTarget(params);
6117
- if (target.error) {
6118
- return { ok: false, error: target.error };
6119
- }
6120
-
6121
- const timeoutMs = Number.isFinite(params.timeoutMs)
6122
- ? Math.max(1000, Number(params.timeoutMs))
6123
- : 30000;
6124
- let finalSpec = target.specs[0];
6125
- let result = null;
6126
-
6127
- for (let index = 0; index < target.specs.length; index += 1) {
6128
- const candidate = target.specs[index];
6129
- const probeResult = await probeJsonPost(candidate.url, candidate.body, {
6130
- apiKey: target.apiKey,
6131
- timeoutMs,
6132
- maxBytes: 512 * 1024
6133
- });
6134
- finalSpec = candidate;
6135
- result = probeResult;
6136
- const shouldTryNextCandidate = index < target.specs.length - 1
6137
- && (!probeResult.ok || probeResult.status === 404);
6138
- if (!shouldTryNextCandidate) {
6139
- break;
6140
- }
6141
- }
6142
-
6143
- if (!result || !result.ok) {
6144
- return {
6145
- ok: false,
6146
- provider: target.providerName,
6147
- model: target.model,
6148
- url: finalSpec.url,
6149
- status: Number.isFinite(result && result.status) ? result.status : 0,
6150
- durationMs: Number.isFinite(result && result.durationMs) ? result.durationMs : 0,
6151
- reply: '',
6152
- rawPreview: '',
6153
- error: result && result.error ? result.error : 'request failed'
6154
- };
6155
- }
6156
-
6157
- let payload = null;
6158
- try {
6159
- payload = result.body ? JSON.parse(result.body) : null;
6160
- } catch (e) {
6161
- payload = null;
6162
- }
6163
-
6164
- const payloadError = extractApiPayloadErrorMessage(payload);
6165
- if (result.status >= 400 || payloadError) {
6166
- return {
6167
- ok: false,
6168
- provider: target.providerName,
6169
- model: target.model,
6170
- url: finalSpec.url,
6171
- status: Number.isFinite(result.status) ? result.status : 0,
6172
- durationMs: Number.isFinite(result.durationMs) ? result.durationMs : 0,
6173
- reply: '',
6174
- rawPreview: result.body ? truncateText(result.body, 600) : '',
6175
- error: payloadError || `HTTP ${result.status}`
6176
- };
6177
- }
6178
-
6179
- const reply = extractModelResponseText(payload);
6180
- return {
6181
- ok: true,
6182
- provider: target.providerName,
6183
- model: target.model,
6184
- url: finalSpec.url,
6185
- status: Number.isFinite(result.status) ? result.status : 0,
6186
- durationMs: Number.isFinite(result.durationMs) ? result.durationMs : 0,
6187
- reply,
6188
- rawPreview: reply ? '' : (result.body ? truncateText(result.body, 600) : ''),
6189
- error: ''
6190
- };
6191
- }
6192
-
6193
6993
  function runSpeedTest(targetUrl, apiKey, options = {}) {
6194
6994
  const timeoutMs = Number.isFinite(options.timeoutMs)
6195
6995
  ? Math.max(1000, Number(options.timeoutMs))
@@ -6198,6 +6998,8 @@ function runSpeedTest(targetUrl, apiKey, options = {}) {
6198
6998
  if (method === 'POST') {
6199
6999
  return probeJsonPost(targetUrl, options.body || {}, {
6200
7000
  apiKey,
7001
+ apiKeyHeader: typeof options.apiKeyHeader === 'string' ? options.apiKeyHeader : '',
7002
+ headers: options.headers && typeof options.headers === 'object' ? options.headers : null,
6201
7003
  timeoutMs,
6202
7004
  maxBytes: 256 * 1024
6203
7005
  }).then((result) => ({
@@ -6209,6 +7011,8 @@ function runSpeedTest(targetUrl, apiKey, options = {}) {
6209
7011
  }
6210
7012
  return probeUrl(targetUrl, {
6211
7013
  apiKey,
7014
+ apiKeyHeader: typeof options.apiKeyHeader === 'string' ? options.apiKeyHeader : '',
7015
+ headers: options.headers && typeof options.headers === 'object' ? options.headers : null,
6212
7016
  timeoutMs,
6213
7017
  maxBytes: 256 * 1024
6214
7018
  }).then((result) => ({
@@ -6471,25 +7275,142 @@ async function cmdSetup() {
6471
7275
  }
6472
7276
  }
6473
7277
 
6474
- // 显示当前状态
6475
- function cmdStatus() {
6476
- const configResult = readConfigOrVirtualDefault();
6477
- if (hasConfigLoadError(configResult)) {
6478
- printConfigLoadErrorAndMarkExit(configResult);
6479
- return;
7278
+ // 显示当前状态
7279
+ function cmdStatus() {
7280
+ const configResult = readConfigOrVirtualDefault();
7281
+ if (hasConfigLoadError(configResult)) {
7282
+ printConfigLoadErrorAndMarkExit(configResult);
7283
+ return;
7284
+ }
7285
+ const { config, isVirtual } = configResult;
7286
+ const current = config.model_provider || '未设置';
7287
+ const currentModel = config.model || '未设置';
7288
+
7289
+ console.log('\n当前状态:');
7290
+ console.log(' 提供商:', current);
7291
+ console.log(' 模型:', currentModel);
7292
+ console.log(' 模型列表: 接口提供');
7293
+ if (isVirtual) {
7294
+ console.log(' 说明: 当前为虚拟默认配置(config.toml 尚未创建)');
7295
+ }
7296
+ console.log();
7297
+ }
7298
+
7299
+ function parseDoctorCommandArgs(argv = []) {
7300
+ const options = {
7301
+ format: 'json',
7302
+ lang: '',
7303
+ range: '7d',
7304
+ targetApp: 'codex',
7305
+ remote: true,
7306
+ includeInstall: true,
7307
+ includeUsage: true,
7308
+ includeTasks: true,
7309
+ includeSkills: true,
7310
+ output: ''
7311
+ };
7312
+ let cursor = 0;
7313
+ while (cursor < argv.length) {
7314
+ const token = String(argv[cursor] || '');
7315
+ if (token === '--json') {
7316
+ options.format = 'json';
7317
+ cursor += 1;
7318
+ continue;
7319
+ }
7320
+ if (token === '--format') {
7321
+ const value = String(argv[cursor + 1] || '').trim().toLowerCase();
7322
+ if (!value || value.startsWith('--')) {
7323
+ throw new Error('错误: --format 需要一个值(json/md)');
7324
+ }
7325
+ options.format = value === 'md' || value === 'markdown' ? 'md' : 'json';
7326
+ cursor += 2;
7327
+ continue;
7328
+ }
7329
+ if (token === '--output') {
7330
+ const value = String(argv[cursor + 1] || '').trim();
7331
+ if (!value || value.startsWith('--')) {
7332
+ throw new Error('错误: --output 需要一个值(文件路径)');
7333
+ }
7334
+ options.output = value;
7335
+ cursor += 2;
7336
+ continue;
7337
+ }
7338
+ if (token === '--lang') {
7339
+ const value = String(argv[cursor + 1] || '').trim().toLowerCase();
7340
+ if (!value || value.startsWith('--')) {
7341
+ throw new Error('错误: --lang 需要一个值(zh/en)');
7342
+ }
7343
+ options.lang = value === 'en' ? 'en' : 'zh';
7344
+ cursor += 2;
7345
+ continue;
7346
+ }
7347
+ if (token === '--range') {
7348
+ const value = String(argv[cursor + 1] || '').trim().toLowerCase();
7349
+ if (!value || value.startsWith('--')) {
7350
+ throw new Error('错误: --range 需要一个值(7d/30d/all)');
7351
+ }
7352
+ options.range = value === 'all' ? 'all' : (value === '30d' ? '30d' : '7d');
7353
+ cursor += 2;
7354
+ continue;
7355
+ }
7356
+ if (token === '--target-app') {
7357
+ const value = String(argv[cursor + 1] || '').trim().toLowerCase();
7358
+ if (!value || value.startsWith('--')) {
7359
+ throw new Error('错误: --target-app 需要一个值(codex/claude)');
7360
+ }
7361
+ options.targetApp = value === 'claude' ? 'claude' : 'codex';
7362
+ cursor += 2;
7363
+ continue;
7364
+ }
7365
+ if (token === '--no-remote') {
7366
+ options.remote = false;
7367
+ cursor += 1;
7368
+ continue;
7369
+ }
7370
+ if (token === '--no-install') {
7371
+ options.includeInstall = false;
7372
+ cursor += 1;
7373
+ continue;
7374
+ }
7375
+ cursor += 1;
7376
+ }
7377
+ return options;
7378
+ }
7379
+
7380
+ async function cmdDoctor(argv = []) {
7381
+ try {
7382
+ const options = parseDoctorCommandArgs(argv);
7383
+ const report = await buildDoctorReport(options, {
7384
+ getStatusPayload: buildMcpStatusPayload,
7385
+ buildInstallStatusReport,
7386
+ buildConfigHealthReport,
7387
+ listSessionUsage,
7388
+ buildTaskOverviewPayload,
7389
+ listSkills
7390
+ });
7391
+ const format = options.format === 'md' ? 'md' : 'json';
7392
+ const text = format === 'md'
7393
+ ? renderDoctorMarkdown(report)
7394
+ : JSON.stringify(report, null, 2);
7395
+ if (options.output) {
7396
+ ensureDir(path.dirname(options.output));
7397
+ fs.writeFileSync(options.output, text);
7398
+ } else {
7399
+ process.stdout.write(text + '\n');
7400
+ }
7401
+ } catch (e) {
7402
+ console.error('错误:', e && e.message ? e.message : e);
7403
+ process.exitCode = 1;
6480
7404
  }
6481
- const { config, isVirtual } = configResult;
6482
- const current = config.model_provider || '未设置';
6483
- const currentModel = config.model || '未设置';
7405
+ }
6484
7406
 
6485
- console.log('\n当前状态:');
6486
- console.log(' 提供商:', current);
6487
- console.log(' 模型:', currentModel);
6488
- console.log(' 模型列表: 接口提供');
6489
- if (isVirtual) {
6490
- console.log(' 说明: 当前为虚拟默认配置(config.toml 尚未创建)');
7407
+ async function cmdImportSkills(argv = []) {
7408
+ try {
7409
+ await cmdImportSkillsFromUrl(argv);
7410
+ } catch (e) {
7411
+ console.error('错误:', e && e.message ? e.message : e);
7412
+ process.exitCode = 1;
6491
7413
  }
6492
- console.log();
6493
7414
  }
6494
7415
 
6495
7416
  // 列出所有提供商
@@ -7262,12 +8183,57 @@ function cmdClaude(baseUrl, apiKey, model, silent = false) {
7262
8183
  }
7263
8184
 
7264
8185
  function commandExists(command, args = '') {
8186
+ const cmd = typeof command === 'string' ? command.trim() : '';
8187
+ const argText = typeof args === 'string' ? args.trim() : '';
8188
+ if (!cmd || cmd.includes('\0') || /[\r\n]/.test(cmd)) {
8189
+ return false;
8190
+ }
8191
+ const argv = argText ? argText.split(/\s+/g).filter(Boolean) : [];
8192
+ const hasSeparators = cmd.includes('/') || cmd.includes('\\');
8193
+ const useShell = process.platform === 'win32' && !hasSeparators;
8194
+ if (useShell) {
8195
+ if (!/^[A-Za-z0-9._-]+$/.test(cmd)) return false;
8196
+ if (argText && /[\r\n;&|<>`$]/.test(argText)) return false;
8197
+ }
7265
8198
  try {
7266
- execSync(`${command} ${args}`, { stdio: 'ignore', shell: process.platform === 'win32' });
7267
- return true;
7268
- } catch (e) {
8199
+ const probe = spawnSync(cmd, argv, {
8200
+ stdio: 'ignore',
8201
+ windowsHide: true,
8202
+ timeout: 5000,
8203
+ shell: useShell
8204
+ });
8205
+ return probe.status === 0;
8206
+ } catch (_) {
8207
+ return false;
8208
+ }
8209
+ }
8210
+
8211
+ function isPrivateNetworkHost(hostname) {
8212
+ const host = typeof hostname === 'string' ? hostname.trim().toLowerCase() : '';
8213
+ if (!host) return true;
8214
+ if (host === 'localhost') return true;
8215
+ const ipVer = net.isIP(host);
8216
+ if (!ipVer) {
8217
+ return false;
8218
+ }
8219
+ if (ipVer === 4) {
8220
+ const parts = host.split('.').map((x) => parseInt(x, 10));
8221
+ if (parts.length !== 4 || parts.some((x) => !Number.isFinite(x))) return true;
8222
+ const [a, b] = parts;
8223
+ if (a === 10) return true;
8224
+ if (a === 127) return true;
8225
+ if (a === 169 && b === 254) return true;
8226
+ if (a === 192 && b === 168) return true;
8227
+ if (a === 172 && b >= 16 && b <= 31) return true;
7269
8228
  return false;
7270
8229
  }
8230
+ if (ipVer === 6) {
8231
+ if (host === '::1') return true;
8232
+ if (host.startsWith('fe80:')) return true;
8233
+ if (host.startsWith('fc') || host.startsWith('fd')) return true;
8234
+ return false;
8235
+ }
8236
+ return false;
7271
8237
  }
7272
8238
 
7273
8239
  function detectPreferredPackageManager() {
@@ -7486,10 +8452,11 @@ function resolveExportOutputPath(outputPath, defaultFileName) {
7486
8452
  }
7487
8453
 
7488
8454
  function printExportSessionUsage() {
7489
- console.log('\n用法: codexmate export-session --source <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
8455
+ console.log('\n用法: codexmate export-session --source <codex|claude|gemini|codebuddy> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
7490
8456
  console.log('\n示例:');
7491
8457
  console.log(' codexmate export-session --source codex --session-id 123456');
7492
8458
  console.log(' codexmate export-session --source claude --file "~/.claude/projects/demo/session.jsonl"');
8459
+ console.log(' codexmate export-session --source codebuddy --file "~/.codebuddy/projects/demo/session.jsonl"');
7493
8460
  console.log(' codexmate export-session --source codex --session-id 123456 --max-messages=all');
7494
8461
  }
7495
8462
 
@@ -7902,6 +8869,234 @@ function writeJsonResponse(res, statusCode, payload) {
7902
8869
  res.end(body, 'utf-8');
7903
8870
  }
7904
8871
 
8872
+ function readJsonRequestBody(req, res, options = {}) {
8873
+ const maxBytes = Number.isFinite(options.maxBytes) ? Math.max(1024, Math.floor(options.maxBytes)) : MAX_API_BODY_SIZE;
8874
+ return new Promise((resolve) => {
8875
+ const chunks = [];
8876
+ let bodySize = 0;
8877
+ let bodyTooLarge = false;
8878
+ req.on('data', (chunk) => {
8879
+ if (bodyTooLarge) return;
8880
+ bodySize += chunk.length;
8881
+ if (bodySize > maxBytes) {
8882
+ bodyTooLarge = true;
8883
+ writeJsonResponse(res, 413, {
8884
+ error: `请求体过大(>${Math.floor(maxBytes / 1024 / 1024)}MB)`
8885
+ });
8886
+ req.destroy();
8887
+ resolve({ ok: false, error: 'payload-too-large' });
8888
+ return;
8889
+ }
8890
+ chunks.push(chunk);
8891
+ });
8892
+ req.on('end', () => {
8893
+ if (bodyTooLarge) return;
8894
+ const rawBuffer = chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0);
8895
+ const rawText = rawBuffer.length ? rawBuffer.toString('utf-8') : '';
8896
+ try {
8897
+ resolve({ ok: true, body: JSON.parse(rawText || '{}'), rawText, rawBuffer });
8898
+ } catch (error) {
8899
+ resolve({ ok: false, error: error && error.message ? error.message : 'invalid json' });
8900
+ }
8901
+ });
8902
+ });
8903
+ }
8904
+
8905
+ function isLoopbackRemoteAddress(value) {
8906
+ const addr = typeof value === 'string' ? value.trim() : '';
8907
+ if (!addr) return false;
8908
+ if (addr === '127.0.0.1' || addr === '::1') return true;
8909
+ if (addr === '::ffff:127.0.0.1') return true;
8910
+ return false;
8911
+ }
8912
+
8913
+ function extractRequestToken(req) {
8914
+ const headers = req && req.headers && typeof req.headers === 'object' ? req.headers : {};
8915
+ const rawAuth = typeof headers.authorization === 'string' ? headers.authorization.trim() : '';
8916
+ if (rawAuth) {
8917
+ const match = rawAuth.match(/^bearer\s+(.+)$/i);
8918
+ if (match && match[1]) return match[1].trim();
8919
+ return rawAuth;
8920
+ }
8921
+ const raw = typeof headers['x-codexmate-token'] === 'string' ? headers['x-codexmate-token'].trim() : '';
8922
+ return raw;
8923
+ }
8924
+
8925
+ function readServerToken() {
8926
+ const raw = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string' ? process.env.CODEXMATE_HTTP_TOKEN.trim() : '';
8927
+ return raw;
8928
+ }
8929
+
8930
+ function assertRequestAuthorized(req, res) {
8931
+ const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
8932
+ if (isLoopbackRemoteAddress(remoteAddr)) {
8933
+ return { ok: true, mode: 'loopback' };
8934
+ }
8935
+ const expected = readServerToken();
8936
+ if (!expected) {
8937
+ writeJsonResponse(res, 403, {
8938
+ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN or use --host 127.0.0.1)'
8939
+ });
8940
+ return { ok: false, mode: 'missing-token' };
8941
+ }
8942
+ const actual = extractRequestToken(req);
8943
+ if (!actual || actual !== expected) {
8944
+ writeJsonResponse(res, 401, { error: 'Unauthorized' });
8945
+ return { ok: false, mode: 'unauthorized' };
8946
+ }
8947
+ return { ok: true, mode: 'token' };
8948
+ }
8949
+
8950
+ const g_webhookDeliveryCache = new Map();
8951
+
8952
+ function pruneWebhookDeliveryCache() {
8953
+ const now = Date.now();
8954
+ for (const [key, expiresAt] of g_webhookDeliveryCache.entries()) {
8955
+ if (now >= expiresAt) {
8956
+ g_webhookDeliveryCache.delete(key);
8957
+ }
8958
+ }
8959
+ }
8960
+
8961
+ function rememberWebhookDeliveryId(value, ttlMs = 10 * 60 * 1000) {
8962
+ const id = typeof value === 'string' ? value.trim() : '';
8963
+ if (!id) return { ok: true, seen: false };
8964
+ pruneWebhookDeliveryCache();
8965
+ if (g_webhookDeliveryCache.has(id)) {
8966
+ return { ok: true, seen: true };
8967
+ }
8968
+ g_webhookDeliveryCache.set(id, Date.now() + ttlMs);
8969
+ while (g_webhookDeliveryCache.size > 2000) {
8970
+ const firstKey = g_webhookDeliveryCache.keys().next().value;
8971
+ if (!firstKey) break;
8972
+ g_webhookDeliveryCache.delete(firstKey);
8973
+ }
8974
+ return { ok: true, seen: false };
8975
+ }
8976
+
8977
+ function safeTimingEqual(a, b) {
8978
+ try {
8979
+ const ba = Buffer.isBuffer(a) ? a : Buffer.from(String(a || ''), 'utf-8');
8980
+ const bb = Buffer.isBuffer(b) ? b : Buffer.from(String(b || ''), 'utf-8');
8981
+ if (ba.length !== bb.length) return false;
8982
+ return crypto.timingSafeEqual(ba, bb);
8983
+ } catch (_) {
8984
+ return false;
8985
+ }
8986
+ }
8987
+
8988
+ function verifyGithubWebhookSignature(secret, signatureHeader, rawBuffer) {
8989
+ const key = typeof secret === 'string' ? secret : '';
8990
+ const signature = typeof signatureHeader === 'string' ? signatureHeader.trim() : '';
8991
+ if (!key || !signature || !signature.startsWith('sha256=')) return false;
8992
+ const expected = 'sha256=' + crypto.createHmac('sha256', key).update(rawBuffer || Buffer.alloc(0)).digest('hex');
8993
+ return safeTimingEqual(signature, expected);
8994
+ }
8995
+
8996
+ async function handleAutomationHook(req, res, source) {
8997
+ const method = (req.method || 'GET').toUpperCase();
8998
+ if (method !== 'POST') {
8999
+ writeJsonResponse(res, 405, { error: 'Method Not Allowed' });
9000
+ return;
9001
+ }
9002
+ const deliveryId = typeof (req.headers || {})['x-github-delivery'] === 'string'
9003
+ ? String(req.headers['x-github-delivery'] || '')
9004
+ : (typeof (req.headers || {})['x-gitlab-event-uuid'] === 'string' ? String(req.headers['x-gitlab-event-uuid'] || '') : '');
9005
+ const remember = rememberWebhookDeliveryId(deliveryId);
9006
+ if (remember.seen) {
9007
+ writeJsonResponse(res, 200, { ok: true, deduped: true });
9008
+ return;
9009
+ }
9010
+ const parsedBody = await readJsonRequestBody(req, res);
9011
+ if (!parsedBody.ok) {
9012
+ if (parsedBody.error !== 'payload-too-large') {
9013
+ writeJsonResponse(res, 400, { error: parsedBody.error || 'invalid request body' });
9014
+ }
9015
+ return;
9016
+ }
9017
+ const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
9018
+ const isLoopback = !remoteAddr || isLoopbackRemoteAddress(remoteAddr);
9019
+ const normalizedSource = typeof source === 'string' ? source.trim().toLowerCase() : '';
9020
+ if (normalizedSource === 'github') {
9021
+ const secret = typeof process.env.CODEXMATE_GITHUB_WEBHOOK_SECRET === 'string'
9022
+ ? process.env.CODEXMATE_GITHUB_WEBHOOK_SECRET
9023
+ : '';
9024
+ if (!secret && !isLoopback) {
9025
+ writeJsonResponse(res, 403, { error: 'Remote GitHub webhook is disabled (set CODEXMATE_GITHUB_WEBHOOK_SECRET)' });
9026
+ return;
9027
+ }
9028
+ if (secret) {
9029
+ const signature = (req.headers || {})['x-hub-signature-256'];
9030
+ if (!verifyGithubWebhookSignature(secret, signature, parsedBody.rawBuffer)) {
9031
+ writeJsonResponse(res, 401, { error: 'Invalid webhook signature' });
9032
+ return;
9033
+ }
9034
+ }
9035
+ } else if (normalizedSource === 'gitlab') {
9036
+ const secret = typeof process.env.CODEXMATE_GITLAB_WEBHOOK_SECRET === 'string'
9037
+ ? process.env.CODEXMATE_GITLAB_WEBHOOK_SECRET.trim()
9038
+ : '';
9039
+ if (!secret && !isLoopback) {
9040
+ writeJsonResponse(res, 403, { error: 'Remote GitLab webhook is disabled (set CODEXMATE_GITLAB_WEBHOOK_SECRET)' });
9041
+ return;
9042
+ }
9043
+ if (secret) {
9044
+ const tokenHeader = typeof (req.headers || {})['x-gitlab-token'] === 'string'
9045
+ ? String(req.headers['x-gitlab-token']).trim()
9046
+ : '';
9047
+ if (!tokenHeader || tokenHeader !== secret) {
9048
+ writeJsonResponse(res, 401, { error: 'Invalid webhook token' });
9049
+ return;
9050
+ }
9051
+ }
9052
+ }
9053
+ const payload = parsedBody.body && typeof parsedBody.body === 'object' ? parsedBody.body : {};
9054
+ const eventKey = buildAutomationEventKey(source, req.headers || {}, payload);
9055
+ if (!eventKey) {
9056
+ writeJsonResponse(res, 400, { error: 'unknown event' });
9057
+ return;
9058
+ }
9059
+ const cfg = readAutomationConfig(AUTOMATION_CONFIG_FILE, { env: process.env });
9060
+ if (!cfg.ok) {
9061
+ writeJsonResponse(res, 500, { error: cfg.error || 'failed to load automation config' });
9062
+ return;
9063
+ }
9064
+ const rule = matchAutomationRule(cfg.config, { source, event: eventKey });
9065
+ if (!rule) {
9066
+ writeJsonResponse(res, 404, { error: 'no matching rule', source, event: eventKey });
9067
+ return;
9068
+ }
9069
+ const action = rule.action && typeof rule.action === 'object' ? rule.action : {};
9070
+ const actionType = typeof action.type === 'string' ? action.type.trim().toLowerCase() : '';
9071
+ if (actionType !== 'task.queue.add') {
9072
+ writeJsonResponse(res, 400, { error: 'unsupported rule action', action: actionType || '' });
9073
+ return;
9074
+ }
9075
+ const taskPayload = action.task && typeof action.task === 'object' ? action.task : {};
9076
+ const enqueue = addTaskToQueue(taskPayload);
9077
+ if (enqueue.error) {
9078
+ writeJsonResponse(res, 400, { error: enqueue.error, issues: enqueue.issues || [], warnings: enqueue.warnings || [] });
9079
+ return;
9080
+ }
9081
+ const taskId = enqueue.task && enqueue.task.taskId ? enqueue.task.taskId : '';
9082
+ const shouldStart = action.startQueue === true;
9083
+ const queueResult = shouldStart
9084
+ ? await startTaskQueueProcessing({ taskId: '', detach: true })
9085
+ : { ok: true, started: false };
9086
+ writeJsonResponse(res, 200, {
9087
+ ok: true,
9088
+ ruleId: rule.id,
9089
+ source,
9090
+ event: eventKey,
9091
+ taskId,
9092
+ queue: {
9093
+ started: !!queueResult.started,
9094
+ alreadyRunning: !!queueResult.alreadyRunning,
9095
+ detached: !!queueResult.detached
9096
+ }
9097
+ });
9098
+ }
9099
+
7905
9100
  function streamZipDownloadResponse(res, filePath, options = {}) {
7906
9101
  if (!filePath || !fs.existsSync(filePath)) {
7907
9102
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
@@ -8204,9 +9399,50 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8204
9399
 
8205
9400
  const server = http.createServer((req, res) => {
8206
9401
  const requestPath = (req.url || '/').split('?')[0];
9402
+ const sendJson = (statusCode, payload) => {
9403
+ const body = JSON.stringify(payload || {}, null, 2);
9404
+ res.writeHead(statusCode, {
9405
+ 'Content-Type': 'application/json; charset=utf-8',
9406
+ 'Content-Length': Buffer.byteLength(body, 'utf-8')
9407
+ });
9408
+ res.end(body, 'utf-8');
9409
+ };
8207
9410
  if (typeof openaiBridgeHandler === 'function' && openaiBridgeHandler(req, res)) {
8208
9411
  return;
8209
9412
  }
9413
+ if (
9414
+ requestPath === '/api'
9415
+ || requestPath.startsWith('/api/import-')
9416
+ || requestPath.startsWith('/hooks/')
9417
+ || requestPath.startsWith('/download/')
9418
+ ) {
9419
+ const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
9420
+ const isLoopback = !remoteAddr
9421
+ || remoteAddr === '127.0.0.1'
9422
+ || remoteAddr === '::1'
9423
+ || remoteAddr === '::ffff:127.0.0.1';
9424
+ if (!isLoopback) {
9425
+ const expected = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string'
9426
+ ? process.env.CODEXMATE_HTTP_TOKEN.trim()
9427
+ : '';
9428
+ if (!expected) {
9429
+ sendJson(403, {
9430
+ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN or use --host 127.0.0.1)'
9431
+ });
9432
+ return;
9433
+ }
9434
+ const headers = req && req.headers && typeof req.headers === 'object' ? req.headers : {};
9435
+ const rawAuth = typeof headers.authorization === 'string' ? headers.authorization.trim() : '';
9436
+ const match = rawAuth ? rawAuth.match(/^bearer\s+(.+)$/i) : null;
9437
+ const actual = match && match[1]
9438
+ ? match[1].trim()
9439
+ : (rawAuth ? rawAuth : (typeof headers['x-codexmate-token'] === 'string' ? String(headers['x-codexmate-token']).trim() : ''));
9440
+ if (!actual || actual !== expected) {
9441
+ sendJson(401, { error: 'Unauthorized' });
9442
+ return;
9443
+ }
9444
+ }
9445
+ }
8210
9446
  if (requestPath === '/api/import-skills-zip') {
8211
9447
  void handleImportSkillsZipUpload(req, res);
8212
9448
  return;
@@ -8215,7 +9451,18 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8215
9451
  void handleImportSkillsZipUpload(req, res, { targetApp: 'codex' });
8216
9452
  return;
8217
9453
  }
9454
+ if (requestPath.startsWith('/hooks/')) {
9455
+ const segments = requestPath.split('/').filter(Boolean);
9456
+ const source = segments[1] ? String(segments[1]) : '';
9457
+ void handleAutomationHook(req, res, source);
9458
+ return;
9459
+ }
8218
9460
  if (requestPath === '/api') {
9461
+ const method = (req.method ? String(req.method) : 'POST').toUpperCase();
9462
+ if (method !== 'POST') {
9463
+ sendJson(405, { error: 'Method Not Allowed' });
9464
+ return;
9465
+ }
8219
9466
  let body = '';
8220
9467
  let bodySize = 0;
8221
9468
  let bodyTooLarge = false;
@@ -8224,7 +9471,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8224
9471
  bodySize += chunk.length;
8225
9472
  if (bodySize > MAX_API_BODY_SIZE) {
8226
9473
  bodyTooLarge = true;
8227
- writeJsonResponse(res, 413, {
9474
+ sendJson(413, {
8228
9475
  error: `请求体过大(>${Math.floor(MAX_API_BODY_SIZE / 1024 / 1024)}MB)`
8229
9476
  });
8230
9477
  req.destroy();
@@ -8235,7 +9482,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8235
9482
  req.on('end', async () => {
8236
9483
  if (bodyTooLarge) return;
8237
9484
  try {
8238
- const { action, params } = JSON.parse(body);
9485
+ const { action, params } = JSON.parse(body || '{}');
8239
9486
  let result;
8240
9487
 
8241
9488
  switch (action) {
@@ -8304,6 +9551,20 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8304
9551
  if (!baseUrl) {
8305
9552
  result = { error: 'Base URL is required' };
8306
9553
  } else {
9554
+ const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
9555
+ const requesterIsLoopback = !remoteAddr
9556
+ || remoteAddr === '127.0.0.1'
9557
+ || remoteAddr === '::1'
9558
+ || remoteAddr === '::ffff:127.0.0.1';
9559
+ if (!requesterIsLoopback) {
9560
+ try {
9561
+ const parsedUrl = new URL(baseUrl);
9562
+ if (isPrivateNetworkHost(parsedUrl.hostname || '')) {
9563
+ result = { error: 'Refusing to access private network baseUrl from non-loopback request' };
9564
+ break;
9565
+ }
9566
+ } catch (_) {}
9567
+ }
8307
9568
  const res = await fetchModelsFromBaseUrl(baseUrl, apiKey);
8308
9569
  if (res.error) {
8309
9570
  result = { error: res.error, models: [], source: 'remote' };
@@ -8339,6 +9600,21 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8339
9600
  case 'config-health-check':
8340
9601
  result = await buildConfigHealthReport(params || {});
8341
9602
  break;
9603
+ case 'doctor':
9604
+ {
9605
+ const doctorParams = isPlainObject(params) ? params : {};
9606
+ const report = await buildDoctorReport(doctorParams, {
9607
+ getStatusPayload: buildMcpStatusPayload,
9608
+ buildInstallStatusReport,
9609
+ buildConfigHealthReport,
9610
+ listSessionUsage,
9611
+ buildTaskOverviewPayload,
9612
+ listSkills
9613
+ });
9614
+ result = buildDoctorLegacyPayload(report);
9615
+ result.markdown = renderDoctorMarkdown(report);
9616
+ }
9617
+ break;
8342
9618
  case 'get-agents-file':
8343
9619
  result = readAgentsFile(params || {});
8344
9620
  break;
@@ -8446,11 +9722,41 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8446
9722
  result = { error: target.error };
8447
9723
  break;
8448
9724
  }
8449
- result = await runSpeedTest(target.url, target.apiKey, target);
8450
- break;
8451
- }
8452
- case 'provider-chat-check': {
8453
- result = await runProviderChatCheck(params || {});
9725
+ const timeoutMs = Number.isFinite(params && params.timeoutMs)
9726
+ ? Math.max(1000, Number(params.timeoutMs))
9727
+ : 0;
9728
+ if (Array.isArray(target.candidates) && target.candidates.length > 0) {
9729
+ let finalCandidate = target.candidates[0];
9730
+ let finalResult = null;
9731
+ for (let index = 0; index < target.candidates.length; index += 1) {
9732
+ const candidate = target.candidates[index];
9733
+ const probeResult = await runSpeedTest(candidate.url, target.apiKey, {
9734
+ ...candidate,
9735
+ apiKeyHeader: target.apiKeyHeader,
9736
+ headers: target.headers,
9737
+ timeoutMs: timeoutMs || undefined
9738
+ });
9739
+ finalCandidate = candidate;
9740
+ finalResult = probeResult;
9741
+ const status = Number.isFinite(probeResult && probeResult.status) ? probeResult.status : 0;
9742
+ const shouldTryNext = index < target.candidates.length - 1 && status === 404;
9743
+ if (!shouldTryNext) {
9744
+ break;
9745
+ }
9746
+ }
9747
+ result = {
9748
+ ok: !!(finalResult && finalResult.ok),
9749
+ status: Number.isFinite(finalResult && finalResult.status) ? finalResult.status : 0,
9750
+ durationMs: Number.isFinite(finalResult && finalResult.durationMs) ? finalResult.durationMs : 0,
9751
+ error: finalResult && finalResult.ok ? '' : (finalResult && finalResult.error ? finalResult.error : ''),
9752
+ url: finalCandidate && finalCandidate.url ? finalCandidate.url : ''
9753
+ };
9754
+ break;
9755
+ }
9756
+ result = await runSpeedTest(target.url, target.apiKey, {
9757
+ ...target,
9758
+ timeoutMs: timeoutMs || undefined
9759
+ });
8454
9760
  break;
8455
9761
  }
8456
9762
  case 'openai-bridge-get-provider': {
@@ -8471,8 +9777,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8471
9777
  case 'list-sessions':
8472
9778
  {
8473
9779
  const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
8474
- if (source && source !== 'codex' && source !== 'claude' && source !== 'all') {
8475
- result = { error: 'Invalid source. Must be codex, claude, or all' };
9780
+ if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') {
9781
+ result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' };
8476
9782
  } else {
8477
9783
  result = {
8478
9784
  sessions: await listSessionBrowse(params),
@@ -8485,8 +9791,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8485
9791
  {
8486
9792
  const usageParams = isPlainObject(params) ? params : {};
8487
9793
  const source = typeof usageParams.source === 'string' ? usageParams.source.trim().toLowerCase() : '';
8488
- if (source && source !== 'codex' && source !== 'claude' && source !== 'all') {
8489
- result = { error: 'Invalid source. Must be codex, claude, or all' };
9794
+ if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') {
9795
+ result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' };
8490
9796
  } else {
8491
9797
  result = {
8492
9798
  sessions: await listSessionUsage({
@@ -8501,8 +9807,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8501
9807
  case 'list-session-paths':
8502
9808
  {
8503
9809
  const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
8504
- if (source && source !== 'codex' && source !== 'claude' && source !== 'all') {
8505
- result = { error: 'Invalid source. Must be codex, claude, or all' };
9810
+ if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') {
9811
+ result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' };
8506
9812
  } else {
8507
9813
  result = {
8508
9814
  paths: listSessionPaths(params)
@@ -8846,7 +10152,14 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8846
10152
  });
8847
10153
  return;
8848
10154
  }
8849
-
10155
+ const allowLegacy = process.env.CODEXMATE_ALLOW_LEGACY_DOWNLOAD === '1';
10156
+ const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
10157
+ const isLoopback = !remoteAddr || remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
10158
+ if (!allowLegacy || !isLoopback) {
10159
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
10160
+ res.end('Not Found');
10161
+ return;
10162
+ }
8850
10163
  const tempDir = os.tmpdir();
8851
10164
  const legacyFilePath = path.join(tempDir, decodedFileName);
8852
10165
  if (!isPathInside(legacyFilePath, tempDir)) {
@@ -8859,8 +10172,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8859
10172
  deleteAfterDownload: false
8860
10173
  });
8861
10174
  } else if (requestPath.startsWith('/res/')) {
8862
- const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, '');
8863
- const filePath = path.join(__dirname, normalized);
10175
+ const normalized = path.normalize(requestPath.slice('/res/'.length)).replace(/^([\\.\\/])+/, '');
10176
+ const filePath = path.join(assetsDir, normalized);
8864
10177
  if (!isPathInside(filePath, assetsDir)) {
8865
10178
  res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
8866
10179
  res.end('Forbidden');
@@ -8913,19 +10226,23 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
8913
10226
  const openUrl = `http://${formatHostForUrl(openHost)}:${port}`;
8914
10227
  server.listen(port, host, () => {
8915
10228
  console.log('\n✓ Web UI 已启动');
8916
- console.log(` 待访问: ${openUrl}`);
10229
+ const willOpenBrowser = !!openBrowser && !process.env.CODEXMATE_NO_BROWSER;
10230
+ console.log(` ${willOpenBrowser ? '已打开' : '待访问'}: ${openUrl}`);
8917
10231
  if (host && host !== openHost) {
8918
10232
  console.log(' 监听地址:', host);
8919
10233
  }
8920
10234
  console.log(' 退出: Ctrl+C\n');
8921
10235
  if (isAnyAddressHost(host)) {
8922
- console.warn('! 安全提示: 当前监听所有网卡(无鉴权)。');
8923
- console.warn(' 建议仅在可信网络使用,或改用 --host 127.0.0.1。');
10236
+ const tokenEnabled = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string' && process.env.CODEXMATE_HTTP_TOKEN.trim().length > 0;
10237
+ console.warn(`! 安全提示: 当前监听所有网卡(${tokenEnabled ? '已启用鉴权' : '无鉴权'})。`);
10238
+ if (!tokenEnabled) {
10239
+ console.warn(' 建议仅在可信网络使用,或改用 --host 127.0.0.1。');
10240
+ console.warn(' 如需远程访问,请设置 CODEXMATE_HTTP_TOKEN。');
10241
+ }
8924
10242
  }
8925
10243
 
8926
- if (!process.env.CODEXMATE_NO_BROWSER && openBrowser) {
8927
- const url = openUrl;
8928
- openBrowserAfterReady(url);
10244
+ if (willOpenBrowser) {
10245
+ openBrowserAfterReady(openUrl);
8929
10246
  }
8930
10247
  });
8931
10248
 
@@ -9022,7 +10339,7 @@ function cmdStart(options = {}) {
9022
10339
  const newHtmlPath = path.join(webDir, 'index.html');
9023
10340
  const legacyHtmlPath = path.join(__dirname, 'web-ui.html');
9024
10341
  const htmlPath = fs.existsSync(newHtmlPath) ? newHtmlPath : legacyHtmlPath;
9025
- const assetsDir = path.join(__dirname, 'res');
10342
+ const assetsDir = path.join(webDir, 'res');
9026
10343
  if (!fs.existsSync(htmlPath)) {
9027
10344
  console.error('错误: Web UI 页面不存在(尝试路径: web-ui/index.html, web-ui.html)');
9028
10345
  process.exit(1);
@@ -9036,22 +10353,26 @@ function cmdStart(options = {}) {
9036
10353
  || process.env.CODEXMATE_DEV === '1'
9037
10354
  || process.env.CODEXMATE_DEV === 'true';
9038
10355
 
9039
- // 禁止自动打开浏览器:仅输出 URL,交由用户自行点击/打开。
10356
+ const shouldOpenBrowser = !options.noBrowser && !process.env.CODEXMATE_NO_BROWSER;
10357
+
9040
10358
  let serverHandle = createWebServer({
9041
10359
  htmlPath,
9042
10360
  assetsDir,
9043
10361
  webDir,
9044
10362
  host,
9045
10363
  port,
9046
- openBrowser: false
10364
+ openBrowser: shouldOpenBrowser
9047
10365
  });
9048
10366
 
10367
+ const stopAutomationScheduler = startAutomationScheduler();
10368
+
9049
10369
  // 禁止前端变更侦测与自动重启:避免终端输出噪音与访问时短暂 Connection Refused。
9050
10370
  // 如需热重启,请由开发者自行使用外部 watcher / nodemon 等工具。
9051
10371
  const stopWatch = () => {};
9052
10372
 
9053
10373
  const handleExit = () => {
9054
10374
  stopWatch();
10375
+ stopAutomationScheduler();
9055
10376
  Promise.allSettled([
9056
10377
  serverHandle.stop(),
9057
10378
  stopBuiltinProxyRuntime(),
@@ -10591,7 +11912,7 @@ function buildMcpClaudeSettingsPayload() {
10591
11912
  function normalizeMcpSource(value) {
10592
11913
  const source = typeof value === 'string' ? value.trim().toLowerCase() : '';
10593
11914
  if (!source) return '';
10594
- if (source === 'codex' || source === 'claude' || source === 'all') {
11915
+ if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'all') {
10595
11916
  return source;
10596
11917
  }
10597
11918
  return null;
@@ -10904,7 +12225,7 @@ function createWorkflowToolCatalog() {
10904
12225
  handler: async (args = {}) => {
10905
12226
  const source = normalizeMcpSource(args.source);
10906
12227
  if (source === null) {
10907
- return { error: 'Invalid source. Must be codex, claude, or all' };
12228
+ return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' };
10908
12229
  }
10909
12230
  return {
10910
12231
  source: source || 'all',
@@ -11276,14 +12597,21 @@ function normalizeTaskQueueItem(raw = {}) {
11276
12597
 
11277
12598
  function readTaskQueueState() {
11278
12599
  const parsed = readJsonObjectFromFile(TASK_QUEUE_FILE, {});
12600
+ if (!parsed.ok && parsed.exists) {
12601
+ return {
12602
+ tasks: [],
12603
+ error: parsed.error || 'failed to read task queue'
12604
+ };
12605
+ }
11279
12606
  if (!parsed.ok || !parsed.exists) {
11280
12607
  return {
11281
- tasks: []
12608
+ tasks: [],
12609
+ error: ''
11282
12610
  };
11283
12611
  }
11284
12612
  const source = parsed.data && typeof parsed.data === 'object' ? parsed.data : {};
11285
12613
  const tasks = Array.isArray(source.tasks) ? source.tasks.map((item) => normalizeTaskQueueItem(item)) : [];
11286
- return { tasks };
12614
+ return { tasks, error: '' };
11287
12615
  }
11288
12616
 
11289
12617
  function writeTaskQueueState(state = {}) {
@@ -11293,17 +12621,58 @@ function writeTaskQueueState(state = {}) {
11293
12621
  });
11294
12622
  }
11295
12623
 
11296
- function upsertTaskQueueItem(item) {
11297
- const state = readTaskQueueState();
11298
- const next = normalizeTaskQueueItem(item || {});
11299
- const index = state.tasks.findIndex((entry) => entry.taskId === next.taskId);
11300
- if (index >= 0) {
11301
- state.tasks[index] = next;
11302
- } else {
11303
- state.tasks.push(next);
12624
+ function withTaskQueueLock(fn) {
12625
+ const lockPath = `${TASK_QUEUE_FILE}.lock`;
12626
+ ensureDir(path.dirname(lockPath));
12627
+ let lockFd = null;
12628
+ try {
12629
+ lockFd = fs.openSync(lockPath, 'wx', 0o600);
12630
+ } catch (error) {
12631
+ const code = error && error.code ? error.code : '';
12632
+ if (code === 'EEXIST') {
12633
+ try {
12634
+ const stat = fs.statSync(lockPath);
12635
+ const ageMs = Date.now() - stat.mtimeMs;
12636
+ if (ageMs > 5000) {
12637
+ try {
12638
+ fs.unlinkSync(lockPath);
12639
+ } catch (_) {}
12640
+ lockFd = fs.openSync(lockPath, 'wx', 0o600);
12641
+ }
12642
+ } catch (_) {}
12643
+ }
12644
+ }
12645
+ if (!lockFd) {
12646
+ return { error: 'task queue is busy' };
12647
+ }
12648
+ try {
12649
+ return fn();
12650
+ } finally {
12651
+ try {
12652
+ fs.closeSync(lockFd);
12653
+ } catch (_) {}
12654
+ try {
12655
+ fs.unlinkSync(lockPath);
12656
+ } catch (_) {}
11304
12657
  }
11305
- writeTaskQueueState(state);
11306
- return next;
12658
+ }
12659
+
12660
+ function upsertTaskQueueItem(item) {
12661
+ return withTaskQueueLock(() => {
12662
+ const state = readTaskQueueState();
12663
+ if (state.error) {
12664
+ return { error: state.error };
12665
+ }
12666
+ const next = normalizeTaskQueueItem(item || {});
12667
+ const index = state.tasks.findIndex((entry) => entry.taskId === next.taskId);
12668
+ if (index >= 0) {
12669
+ state.tasks[index] = next;
12670
+ } else {
12671
+ state.tasks.push(next);
12672
+ }
12673
+ writeTaskQueueState(state);
12674
+ return next;
12675
+ });
11307
12676
  }
11308
12677
 
11309
12678
  function getTaskQueueItem(taskId) {
@@ -11338,15 +12707,19 @@ function appendTaskRunRecord(record) {
11338
12707
  fs.appendFileSync(TASK_RUNS_FILE, `${JSON.stringify(record)}\n`, { encoding: 'utf-8', mode: 0o600 });
11339
12708
  }
11340
12709
 
12710
+ let g_taskRunRecordsLastParseErrors = 0;
12711
+
11341
12712
  function listTaskRunRecords(limit = 20) {
11342
12713
  const max = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 20;
11343
12714
  if (!fs.existsSync(TASK_RUNS_FILE)) {
12715
+ g_taskRunRecordsLastParseErrors = 0;
11344
12716
  return [];
11345
12717
  }
11346
12718
  let content = '';
11347
12719
  try {
11348
12720
  content = fs.readFileSync(TASK_RUNS_FILE, 'utf-8');
11349
12721
  } catch (_) {
12722
+ g_taskRunRecordsLastParseErrors = 0;
11350
12723
  return [];
11351
12724
  }
11352
12725
  const rows = content
@@ -11354,14 +12727,18 @@ function listTaskRunRecords(limit = 20) {
11354
12727
  .map((line) => line.trim())
11355
12728
  .filter(Boolean);
11356
12729
  const parsed = [];
12730
+ let parseErrors = 0;
11357
12731
  for (let i = rows.length - 1; i >= 0; i -= 1) {
11358
12732
  try {
11359
12733
  parsed.push(JSON.parse(rows[i]));
11360
12734
  if (parsed.length >= max) {
11361
12735
  break;
11362
12736
  }
11363
- } catch (_) {}
12737
+ } catch (_) {
12738
+ parseErrors += 1;
12739
+ }
11364
12740
  }
12741
+ g_taskRunRecordsLastParseErrors = parseErrors;
11365
12742
  return parsed;
11366
12743
  }
11367
12744
 
@@ -11423,18 +12800,117 @@ function collectTaskRunSummary(detail = {}) {
11423
12800
  };
11424
12801
  }
11425
12802
 
12803
+ function writeTaskRunArtifacts(detail = {}) {
12804
+ const validation = validateTaskRunId(detail && typeof detail.runId === 'string' ? detail.runId : '');
12805
+ if (!validation.ok) {
12806
+ return;
12807
+ }
12808
+ const run = detail.run && typeof detail.run === 'object' ? detail.run : {};
12809
+ const nodes = Array.isArray(run.nodes) ? run.nodes : [];
12810
+ const dir = path.join(TASK_ARTIFACTS_DIR, validation.runId);
12811
+ ensureDir(dir);
12812
+ writeJsonAtomic(path.join(dir, 'summary.json'), collectTaskRunSummary(detail));
12813
+ const runLogText = summarizeTaskLogs(run.logs || [], 200);
12814
+ const nodeLogText = nodes
12815
+ .map((node) => {
12816
+ const header = node && node.id ? `\n# ${node.id}\n` : '\n# node\n';
12817
+ const text = summarizeTaskLogs(node && node.logs ? node.logs : [], 200);
12818
+ return `${header}${text}`.trimEnd();
12819
+ })
12820
+ .filter(Boolean)
12821
+ .join('\n');
12822
+ const combined = `${runLogText}${nodeLogText ? `\n\n${nodeLogText}` : ''}`.trim();
12823
+ try {
12824
+ fs.writeFileSync(path.join(dir, 'logs.txt'), combined, { encoding: 'utf-8', mode: 0o600 });
12825
+ } catch (_) {}
12826
+ }
12827
+
12828
+ async function notifyAutomationOnTaskRun(detail = {}) {
12829
+ const cfg = readAutomationConfig(AUTOMATION_CONFIG_FILE, { env: process.env });
12830
+ if (!cfg.ok || !cfg.config) {
12831
+ return [];
12832
+ }
12833
+ const payload = formatTaskRunNotificationPayload(detail);
12834
+ const status = String(payload.status || '').toLowerCase();
12835
+ const eventType = status === 'success'
12836
+ ? 'task.completed'
12837
+ : (status === 'failed' ? 'task.failed' : 'task.finished');
12838
+ return await dispatchAutomationNotifiers(cfg.config, eventType, payload);
12839
+ }
12840
+
12841
+ function startAutomationScheduler() {
12842
+ const lastTicks = new Map();
12843
+ let tickInFlight = false;
12844
+ let timer = setInterval(async () => {
12845
+ if (tickInFlight) {
12846
+ return;
12847
+ }
12848
+ tickInFlight = true;
12849
+ try {
12850
+ const cfg = readAutomationConfig(AUTOMATION_CONFIG_FILE, { env: process.env });
12851
+ if (!cfg.ok || !cfg.config) {
12852
+ return;
12853
+ }
12854
+ const schedules = Array.isArray(cfg.config.schedules) ? cfg.config.schedules : [];
12855
+ if (schedules.length === 0) {
12856
+ return;
12857
+ }
12858
+ const now = new Date();
12859
+ const tickKey = now.toISOString().slice(0, 16);
12860
+ for (const schedule of schedules) {
12861
+ if (!schedule || schedule.enabled === false) continue;
12862
+ if (!schedule.id || !schedule.cron) continue;
12863
+ if (!isCronMatch(schedule.cron, now)) continue;
12864
+ if (lastTicks.get(schedule.id) === tickKey) continue;
12865
+ lastTicks.set(schedule.id, tickKey);
12866
+ const action = schedule.action && typeof schedule.action === 'object' ? schedule.action : {};
12867
+ const actionType = typeof action.type === 'string' ? action.type.trim().toLowerCase() : '';
12868
+ if (actionType !== 'task.queue.add') continue;
12869
+ const taskPayload = action.task && typeof action.task === 'object' ? action.task : {};
12870
+ try {
12871
+ const enqueue = addTaskToQueue(taskPayload);
12872
+ if (enqueue && enqueue.error) continue;
12873
+ if (action.startQueue === true) {
12874
+ await startTaskQueueProcessing({ taskId: '', detach: true });
12875
+ }
12876
+ } catch (_) {}
12877
+ }
12878
+ } finally {
12879
+ tickInFlight = false;
12880
+ }
12881
+ }, 30000);
12882
+ if (timer && typeof timer.unref === 'function') {
12883
+ timer.unref();
12884
+ }
12885
+ return () => {
12886
+ if (!timer) return;
12887
+ clearInterval(timer);
12888
+ timer = null;
12889
+ };
12890
+ }
12891
+
11426
12892
  function buildTaskOverviewPayload(options = {}) {
11427
12893
  const queueLimit = Number.isFinite(options.queueLimit) ? Math.max(1, Math.floor(options.queueLimit)) : 20;
11428
12894
  const runLimit = Number.isFinite(options.runLimit) ? Math.max(1, Math.floor(options.runLimit)) : 20;
11429
12895
  const workflowCatalog = buildTaskWorkflowCatalog();
11430
- const queue = listTaskQueueItems({ limit: queueLimit });
12896
+ const queueState = readTaskQueueState();
12897
+ const queue = queueState.error ? [] : listTaskQueueItems({ limit: queueLimit });
11431
12898
  const runs = listTaskRunRecords(runLimit);
12899
+ const warnings = Array.isArray(workflowCatalog.warnings) ? [...workflowCatalog.warnings] : [];
12900
+ if (queueState.error) {
12901
+ warnings.push(`task queue read error: ${queueState.error}`);
12902
+ }
12903
+ if (g_taskRunRecordsLastParseErrors > 0) {
12904
+ warnings.push(`task run history parse errors: ${g_taskRunRecordsLastParseErrors}`);
12905
+ }
11432
12906
  return {
11433
12907
  workflows: workflowCatalog.workflows,
11434
- warnings: workflowCatalog.warnings,
12908
+ warnings,
11435
12909
  queue,
11436
12910
  runs,
11437
- activeRunIds: Array.from(g_taskRunControllers.keys())
12911
+ activeRunIds: Array.from(g_taskRunControllers.keys()),
12912
+ queueError: queueState.error || '',
12913
+ runParseErrors: g_taskRunRecordsLastParseErrors
11438
12914
  };
11439
12915
  }
11440
12916
 
@@ -11732,7 +13208,7 @@ async function runTaskPlanInternal(plan, options = {}) {
11732
13208
  }
11733
13209
  });
11734
13210
  if (options.queueItem) {
11735
- upsertTaskQueueItem({
13211
+ const queued = upsertTaskQueueItem({
11736
13212
  ...options.queueItem,
11737
13213
  taskId,
11738
13214
  status: 'running',
@@ -11742,6 +13218,7 @@ async function runTaskPlanInternal(plan, options = {}) {
11742
13218
  updatedAt: toIsoTime(Date.now()),
11743
13219
  plan
11744
13220
  });
13221
+ if (queued && queued.error) {}
11745
13222
  }
11746
13223
  try {
11747
13224
  const run = await executeTaskPlan(plan, {
@@ -11765,7 +13242,7 @@ async function runTaskPlanInternal(plan, options = {}) {
11765
13242
  };
11766
13243
  writeTaskRunDetail(nextDetail);
11767
13244
  if (options.queueItem) {
11768
- upsertTaskQueueItem({
13245
+ const queued = upsertTaskQueueItem({
11769
13246
  ...options.queueItem,
11770
13247
  taskId,
11771
13248
  status: snapshot.status === 'success'
@@ -11777,6 +13254,7 @@ async function runTaskPlanInternal(plan, options = {}) {
11777
13254
  updatedAt: toIsoTime(Date.now()),
11778
13255
  plan
11779
13256
  });
13257
+ if (queued && queued.error) {}
11780
13258
  }
11781
13259
  }
11782
13260
  });
@@ -11788,8 +13266,12 @@ async function runTaskPlanInternal(plan, options = {}) {
11788
13266
  };
11789
13267
  writeTaskRunDetail(detail);
11790
13268
  appendTaskRunRecord(collectTaskRunSummary(detail));
13269
+ writeTaskRunArtifacts(detail);
13270
+ try {
13271
+ await notifyAutomationOnTaskRun(detail);
13272
+ } catch (_) {}
11791
13273
  if (options.queueItem) {
11792
- upsertTaskQueueItem({
13274
+ const queued = upsertTaskQueueItem({
11793
13275
  ...options.queueItem,
11794
13276
  taskId,
11795
13277
  status: run.status === 'success'
@@ -11801,6 +13283,7 @@ async function runTaskPlanInternal(plan, options = {}) {
11801
13283
  updatedAt: toIsoTime(Date.now()),
11802
13284
  plan
11803
13285
  });
13286
+ if (queued && queued.error) {}
11804
13287
  }
11805
13288
  return detail;
11806
13289
  } finally {
@@ -11836,6 +13319,9 @@ function addTaskToQueue(params = {}) {
11836
13319
  runStatus: '',
11837
13320
  plan
11838
13321
  });
13322
+ if (item && item.error) {
13323
+ return { error: item.error };
13324
+ }
11839
13325
  return {
11840
13326
  ok: true,
11841
13327
  task: item,
@@ -12006,6 +13492,9 @@ function cancelTaskRunOrQueue(params = {}) {
12006
13492
  updatedAt: toIsoTime(Date.now()),
12007
13493
  lastSummary: queueItem.lastSummary || '已取消'
12008
13494
  });
13495
+ if (next && next.error) {
13496
+ return { error: next.error };
13497
+ }
12009
13498
  return {
12010
13499
  ok: true,
12011
13500
  cancelled: true,
@@ -12144,13 +13633,37 @@ function isLiveProcessId(value) {
12144
13633
  }
12145
13634
  }
12146
13635
 
13636
+ function isTaskWorkerProcessId(value) {
13637
+ const pid = Number.isFinite(Number(value)) ? Math.floor(Number(value)) : 0;
13638
+ if (!isLiveProcessId(pid)) return false;
13639
+ if (process.platform === 'linux') {
13640
+ try {
13641
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
13642
+ return raw.includes('__task-worker');
13643
+ } catch (_) {
13644
+ return true;
13645
+ }
13646
+ }
13647
+ if (process.platform === 'darwin') {
13648
+ try {
13649
+ const probe = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8', timeout: 1500 });
13650
+ if (probe.error || probe.status !== 0) return true;
13651
+ const cmd = String(probe.stdout || '');
13652
+ return cmd.includes('__task-worker');
13653
+ } catch (_) {
13654
+ return true;
13655
+ }
13656
+ }
13657
+ return true;
13658
+ }
13659
+
12147
13660
  function readTaskQueueWorkerState() {
12148
13661
  const parsed = readJsonObjectFromFile(TASK_QUEUE_WORKER_FILE, {});
12149
13662
  if (!parsed.ok || !parsed.exists || !parsed.data || typeof parsed.data !== 'object') {
12150
13663
  return null;
12151
13664
  }
12152
13665
  const state = parsed.data;
12153
- if (!isLiveProcessId(state.pid)) {
13666
+ if (!isTaskWorkerProcessId(state.pid)) {
12154
13667
  try {
12155
13668
  fs.unlinkSync(TASK_QUEUE_WORKER_FILE);
12156
13669
  } catch (_) {}
@@ -12405,7 +13918,7 @@ function createMcpTools(options = {}) {
12405
13918
  const input = args && typeof args === 'object' ? args : {};
12406
13919
  const source = normalizeMcpSource(input.source);
12407
13920
  if (source === null) {
12408
- return { error: 'Invalid source. Must be codex, claude, or all' };
13921
+ return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' };
12409
13922
  }
12410
13923
  const normalizedInput = {
12411
13924
  ...input,
@@ -12854,7 +14367,7 @@ function createMcpResources() {
12854
14367
  contents: [{
12855
14368
  uri,
12856
14369
  mimeType: 'application/json',
12857
- text: JSON.stringify({ error: 'Invalid source. Must be codex, claude, or all' }, null, 2)
14370
+ text: JSON.stringify({ error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }, null, 2)
12858
14371
  }]
12859
14372
  };
12860
14373
  }
@@ -13076,6 +14589,37 @@ async function cmdMcp(args = []) {
13076
14589
  });
13077
14590
  }
13078
14591
 
14592
+ function printMainHelp() {
14593
+ console.log('\nCodex Mate - Codex 提供商管理工具');
14594
+ console.log('\n用法:');
14595
+ console.log(' codexmate status 显示当前状态');
14596
+ console.log(' codexmate doctor [--format json|md] [--lang zh|en] [--output <PATH>] 输出诊断报告');
14597
+ console.log(' codexmate import-skills <URL> [--target-app codex|claude] [--name <NAME>] [--timeout-ms <MS>] 从 URL 导入 skills');
14598
+ console.log(' codexmate setup 交互式配置向导');
14599
+ console.log(' codexmate list 列出所有提供商');
14600
+ console.log(' codexmate models 列出所有模型');
14601
+ console.log(' codexmate switch <名称> 切换提供商');
14602
+ console.log(' codexmate use <模型> 切换模型');
14603
+ console.log(' codexmate add <名称> <URL> [密钥] [--bridge <openai>]');
14604
+ console.log(' codexmate delete <名称> 删除提供商');
14605
+ console.log(' codexmate claude <BaseURL> <API密钥> [模型] 写入 Claude Code 配置');
14606
+ console.log(' codexmate auth <list|import|switch|delete|status> 认证管理');
14607
+ console.log(' codexmate add-model <模型> 添加模型');
14608
+ console.log(' codexmate delete-model <模型> 删除模型');
14609
+ console.log(' codexmate workflow <list|get|validate|run|runs> MCP 工作流中心');
14610
+ console.log(' codexmate task <plan|run|runs|queue|retry|cancel|logs> 本地任务编排');
14611
+ console.log(' codexmate run [--host <HOST>] [--no-browser] 启动 Web 界面');
14612
+ console.log(' codexmate codex [参数...] [--follow-up <文本>|--queued-follow-up <文本> 可重复] 等同于 codex --yolo');
14613
+ console.log(' 注: follow-up 自动排队仅支持 linux/android/netbsd/openbsd/darwin/freebsd 且 stdin 必须是 TTY,其他平台会报错');
14614
+ console.log(' codexmate qwen [参数...] 等同于 qwen --yolo');
14615
+ console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]');
14616
+ console.log(' codexmate export-session --source <codex|claude|gemini|codebuddy> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
14617
+ console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)');
14618
+ console.log(' codexmate unzip <zip文件> [输出目录] 解压(zip-lib)');
14619
+ console.log(' codexmate unzip-ext <zip目录> [输出目录] [--ext:后缀[,后缀...]] [--no-recursive] 批量提取 ZIP 指定后缀文件(默认递归)');
14620
+ console.log('');
14621
+ }
14622
+
13079
14623
  // ============================================================================
13080
14624
  // 主程序
13081
14625
  // ============================================================================
@@ -13091,32 +14635,8 @@ async function main() {
13091
14635
  }
13092
14636
  }
13093
14637
 
13094
- if (args.length === 0) {
13095
- console.log('\nCodex Mate - Codex 提供商管理工具');
13096
- console.log('\n用法:');
13097
- console.log(' codexmate status 显示当前状态');
13098
- console.log(' codexmate setup 交互式配置向导');
13099
- console.log(' codexmate list 列出所有提供商');
13100
- console.log(' codexmate models 列出所有模型');
13101
- console.log(' codexmate switch <名称> 切换提供商');
13102
- console.log(' codexmate use <模型> 切换模型');
13103
- console.log(' codexmate add <名称> <URL> [密钥] [--bridge <openai>]');
13104
- console.log(' codexmate delete <名称> 删除提供商');
13105
- console.log(' codexmate claude <BaseURL> <API密钥> [模型] 写入 Claude Code 配置');
13106
- console.log(' codexmate add-model <模型> 添加模型');
13107
- console.log(' codexmate delete-model <模型> 删除模型');
13108
- console.log(' codexmate workflow <list|get|validate|run|runs> MCP 工作流中心');
13109
- console.log(' codexmate task <plan|run|runs|queue|retry|cancel|logs> 本地任务编排');
13110
- console.log(' codexmate run [--host <HOST>] [--no-browser] 启动 Web 界面');
13111
- console.log(' codexmate codex [参数...] [--follow-up <文本>|--queued-follow-up <文本> 可重复] 等同于 codex --yolo');
13112
- console.log(' 注: follow-up 自动排队仅支持 linux/android/netbsd/openbsd/darwin/freebsd 且 stdin 必须是 TTY,其他平台会报错');
13113
- console.log(' codexmate qwen [参数...] 等同于 qwen --yolo');
13114
- console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]');
13115
- console.log(' codexmate export-session --source <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
13116
- console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)');
13117
- console.log(' codexmate unzip <zip文件> [输出目录] 解压(zip-lib)');
13118
- console.log(' codexmate unzip-ext <zip目录> [输出目录] [--ext:后缀[,后缀...]] [--no-recursive] 批量提取 ZIP 指定后缀文件(默认递归)');
13119
- console.log('');
14638
+ if (args.length === 0 || command === '--help' || command === '-h' || command === 'help') {
14639
+ printMainHelp();
13120
14640
  process.exit(0);
13121
14641
  }
13122
14642
 
@@ -13155,6 +14675,8 @@ async function main() {
13155
14675
  switch (command) {
13156
14676
  case '__task-worker': await cmdTaskWorker(args.slice(1)); break;
13157
14677
  case 'status': cmdStatus(); break;
14678
+ case 'doctor': await cmdDoctor(args.slice(1)); break;
14679
+ case 'import-skills': await cmdImportSkills(args.slice(1)); break;
13158
14680
  case 'setup': await cmdSetup(); break;
13159
14681
  case 'list': cmdList(); break;
13160
14682
  case 'models': await cmdModels(); break;