coding-tool-x 3.5.9 → 3.5.11

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 (92) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +4 -25
  3. package/bin/ctx.js +1 -6
  4. package/dist/web/assets/{Analytics-C5W3axXs.js → Analytics-Bw-0r4Ys.js} +2 -2
  5. package/dist/web/assets/Analytics-RNn1BUbG.css +1 -0
  6. package/dist/web/assets/{ConfigTemplates-DzyVFDx9.js → ConfigTemplates-C6QRIeDj.js} +1 -1
  7. package/dist/web/assets/Home-BQxQ1LhR.css +1 -0
  8. package/dist/web/assets/{Home-C9TQNB6f.js → Home-B_Z9ythw.js} +1 -1
  9. package/dist/web/assets/{PluginManager-9B_brLWT.js → PluginManager-Ce-mws6Q.js} +1 -1
  10. package/dist/web/assets/ProjectList-9Z7FFCIT.js +1 -0
  11. package/dist/web/assets/ProjectList-DL4JK6ci.css +1 -0
  12. package/dist/web/assets/SessionList-CyETQcs8.js +1 -0
  13. package/dist/web/assets/SessionList-D12muXvx.css +1 -0
  14. package/dist/web/assets/{SkillManager-vST8DRRg.js → SkillManager-DSUcP1se.js} +1 -1
  15. package/dist/web/assets/{WorkspaceManager-ov1KgRXR.js → WorkspaceManager-Cvbc11bJ.js} +1 -1
  16. package/dist/web/assets/icons-lKCTySl7.js +1 -0
  17. package/dist/web/assets/index-BS7aplYp.css +1 -0
  18. package/dist/web/assets/index-CTeg1xG-.js +2 -0
  19. package/dist/web/assets/{markdown-DyTJGI4N.js → markdown-BNJfEgCA.js} +1 -1
  20. package/dist/web/assets/{naive-ui-Cg4_ZeoT.js → naive-ui-DM897mIP.js} +1 -1
  21. package/dist/web/assets/vendors-DQA4Hw9v.js +7 -0
  22. package/dist/web/assets/vue-vendor-B5RqjEjQ.js +45 -0
  23. package/dist/web/index.html +8 -8
  24. package/package.json +1 -2
  25. package/src/commands/daemon.js +5 -33
  26. package/src/commands/export-config.js +6 -6
  27. package/src/commands/stats.js +4 -41
  28. package/src/commands/ui.js +3 -12
  29. package/src/config/channel-preset-websites.js +114 -0
  30. package/src/config/default.js +9 -5
  31. package/src/config/loader.js +2 -2
  32. package/src/config/model-metadata.json +16 -20
  33. package/src/config/paths.js +33 -166
  34. package/src/index.js +34 -125
  35. package/src/server/api/agents.js +2 -52
  36. package/src/server/api/codex-channels.js +4 -0
  37. package/src/server/api/codex-proxy.js +45 -2
  38. package/src/server/api/codex-sessions.js +89 -12
  39. package/src/server/api/commands.js +2 -38
  40. package/src/server/api/dashboard.js +1 -25
  41. package/src/server/api/gemini-proxy.js +4 -2
  42. package/src/server/api/gemini-sessions.js +86 -9
  43. package/src/server/api/hooks.js +1 -17
  44. package/src/server/api/opencode-proxy.js +7 -2
  45. package/src/server/api/opencode-sessions.js +83 -10
  46. package/src/server/api/plugins.js +34 -128
  47. package/src/server/api/proxy.js +5 -3
  48. package/src/server/api/sessions.js +181 -27
  49. package/src/server/api/workspaces.js +0 -14
  50. package/src/server/index.js +9 -31
  51. package/src/server/services/agents-service.js +62 -269
  52. package/src/server/services/base/base-channel-service.js +3 -1
  53. package/src/server/services/channels.js +5 -0
  54. package/src/server/services/codex-channels.js +2 -0
  55. package/src/server/services/codex-sessions.js +88 -108
  56. package/src/server/services/commands-service.js +81 -281
  57. package/src/server/services/config-export-service.js +7 -7
  58. package/src/server/services/config-registry-service.js +9 -8
  59. package/src/server/services/config-sync-manager.js +41 -61
  60. package/src/server/services/config-sync-service.js +3 -3
  61. package/src/server/services/gemini-channels.js +17 -6
  62. package/src/server/services/gemini-config.js +4 -3
  63. package/src/server/services/gemini-sessions.js +59 -26
  64. package/src/server/services/gemini-settings-manager.js +3 -2
  65. package/src/server/services/mcp-service.js +14 -9
  66. package/src/server/services/native-oauth-adapters.js +3 -3
  67. package/src/server/services/network-access.js +2 -61
  68. package/src/server/services/notification-hooks.js +19 -184
  69. package/src/server/services/opencode-channels.js +9 -0
  70. package/src/server/services/opencode-sessions.js +6 -16
  71. package/src/server/services/opencode-settings-manager.js +3 -3
  72. package/src/server/services/plugins-service.js +62 -996
  73. package/src/server/services/prompts-service.js +9 -5
  74. package/src/server/services/sessions.js +171 -183
  75. package/src/server/services/skill-service.js +18 -155
  76. package/src/server/websocket-server.js +4 -35
  77. package/dist/web/assets/Analytics-vQS5IWvs.css +0 -1
  78. package/dist/web/assets/Home-qzk118Of.css +0 -1
  79. package/dist/web/assets/ProjectList-Bjt6mrsV.js +0 -1
  80. package/dist/web/assets/ProjectList-GCC2QOmq.css +0 -1
  81. package/dist/web/assets/SessionList-BsHPgmUR.css +0 -1
  82. package/dist/web/assets/SessionList-DcBH13uA.js +0 -1
  83. package/dist/web/assets/icons-CEq2hYB-.js +0 -1
  84. package/dist/web/assets/index-Dih_bOsv.css +0 -1
  85. package/dist/web/assets/index-Duc7QP4e.js +0 -2
  86. package/dist/web/assets/vendors-Bsp-dq2d.js +0 -7
  87. package/dist/web/assets/vue-vendor-BxIT0uQq.js +0 -45
  88. package/docs/Caddyfile.example +0 -19
  89. package/docs/reverse-proxy-https.md +0 -57
  90. package/src/server/services/https-cert.js +0 -171
  91. package/src/server/services/session-launch-command.js +0 -58
  92. package/src/server/services/web-ui-runtime.js +0 -54
@@ -14,7 +14,6 @@ const {
14
14
  } = require('../services/opencode-sessions');
15
15
  const { loadAliases } = require('../services/alias');
16
16
  const { broadcastLog } = require('../websocket-server');
17
- const { buildLaunchCommand } = require('../services/session-launch-command');
18
17
  const { HOME_DIR } = require('../../config/paths');
19
18
 
20
19
  function isNotFoundError(error) {
@@ -152,6 +151,73 @@ module.exports = (config) => {
152
151
  }
153
152
  });
154
153
 
154
+ /**
155
+ * GET /api/opencode/sessions/:projectName/:sessionId/status
156
+ * 获取会话轻量状态(用于实时刷新)
157
+ */
158
+ router.get('/:projectName/:sessionId/status', (req, res) => {
159
+ try {
160
+ if (!isOpenCodeInstalled()) {
161
+ return res.status(404).json({ error: 'OpenCode CLI not installed' });
162
+ }
163
+
164
+ const { sessionId } = req.params;
165
+ const session = getSessionById(sessionId);
166
+ if (!session) {
167
+ return res.status(404).json({ error: 'Session not found' });
168
+ }
169
+
170
+ res.json({
171
+ sessionId,
172
+ lastModified: session.mtime || null,
173
+ size: session.size || 0
174
+ });
175
+ } catch (err) {
176
+ console.error('[OpenCode API] Failed to get session status:', err);
177
+ res.status(500).json({ error: err.message });
178
+ }
179
+ });
180
+
181
+ /**
182
+ * GET /api/opencode/sessions/:projectName/:sessionId/outline
183
+ * 获取用户消息目录
184
+ */
185
+ router.get('/:projectName/:sessionId/outline', (req, res) => {
186
+ try {
187
+ if (!isOpenCodeInstalled()) {
188
+ return res.status(404).json({ error: 'OpenCode CLI not installed' });
189
+ }
190
+
191
+ const { sessionId } = req.params;
192
+ const session = getSessionById(sessionId);
193
+ if (!session) {
194
+ return res.status(404).json({ error: 'Session not found' });
195
+ }
196
+
197
+ let userMessageNumber = 0;
198
+ const items = [];
199
+ for (const msg of getSessionMessages(sessionId)) {
200
+ if (msg.type !== 'user') continue;
201
+ const preview = typeof msg.content === 'string' ? msg.content.trim() : '';
202
+ if (!preview) continue;
203
+ userMessageNumber += 1;
204
+ items.push({
205
+ userMessageNumber,
206
+ preview: preview.length > 42 ? `${preview.slice(0, 42)}...` : preview,
207
+ timestamp: msg.timestamp || null
208
+ });
209
+ }
210
+
211
+ res.json({
212
+ sessionId,
213
+ items
214
+ });
215
+ } catch (err) {
216
+ console.error('[OpenCode API] Failed to get session outline:', err);
217
+ res.status(500).json({ error: err.message });
218
+ }
219
+ });
220
+
155
221
  /**
156
222
  * GET /api/opencode/sessions/:projectName/:sessionId/messages
157
223
  * 获取会话的消息列表
@@ -168,7 +234,18 @@ module.exports = (config) => {
168
234
  if (!session) {
169
235
  return res.status(404).json({ error: 'Session not found' });
170
236
  }
171
- const convertedMessages = getSessionMessages(sessionId);
237
+ const baseMessages = getSessionMessages(sessionId);
238
+ let userMessageNumber = 0;
239
+ const convertedMessages = baseMessages.map((message) => {
240
+ if (message.type !== 'user') {
241
+ return message;
242
+ }
243
+ userMessageNumber += 1;
244
+ return {
245
+ ...message,
246
+ userMessageNumber
247
+ };
248
+ });
172
249
 
173
250
  // 分页处理
174
251
  const pageNum = parseInt(page);
@@ -342,7 +419,6 @@ module.exports = (config) => {
342
419
  }
343
420
 
344
421
  const { projectName, sessionId } = req.params;
345
- const fork = Boolean(req.body?.fork);
346
422
 
347
423
  const session = getSessionById(sessionId);
348
424
  if (!session) {
@@ -352,16 +428,14 @@ module.exports = (config) => {
352
428
  const projects = getProjects();
353
429
  const project = projects.find(p => p.name === projectName);
354
430
  const cwd = session.directory || project?.fullPath || HOME_DIR;
355
- const { command, copyCommand } = buildLaunchCommand({
356
- cwd,
357
- executable: 'opencode',
358
- args: fork ? ['--session', sessionId, '--fork'] : ['--session', sessionId]
359
- });
431
+ const command = `opencode -r ${sessionId}`;
432
+ const quotedCwd = `"${String(cwd).replace(/"/g, '\\"')}"`;
433
+ const copyCommand = `cd ${quotedCwd} && ${command}`;
360
434
 
361
435
  broadcastLog({
362
436
  type: 'action',
363
437
  action: 'launch_opencode_session',
364
- message: `${fork ? '复制 OpenCode 会话 Fork 命令' : '复制 OpenCode 会话启动命令'} ${sessionId.substring(0, 8)}`,
438
+ message: `复制 OpenCode 会话启动命令 ${sessionId.substring(0, 8)}`,
365
439
  sessionId,
366
440
  tool: 'opencode',
367
441
  timestamp: Date.now()
@@ -372,7 +446,6 @@ module.exports = (config) => {
372
446
  cwd,
373
447
  sessionId,
374
448
  tool: 'opencode',
375
- fork,
376
449
  command,
377
450
  copyCommand
378
451
  });
@@ -45,24 +45,6 @@ function extractRepoPayload(source = {}) {
45
45
  };
46
46
  }
47
47
 
48
- function extractPluginQueryInfo(name, query = {}) {
49
- return {
50
- name,
51
- repoId: query.repoId || '',
52
- repoProvider: query.repoProvider || '',
53
- repoHost: query.repoHost || '',
54
- repoOwner: query.repoOwner || '',
55
- repoName: query.repoName || '',
56
- repoBranch: query.repoBranch || '',
57
- directory: query.directory || '',
58
- source: query.source || '',
59
- repoUrl: query.repoUrl || '',
60
- repoProjectPath: query.repoProjectPath || '',
61
- repoLocalPath: query.repoLocalPath || '',
62
- installPath: query.installPath || ''
63
- };
64
- }
65
-
66
48
  function sanitizeRepo(repo = {}) {
67
49
  const token = String(repo.token || '').trim();
68
50
  const sanitized = {
@@ -88,10 +70,7 @@ function sanitizeRepos(service, repos = []) {
88
70
  router.get('/', (req, res) => {
89
71
  try {
90
72
  const { platform, service } = getPluginsService(req);
91
- const forceRefresh = req.query.refresh === '1';
92
- const result = service.listPlugins({
93
- syncManagedLocalPlugins: forceRefresh
94
- });
73
+ const result = service.listPlugins();
95
74
 
96
75
  res.json({
97
76
  success: true,
@@ -192,46 +171,6 @@ router.post('/install', async (req, res) => {
192
171
  }
193
172
  });
194
173
 
195
- /**
196
- * 安装本地托管插件
197
- * POST /api/plugins/install-local
198
- * Body: { name }
199
- */
200
- router.post('/install-local', (req, res) => {
201
- try {
202
- const { platform, service } = getPluginsService(req);
203
- const { name } = req.body;
204
-
205
- if (!name) {
206
- return res.status(400).json({
207
- success: false,
208
- message: 'name is required'
209
- });
210
- }
211
-
212
- const result = service.installLocalPlugin(name);
213
- if (!result.success) {
214
- return res.status(400).json({
215
- success: false,
216
- message: result.error || result.message || 'Install failed'
217
- });
218
- }
219
-
220
- res.json({
221
- success: true,
222
- platform,
223
- plugin: result.plugin,
224
- message: result.message || `Plugin "${result.plugin?.name || name}" installed successfully`
225
- });
226
- } catch (err) {
227
- console.error('[Plugins API] Install local plugin error:', err);
228
- res.status(500).json({
229
- success: false,
230
- message: err.message
231
- });
232
- }
233
- });
234
-
235
174
  // ==================== 仓库管理 API ====================
236
175
 
237
176
  /**
@@ -483,66 +422,6 @@ router.post('/sync', async (req, res) => {
483
422
  }
484
423
  });
485
424
 
486
- /**
487
- * 获取插件文件列表
488
- * GET /api/plugins/:name/files
489
- */
490
- router.get('/:name/files', async (req, res) => {
491
- try {
492
- const { platform, service } = getPluginsService(req);
493
- const { name } = req.params;
494
- const pluginInfo = extractPluginQueryInfo(name, req.query);
495
- const result = await service.getPluginFiles(pluginInfo);
496
-
497
- res.json({
498
- success: true,
499
- platform,
500
- ...result
501
- });
502
- } catch (err) {
503
- console.error('[Plugins API] Get plugin files error:', err);
504
- res.status(500).json({
505
- success: false,
506
- message: err.message,
507
- files: []
508
- });
509
- }
510
- });
511
-
512
- /**
513
- * 获取插件单个文件内容
514
- * GET /api/plugins/:name/file/*
515
- */
516
- router.get('/:name/file/*', async (req, res) => {
517
- try {
518
- const { platform, service } = getPluginsService(req);
519
- const { name } = req.params;
520
- const filePath = req.params[0];
521
-
522
- if (!filePath) {
523
- return res.status(400).json({
524
- success: false,
525
- message: '请指定文件路径'
526
- });
527
- }
528
-
529
- const pluginInfo = extractPluginQueryInfo(name, req.query);
530
- const result = await service.getPluginFileContent(pluginInfo, filePath);
531
-
532
- res.json({
533
- success: true,
534
- platform,
535
- ...result
536
- });
537
- } catch (err) {
538
- console.error('[Plugins API] Get plugin file content error:', err);
539
- res.status(500).json({
540
- success: false,
541
- message: err.message
542
- });
543
- }
544
- });
545
-
546
425
  /**
547
426
  * 获取插件 README
548
427
  * GET /api/plugins/:name/readme
@@ -552,7 +431,36 @@ router.get('/:name/readme', async (req, res) => {
552
431
  try {
553
432
  const { platform, service } = getPluginsService(req);
554
433
  const { name } = req.params;
555
- const pluginInfo = extractPluginQueryInfo(name, req.query);
434
+ const {
435
+ repoId,
436
+ repoProvider,
437
+ repoHost,
438
+ repoOwner,
439
+ repoName,
440
+ repoBranch,
441
+ directory,
442
+ source,
443
+ repoUrl,
444
+ repoProjectPath,
445
+ repoLocalPath,
446
+ installPath
447
+ } = req.query;
448
+
449
+ const pluginInfo = {
450
+ name,
451
+ repoId,
452
+ repoProvider,
453
+ repoHost,
454
+ repoOwner,
455
+ repoName,
456
+ repoBranch,
457
+ directory,
458
+ source,
459
+ repoUrl,
460
+ repoProjectPath,
461
+ repoLocalPath,
462
+ installPath
463
+ };
556
464
 
557
465
  const readme = await service.getPluginReadme(pluginInfo);
558
466
 
@@ -575,14 +483,12 @@ router.get('/:name/readme', async (req, res) => {
575
483
  * 获取单个插件详情
576
484
  * GET /api/plugins/:name
577
485
  */
578
- router.get('/:name', async (req, res) => {
486
+ router.get('/:name', (req, res) => {
579
487
  try {
580
488
  const { platform, service } = getPluginsService(req);
581
489
  const { name } = req.params;
582
- const pluginInfo = extractPluginQueryInfo(name, req.query);
583
- const plugin = typeof service.getPluginDetail === 'function'
584
- ? await service.getPluginDetail(pluginInfo)
585
- : service.getPlugin(name);
490
+
491
+ const plugin = service.getPlugin(name);
586
492
 
587
493
  if (!plugin) {
588
494
  return res.status(404).json({
@@ -11,7 +11,7 @@ const {
11
11
  hasBackup,
12
12
  readSettings
13
13
  } = require('../services/settings-manager');
14
- const { getAllChannels, extractApiKeyFromHelper } = require('../services/channels');
14
+ const { getAllChannels, markChannelAsRecentlyUsed, extractApiKeyFromHelper } = require('../services/channels');
15
15
  const { clearNativeOAuth } = require('../services/native-oauth-adapters');
16
16
  const { clearAllLogs } = require('../websocket-server');
17
17
  const { PATHS, NATIVE_PATHS, ensureStorageDirMigrated } = require('../../config/paths');
@@ -194,13 +194,15 @@ router.post('/start', async (req, res) => {
194
194
  }
195
195
 
196
196
  // 2. 从 settings.json 找到当前使用的渠道
197
- const currentChannel = findActiveChannelFromSettings();
197
+ let currentChannel = findActiveChannelFromSettings();
198
198
  if (!currentChannel) {
199
199
  return res.status(400).json({
200
200
  error: '无法从 settings.json 识别当前渠道。请先激活一个渠道。'
201
201
  });
202
202
  }
203
203
 
204
+ currentChannel = markChannelAsRecentlyUsed(currentChannel.id);
205
+
204
206
  // 3. 保存当前激活渠道ID(用于代理模式)
205
207
  saveActiveChannelId(currentChannel.id);
206
208
  console.log(`[OK] Saved active channel: ${currentChannel.name} (${currentChannel.id})`);
@@ -218,7 +220,7 @@ router.post('/start', async (req, res) => {
218
220
 
219
221
  const updatedStatus = getProxyStatus();
220
222
  const channels = getAllChannels();
221
- const activeChannel = channels.find(ch => ch.enabled !== false);
223
+ const activeChannel = channels.find(ch => ch.id === currentChannel.id) || currentChannel;
222
224
 
223
225
  // 6. 通过 WebSocket 推送代理状态更新
224
226
  const { broadcastProxyState } = require('../websocket-server');
@@ -6,10 +6,118 @@ const readline = require('readline');
6
6
  const { getSessionsForProject, deleteSession, forkSession, saveSessionOrder, parseRealProjectPath, searchSessions, getRecentSessions, searchSessionsAcrossProjects, hasActualMessages } = require('../services/sessions');
7
7
  const { loadAliases } = require('../services/alias');
8
8
  const { broadcastLog } = require('../websocket-server');
9
- const { buildLaunchCommand } = require('../services/session-launch-command');
10
- const { NATIVE_PATHS, joinNativeBasePath } = require('../../config/paths');
9
+ const { NATIVE_PATHS } = require('../../config/paths');
11
10
  const CLAUDE_PROJECTS_DIR = NATIVE_PATHS.claude.projects;
12
11
 
12
+ function resolveClaudeSessionFile(projectName, sessionId, fullPath) {
13
+ const possiblePaths = [
14
+ path.join(fullPath, '.claude', 'sessions', sessionId + '.jsonl'),
15
+ path.join(CLAUDE_PROJECTS_DIR, projectName, sessionId + '.jsonl')
16
+ ];
17
+
18
+ for (const testPath of possiblePaths) {
19
+ if (fs.existsSync(testPath)) {
20
+ return {
21
+ sessionFile: testPath,
22
+ triedPaths: possiblePaths
23
+ };
24
+ }
25
+ }
26
+
27
+ return {
28
+ sessionFile: null,
29
+ triedPaths: possiblePaths
30
+ };
31
+ }
32
+
33
+ function getClaudeUserText(content) {
34
+ if (typeof content === 'string') {
35
+ return content === 'Warmup' ? '' : content;
36
+ }
37
+
38
+ if (!Array.isArray(content)) {
39
+ return '';
40
+ }
41
+
42
+ const parts = [];
43
+ for (const item of content) {
44
+ if (item?.type === 'text' && item.text) {
45
+ parts.push(item.text);
46
+ } else if (item?.type === 'image') {
47
+ parts.push('[图片]');
48
+ }
49
+ }
50
+
51
+ return parts.join('\n\n').trim();
52
+ }
53
+
54
+ function buildOutlinePreview(text = '') {
55
+ const firstLine = String(text)
56
+ .split('\n')
57
+ .map(line => line.trim())
58
+ .find(Boolean) || '(空消息)';
59
+ return firstLine.length > 42 ? `${firstLine.slice(0, 42)}...` : firstLine;
60
+ }
61
+
62
+ async function readClaudeSessionOutline(sessionFile) {
63
+ const outline = [];
64
+ const stream = fs.createReadStream(sessionFile, { encoding: 'utf8' });
65
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
66
+ let userMessageNumber = 0;
67
+
68
+ try {
69
+ for await (const line of rl) {
70
+ if (!line.trim()) continue;
71
+
72
+ let json;
73
+ try {
74
+ json = JSON.parse(line);
75
+ } catch (err) {
76
+ continue;
77
+ }
78
+
79
+ if (json.type !== 'user') {
80
+ continue;
81
+ }
82
+
83
+ const userText = getClaudeUserText(json.message?.content);
84
+ if (!userText) {
85
+ continue;
86
+ }
87
+
88
+ userMessageNumber += 1;
89
+ outline.push({
90
+ userMessageNumber,
91
+ preview: buildOutlinePreview(userText),
92
+ timestamp: json.timestamp || null
93
+ });
94
+ }
95
+ } finally {
96
+ rl.close();
97
+ stream.destroy();
98
+ }
99
+
100
+ return outline;
101
+ }
102
+
103
+ function normalizeForkOptions(body = {}) {
104
+ const alias = typeof body.alias === 'string' ? body.alias.trim() : '';
105
+ const rawNumber = body.afterUserMessageNumber;
106
+ const hasExplicitNumber = rawNumber !== undefined && rawNumber !== null && rawNumber !== '';
107
+ const parsedNumber = hasExplicitNumber ? parseInt(rawNumber, 10) : null;
108
+
109
+ if (hasExplicitNumber && (!Number.isInteger(parsedNumber) || parsedNumber <= 0)) {
110
+ const error = new Error('afterUserMessageNumber must be a positive integer');
111
+ error.statusCode = 400;
112
+ throw error;
113
+ }
114
+
115
+ return {
116
+ afterUserMessageNumber: parsedNumber,
117
+ alias: alias || null
118
+ };
119
+ }
120
+
13
121
  module.exports = (config) => {
14
122
  // GET /api/sessions/search/global - Search sessions across all projects
15
123
  router.get('/search/global', async (req, res) => {
@@ -137,11 +245,11 @@ module.exports = (config) => {
137
245
  router.post('/:projectName/:sessionId/fork', (req, res) => {
138
246
  try {
139
247
  const { projectName, sessionId } = req.params;
140
- const result = forkSession(config, projectName, sessionId);
248
+ const result = forkSession(config, projectName, sessionId, normalizeForkOptions(req.body));
141
249
  res.json(result);
142
250
  } catch (error) {
143
251
  console.error('Error forking session:', error);
144
- res.status(500).json({ error: error.message });
252
+ res.status(error.statusCode || 500).json({ error: error.message });
145
253
  }
146
254
  });
147
255
 
@@ -171,13 +279,13 @@ module.exports = (config) => {
171
279
  const year = now.getFullYear();
172
280
  const month = String(now.getMonth() + 1).padStart(2, '0');
173
281
  const day = String(now.getDate()).padStart(2, '0');
174
- sessionDir = joinNativeBasePath(NATIVE_PATHS.codex.sessions, String(year), month, day);
175
- sessionFile = joinNativeBasePath(sessionDir, `${newSessionId}.jsonl`);
282
+ sessionDir = path.join(NATIVE_PATHS.codex.sessions, String(year), month, day);
283
+ sessionFile = path.join(sessionDir, `${newSessionId}.jsonl`);
176
284
  } else if (toolType === 'gemini') {
177
285
  // Gemini: ~/.gemini/tmp/{hash}/chats/{sessionId}.json
178
286
  const pathHash = crypto.createHash('sha256').update(fullPath).digest('hex');
179
- sessionDir = joinNativeBasePath(NATIVE_PATHS.gemini.tmp, pathHash, 'chats');
180
- sessionFile = joinNativeBasePath(sessionDir, `${newSessionId}.json`);
287
+ sessionDir = path.join(NATIVE_PATHS.gemini.tmp, pathHash, 'chats');
288
+ sessionFile = path.join(sessionDir, `${newSessionId}.json`);
181
289
  } else {
182
290
  return res.status(400).json({ error: 'Invalid toolType. Must be claude, codex, or gemini' });
183
291
  }
@@ -275,6 +383,57 @@ module.exports = (config) => {
275
383
  }
276
384
  });
277
385
 
386
+ // GET /api/sessions/:projectName/:sessionId/status - Lightweight status for live sync
387
+ router.get('/:projectName/:sessionId/status', async (req, res) => {
388
+ try {
389
+ const { projectName, sessionId } = req.params;
390
+ const { fullPath } = parseRealProjectPath(projectName);
391
+ const { sessionFile, triedPaths } = resolveClaudeSessionFile(projectName, sessionId, fullPath);
392
+
393
+ if (!sessionFile) {
394
+ return res.status(404).json({
395
+ error: `Session file not found: ${sessionId}`,
396
+ triedPaths
397
+ });
398
+ }
399
+
400
+ const stats = await fs.promises.stat(sessionFile);
401
+ res.json({
402
+ sessionId,
403
+ lastModified: stats.mtime.toISOString(),
404
+ size: stats.size
405
+ });
406
+ } catch (error) {
407
+ console.error('Error fetching session status:', error);
408
+ res.status(500).json({ error: error.message });
409
+ }
410
+ });
411
+
412
+ // GET /api/sessions/:projectName/:sessionId/outline - Lightweight user outline for TOC and forking
413
+ router.get('/:projectName/:sessionId/outline', async (req, res) => {
414
+ try {
415
+ const { projectName, sessionId } = req.params;
416
+ const { fullPath } = parseRealProjectPath(projectName);
417
+ const { sessionFile, triedPaths } = resolveClaudeSessionFile(projectName, sessionId, fullPath);
418
+
419
+ if (!sessionFile) {
420
+ return res.status(404).json({
421
+ error: `Session file not found: ${sessionId}`,
422
+ triedPaths
423
+ });
424
+ }
425
+
426
+ const outline = await readClaudeSessionOutline(sessionFile);
427
+ res.json({
428
+ sessionId,
429
+ items: outline
430
+ });
431
+ } catch (error) {
432
+ console.error('Error fetching session outline:', error);
433
+ res.status(500).json({ error: error.message });
434
+ }
435
+ });
436
+
278
437
  // GET /api/sessions/:projectName/:sessionId/messages - Get session messages with pagination
279
438
  router.get('/:projectName/:sessionId/messages', async (req, res) => {
280
439
  try {
@@ -291,20 +450,12 @@ module.exports = (config) => {
291
450
  console.log(`[Messages API] Parsed project path: ${fullPath}`);
292
451
 
293
452
  // Try to find session file
294
- let sessionFile = null;
295
- const possiblePaths = [
296
- path.join(fullPath, '.claude', 'sessions', sessionId + '.jsonl'),
297
- path.join(CLAUDE_PROJECTS_DIR, projectName, sessionId + '.jsonl')
298
- ];
453
+ const { sessionFile, triedPaths: possiblePaths } = resolveClaudeSessionFile(projectName, sessionId, fullPath);
299
454
 
300
455
  console.log(`[Messages API] Trying paths:`, possiblePaths);
301
456
 
302
- for (const testPath of possiblePaths) {
303
- if (fs.existsSync(testPath)) {
304
- sessionFile = testPath;
305
- console.log(`[Messages API] Found session file: ${sessionFile}`);
306
- break;
307
- }
457
+ if (sessionFile) {
458
+ console.log(`[Messages API] Found session file: ${sessionFile}`);
308
459
  }
309
460
 
310
461
  if (!sessionFile) {
@@ -331,6 +482,7 @@ module.exports = (config) => {
331
482
  const stream = fs.createReadStream(sessionFile, { encoding: 'utf8' });
332
483
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
333
484
  let lastAssistantModel = null;
485
+ let userMessageNumber = 0;
334
486
 
335
487
  try {
336
488
  for await (const line of rl) {
@@ -362,6 +514,7 @@ module.exports = (config) => {
362
514
  let deferredToolResultContent = '';
363
515
 
364
516
  if (json.type === 'user') {
517
+ const normalizedUserText = getClaudeUserText(json.message?.content);
365
518
  if (typeof json.message?.content === 'string') {
366
519
  message.content = json.message.content;
367
520
  } else if (Array.isArray(json.message?.content)) {
@@ -389,6 +542,11 @@ module.exports = (config) => {
389
542
  deferredToolResultContent = toolResultParts.join('\n\n');
390
543
  }
391
544
  }
545
+
546
+ if (normalizedUserText) {
547
+ userMessageNumber += 1;
548
+ message.userMessageNumber = userMessageNumber;
549
+ }
392
550
  } else if (json.type === 'assistant') {
393
551
  if (Array.isArray(json.message?.content)) {
394
552
  const parts = [];
@@ -472,7 +630,6 @@ module.exports = (config) => {
472
630
  router.post('/:projectName/:sessionId/launch', async (req, res) => {
473
631
  try {
474
632
  const { projectName, sessionId } = req.params;
475
- const fork = Boolean(req.body?.fork);
476
633
  const path = require('path');
477
634
  const fs = require('fs');
478
635
 
@@ -565,18 +722,16 @@ module.exports = (config) => {
565
722
  broadcastLog({
566
723
  type: 'action',
567
724
  action: 'launch_session',
568
- message: `${fork ? '复制会话 Fork 命令' : '复制会话启动命令'} ${alias || sessionId.substring(0, 8)} (claude)`,
725
+ message: `复制会话启动命令 ${alias || sessionId.substring(0, 8)} (claude)`,
569
726
  sessionId,
570
727
  alias: alias || null,
571
728
  tool: 'claude',
572
729
  timestamp: Date.now()
573
730
  });
574
731
 
575
- const { command, copyCommand } = buildLaunchCommand({
576
- cwd,
577
- executable: 'claude',
578
- args: fork ? ['-r', sessionId, '--fork-session'] : ['-r', sessionId]
579
- });
732
+ const command = `claude -r ${sessionId}`;
733
+ const quotedCwd = `"${String(cwd).replace(/"/g, '\\"')}"`;
734
+ const copyCommand = `cd ${quotedCwd} && ${command}`;
580
735
 
581
736
  res.json({
582
737
  success: true,
@@ -584,7 +739,6 @@ module.exports = (config) => {
584
739
  sessionFile,
585
740
  sessionId,
586
741
  tool: 'claude',
587
- fork,
588
742
  command,
589
743
  copyCommand
590
744
  });