aicodeswitch 6.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/restore.js CHANGED
@@ -5,7 +5,7 @@ const chalk = require('chalk');
5
5
  const boxen = require('boxen');
6
6
  const ora = require('ora');
7
7
  const { parseToml, stringifyToml, mergeJsonSettings, mergeTomlSettings, atomicWriteFile } = require('./utils/config-helpers');
8
- const { CLAUDE_SETTINGS_MANAGED_FIELDS, CLAUDE_JSON_MANAGED_FIELDS, CODEX_CONFIG_MANAGED_FIELDS, CODEX_AUTH_MANAGED_FIELDS } = require('./utils/managed-fields');
8
+ const { CLAUDE_SETTINGS_MANAGED_FIELDS, CLAUDE_JSON_MANAGED_FIELDS, CODEX_CONFIG_MANAGED_FIELDS, CODEX_AUTH_MANAGED_FIELDS, OPENCODE_CONFIG_MANAGED_FIELDS } = require('./utils/managed-fields');
9
9
  const { isServerRunning, getServerInfo } = require('./utils/get-server');
10
10
  const { findPidByPort } = require('./utils/port-utils');
11
11
 
@@ -240,10 +240,57 @@ const restoreCodexConfig = () => {
240
240
  return results;
241
241
  };
242
242
 
243
+ // 恢复 OpenCode 配置(使用智能合并)
244
+ const restoreOpencodeConfig = () => {
245
+ const results = {
246
+ restored: [],
247
+ notFound: [],
248
+ errors: []
249
+ };
250
+
251
+ try {
252
+ const homeDir = os.homedir();
253
+ const opencodeConfigPath = path.join(homeDir, '.config', 'opencode', 'opencode.json');
254
+ const opencodeConfigBakPath = `${opencodeConfigPath}.aicodeswitch_backup`;
255
+
256
+ if (fs.existsSync(opencodeConfigBakPath)) {
257
+ try {
258
+ const backupConfig = JSON.parse(fs.readFileSync(opencodeConfigBakPath, 'utf-8'));
259
+ let currentConfig = {};
260
+ if (fs.existsSync(opencodeConfigPath)) {
261
+ try {
262
+ currentConfig = JSON.parse(fs.readFileSync(opencodeConfigPath, 'utf-8'));
263
+ } catch (e) {
264
+ // 忽略解析错误
265
+ }
266
+ }
267
+
268
+ const mergedConfig = mergeJsonSettings(
269
+ backupConfig,
270
+ currentConfig,
271
+ OPENCODE_CONFIG_MANAGED_FIELDS
272
+ );
273
+
274
+ atomicWriteFile(opencodeConfigPath, JSON.stringify(mergedConfig, null, 2));
275
+ fs.unlinkSync(opencodeConfigBakPath);
276
+ results.restored.push('opencode.json');
277
+ } catch (error) {
278
+ results.errors.push({ file: 'opencode.json', error: error.message });
279
+ }
280
+ } else {
281
+ results.notFound.push('opencode.json.aicodeswitch_backup');
282
+ }
283
+ } catch (err) {
284
+ results.errors.push({ file: 'opencode.json', error: err.message });
285
+ }
286
+
287
+ return results;
288
+ };
289
+
243
290
  // 显示恢复结果
244
291
  const showRestoreResult = (target, results) => {
245
- const targetName = target === 'claude-code' ? 'Claude Code' : 'Codex';
246
- const targetColor = target === 'claude-code' ? chalk.cyan : chalk.magenta;
292
+ const targetName = target === 'claude-code' ? 'Claude Code' : target === 'opencode' ? 'OpenCode' : 'Codex';
293
+ const targetColor = target === 'claude-code' ? chalk.cyan : target === 'opencode' ? chalk.blue : chalk.magenta;
247
294
 
248
295
  let message = targetColor.bold(`${targetName} Configuration Restore\n\n`);
249
296
 
@@ -276,6 +323,8 @@ const showRestoreResult = (target, results) => {
276
323
  message += chalk.yellow.bold('⚠️ Important:\n\n');
277
324
  if (target === 'claude-code') {
278
325
  message += chalk.white('Please restart ') + chalk.cyan.bold('Claude Code') + chalk.white(' to apply the restored configuration.\n');
326
+ } else if (target === 'opencode') {
327
+ message += chalk.white('Please restart ') + chalk.blue.bold('OpenCode') + chalk.white(' to apply the restored configuration.\n');
279
328
  } else {
280
329
  message += chalk.white('Please restart ') + chalk.magenta.bold('Codex') + chalk.white(' to apply the restored configuration.\n');
281
330
  }
@@ -307,7 +356,7 @@ const restore = async () => {
307
356
 
308
357
  console.log('\n');
309
358
 
310
- const validTargets = ['claude-code', 'codex', undefined];
359
+ const validTargets = ['claude-code', 'codex', 'opencode', undefined];
311
360
 
312
361
  if (target && !validTargets.includes(target)) {
313
362
  console.log(boxen(
@@ -316,6 +365,7 @@ const restore = async () => {
316
365
  chalk.white('Targets:\n') +
317
366
  chalk.white(' claude-code Restore Claude Code configuration\n') +
318
367
  chalk.white(' codex Restore Codex configuration\n') +
368
+ chalk.white(' opencode Restore OpenCode configuration\n') +
319
369
  chalk.white(' (no arg) Restore all configurations\n'),
320
370
  {
321
371
  padding: 1,
@@ -379,6 +429,19 @@ const restore = async () => {
379
429
  showRestoreResult('codex', codexResults);
380
430
  }
381
431
 
432
+ if (target === 'opencode' || !target) {
433
+ if (!target) console.log('');
434
+
435
+ const spinner = ora({
436
+ text: chalk.blue('Restoring OpenCode configuration...'),
437
+ color: 'blue'
438
+ }).start();
439
+
440
+ const opencodeResults = restoreOpencodeConfig();
441
+ spinner.succeed(chalk.green('OpenCode restore complete'));
442
+ showRestoreResult('opencode', opencodeResults);
443
+ }
444
+
382
445
  // 停用所有激活的路由
383
446
  console.log('');
384
447
  const routesSpinner = ora({
@@ -56,9 +56,21 @@ const CODEX_AUTH_MANAGED_FIELDS = [
56
56
  'OPENAI_API_KEY',
57
57
  ];
58
58
 
59
+ /**
60
+ * OpenCode opencode.json 管理字段列表
61
+ * 托管整个 provider.aicodeswitch 段与 model/small_model/mcp
62
+ */
63
+ const OPENCODE_CONFIG_MANAGED_FIELDS = [
64
+ 'provider.aicodeswitch',
65
+ 'model',
66
+ 'small_model',
67
+ 'mcp',
68
+ ];
69
+
59
70
  module.exports = {
60
71
  CLAUDE_SETTINGS_MANAGED_FIELDS,
61
72
  CLAUDE_JSON_MANAGED_FIELDS,
62
73
  CODEX_CONFIG_MANAGED_FIELDS,
63
74
  CODEX_AUTH_MANAGED_FIELDS,
75
+ OPENCODE_CONFIG_MANAGED_FIELDS,
64
76
  };
@@ -314,6 +314,14 @@ function detectTurnEnd(agent, downstream, responseBody) {
314
314
  return true;
315
315
  return null;
316
316
  }
317
+ if (agent === 'opencode') {
318
+ // OpenAI Chat Completions:tool_calls 仍在 → 继续;finish_reason=stop / [DONE] → 本轮结束
319
+ if (/"finish_reason"\s*:\s*"tool_calls"/.test(raw))
320
+ return false;
321
+ if (/"finish_reason"\s*:\s*"(stop|length|content_filter)"/.test(raw) || /\[DONE\]/.test(raw))
322
+ return true;
323
+ return null;
324
+ }
317
325
  // Claude:看 stop_reason(流式 message_delta 或非流式 JSON 都带该字段)
318
326
  const m = raw.match(/"stop_reason"\s*:\s*"([a-z_]+)"/);
319
327
  const stopReason = m ? m[1] : null;
@@ -599,7 +599,8 @@ class AgentMapService extends events_1.EventEmitter {
599
599
  // 异步解析本地会话元信息(项目路径 + 原始标题);仅 global 来源、且未解析过
600
600
  this.enrichSession(st);
601
601
  }
602
- /** 从本机 Claude/Codex 会话存储读取项目路径与原始标题并回填(access-key 不解析) */
602
+ /** 从本机 Claude/Codex 会话存储读取项目路径并回填(access-key 不解析)。
603
+ * 注意:不在此处覆盖标题——统一沿用 proxy 抽取的标题(与会话列表一致),避免两边标题分叉。 */
603
604
  enrichSession(st) {
604
605
  return __awaiter(this, void 0, void 0, function* () {
605
606
  if (st.source === 'access-key' || st.metaResolved)
@@ -607,18 +608,10 @@ class AgentMapService extends events_1.EventEmitter {
607
608
  st.metaResolved = true;
608
609
  try {
609
610
  const meta = yield (0, session_meta_1.resolveSessionMeta)(st.sessionId, st.agent);
610
- let changed = false;
611
611
  if (meta.projectPath && !st.projectPath) {
612
612
  st.projectPath = meta.projectPath;
613
- changed = true;
614
- }
615
- // 原始标题更标准,命中即覆盖日志截取的标题
616
- if (meta.title) {
617
- st.title = meta.title;
618
- changed = true;
619
- }
620
- if (changed)
621
613
  this.emitSession(st);
614
+ }
622
615
  }
623
616
  catch ( /* ignore */_a) { /* ignore */ }
624
617
  });
@@ -631,18 +624,16 @@ class AgentMapService extends events_1.EventEmitter {
631
624
  if (source === 'access-key' || source === 'unknown') {
632
625
  return { source, projectPath: st === null || st === void 0 ? void 0 : st.projectPath, title: st === null || st === void 0 ? void 0 : st.title };
633
626
  }
634
- // global:命中缓存或现解析
627
+ // global:命中缓存或现解析(仅项目路径;标题沿用 proxy 抽取值,与会话列表保持一致)
635
628
  const meta = yield (0, session_meta_1.resolveSessionMeta)(sessionId, (st === null || st === void 0 ? void 0 : st.agent) || 'claude-code');
636
629
  if (st) {
637
630
  if (meta.projectPath && !st.projectPath)
638
631
  st.projectPath = meta.projectPath;
639
- if (meta.title)
640
- st.title = meta.title;
641
632
  st.metaResolved = true;
642
633
  // legacy 会话(修复前累积、拆分从未持久化):点开详情时按日志回填输入/输出拆分
643
634
  this.backfillTokenSplit(st).catch(err => console.error('[AgentMap] backfillTokenSplit error:', err));
644
635
  }
645
- return { source, projectPath: meta.projectPath || (st === null || st === void 0 ? void 0 : st.projectPath), title: meta.title || (st === null || st === void 0 ? void 0 : st.title) };
636
+ return { source, projectPath: meta.projectPath || (st === null || st === void 0 ? void 0 : st.projectPath), title: st === null || st === void 0 ? void 0 : st.title };
646
637
  });
647
638
  }
648
639
  /**
@@ -736,6 +727,8 @@ class AgentMapService extends events_1.EventEmitter {
736
727
  return; // 本轮已调度
737
728
  if (st.lastStatusCode === 499)
738
729
  return; // 用户主动取消,不弹
730
+ if (st.thinkingInFlight > 0)
731
+ return; // 仍有思考请求在途,暂不弹结束通知
739
732
  st.notifiedForTurn = true;
740
733
  this.scheduleNotify(st, isError);
741
734
  }
@@ -808,7 +801,9 @@ class AgentMapService extends events_1.EventEmitter {
808
801
  }
809
802
  // 已开始产出:思考请求用更宽的 THINKING_SILENCE_MS,否则 SSE_SILENCE_MS(30s)
810
803
  const limit = args.thinkingInFlight > 0 ? this.thinkingSilenceMs : this.sseSilenceMs;
811
- const since = args.streamFirstChunkAt;
804
+ // 静默基准用「最近一次活动」(每个下游 chunk 经 heartbeat 刷新),而非首个 chunk 时刻:
805
+ // 否则任何总时长超过 30s 的流式响应都会被误判为停滞,在响应仍正常输出时弹出「任务已结束」通知。
806
+ const since = args.lastActivityAt;
812
807
  if (args.now - since <= limit) {
813
808
  return { status: 'active', reason: args.thinkingInFlight > 0 ? 'thinking' : 'streaming', notifyEligible: true };
814
809
  }
@@ -37,6 +37,9 @@ function isCodingToolRequest(body, format, headers) {
37
37
  // Codex: originator header
38
38
  if ((headers['originator'] || '').toLowerCase().includes('codex'))
39
39
  return { isCoding: true, reason: '' };
40
+ // OpenCode: user-agent 包含 "opencode"
41
+ if (ua.includes('opencode'))
42
+ return { isCoding: true, reason: '' };
40
43
  }
41
44
  // ── Layer 2: Claude Messages API ───────────────────────────────────
42
45
  if (format === 'claude' && Array.isArray(body === null || body === void 0 ? void 0 : body.messages)) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getManagedFields = exports.CODEX_AUTH_MANAGED_FIELDS = exports.CODEX_CONFIG_MANAGED_FIELDS = exports.CLAUDE_JSON_MANAGED_FIELDS = exports.CLAUDE_SETTINGS_MANAGED_FIELDS = void 0;
3
+ exports.getManagedFields = exports.OPENCODE_CONFIG_MANAGED_FIELDS = exports.CODEX_AUTH_MANAGED_FIELDS = exports.CODEX_CONFIG_MANAGED_FIELDS = exports.CLAUDE_JSON_MANAGED_FIELDS = exports.CLAUDE_SETTINGS_MANAGED_FIELDS = void 0;
4
4
  /**
5
5
  * Claude Code settings.json 管理字段定义
6
6
  */
@@ -50,6 +50,20 @@ exports.CODEX_CONFIG_MANAGED_FIELDS = [
50
50
  exports.CODEX_AUTH_MANAGED_FIELDS = [
51
51
  { path: ['OPENAI_API_KEY'] },
52
52
  ];
53
+ /**
54
+ * OpenCode opencode.json 管理字段定义
55
+ *
56
+ * OpenCode 配置为纯 JSON,使用自定义 provider 指向本代理。
57
+ * 托管整个 `provider.aicodeswitch` section(npm/name/options/models)
58
+ * 以及 `model` / `small_model` 两个模型选择字段。其余字段(其它 provider、
59
+ * agent、command、mcp、theme 等)一律保留给用户。
60
+ */
61
+ exports.OPENCODE_CONFIG_MANAGED_FIELDS = [
62
+ { path: ['provider', 'aicodeswitch'], isSection: true },
63
+ { path: ['model'] },
64
+ { path: ['small_model'], optional: true },
65
+ { path: ['mcp'], isSection: true, optional: true },
66
+ ];
53
67
  /**
54
68
  * 根据配置类型和文件路径获取管理字段列表
55
69
  */
@@ -70,6 +84,11 @@ const getManagedFields = (configType, filePath) => {
70
84
  return exports.CODEX_AUTH_MANAGED_FIELDS;
71
85
  }
72
86
  }
87
+ else if (configType === 'opencode') {
88
+ if (filePath.endsWith('opencode.json')) {
89
+ return exports.OPENCODE_CONFIG_MANAGED_FIELDS;
90
+ }
91
+ }
73
92
  return [];
74
93
  };
75
94
  exports.getManagedFields = getManagedFields;
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.cleanupInvalidMetadata = exports.checkCodexConfigStatus = exports.checkClaudeConfigStatus = exports.deleteMetadata = exports.loadMetadata = exports.saveMetadata = void 0;
6
+ exports.cleanupInvalidMetadata = exports.checkOpencodeConfigStatus = exports.getOpencodeConfigPath = exports.checkCodexConfigStatus = exports.checkClaudeConfigStatus = exports.deleteMetadata = exports.loadMetadata = exports.saveMetadata = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const os_1 = __importDefault(require("os"));
@@ -270,6 +270,83 @@ const checkCodexConfigStatus = () => {
270
270
  };
271
271
  };
272
272
  exports.checkCodexConfigStatus = checkCodexConfigStatus;
273
+ /**
274
+ * 检查 OpenCode 配置文件是否包含我们的代理特征
275
+ */
276
+ const isOpencodeProxyConfig = (filePath) => {
277
+ var _a, _b;
278
+ try {
279
+ if (!fs_1.default.existsSync(filePath)) {
280
+ return false;
281
+ }
282
+ const content = fs_1.default.readFileSync(filePath, 'utf-8');
283
+ const config = JSON.parse(content);
284
+ // 检查是否包含我们的自定义 provider(provider.aicodeswitch)
285
+ const provider = (_a = config === null || config === void 0 ? void 0 : config.provider) === null || _a === void 0 ? void 0 : _a.aicodeswitch;
286
+ if (provider && typeof provider === 'object') {
287
+ const baseUrl = (_b = provider.options) === null || _b === void 0 ? void 0 : _b.baseURL;
288
+ // baseURL 指向本代理的 /opencode 路径
289
+ if (baseUrl && typeof baseUrl === 'string' && /\/opencode(\/|$)/.test(baseUrl)) {
290
+ return true;
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+ catch (error) {
296
+ console.error(`Failed to check OpenCode config:`, error);
297
+ return false;
298
+ }
299
+ };
300
+ /**
301
+ * OpenCode 配置文件路径(~/.config/opencode/opencode.json)
302
+ */
303
+ const getOpencodeConfigPath = () => {
304
+ const homeDir = os_1.default.homedir();
305
+ return path_1.default.join(homeDir, '.config', 'opencode', 'opencode.json');
306
+ };
307
+ exports.getOpencodeConfigPath = getOpencodeConfigPath;
308
+ /**
309
+ * 检查 OpenCode 配置状态
310
+ */
311
+ const checkOpencodeConfigStatus = () => {
312
+ var _a;
313
+ const configPath = (0, exports.getOpencodeConfigPath)();
314
+ const configBakPath = `${configPath}.aicodeswitch_backup`;
315
+ // 检查备份文件是否存在
316
+ const hasBackup = fs_1.default.existsSync(configBakPath);
317
+ // 尝试加载元数据
318
+ const metadata = (0, exports.loadMetadata)('opencode');
319
+ if (metadata) {
320
+ const currentHash = calculateFileHash(configPath);
321
+ const isProxyConfig = isOpencodeProxyConfig(configPath);
322
+ let isModified = false;
323
+ if (currentHash && ((_a = metadata.files[0]) === null || _a === void 0 ? void 0 : _a.currentHash)) {
324
+ isModified = currentHash !== metadata.files[0].currentHash;
325
+ }
326
+ const isOverwritten = isProxyConfig;
327
+ return {
328
+ isOverwritten,
329
+ isModified,
330
+ hasBackup,
331
+ metadata
332
+ };
333
+ }
334
+ if (hasBackup) {
335
+ const isProxyConfig = isOpencodeProxyConfig(configPath);
336
+ return {
337
+ isOverwritten: isProxyConfig,
338
+ isModified: false,
339
+ hasBackup
340
+ };
341
+ }
342
+ const isProxyConfig = isOpencodeProxyConfig(configPath);
343
+ return {
344
+ isOverwritten: isProxyConfig,
345
+ isModified: false,
346
+ hasBackup: false
347
+ };
348
+ };
349
+ exports.checkOpencodeConfigStatus = checkOpencodeConfigStatus;
273
350
  /**
274
351
  * 清理无效的元数据
275
352
  * 当备份文件不存在但元数据存在时,说明状态不一致,需要清理
@@ -190,6 +190,7 @@ class FileSystemDatabaseManager {
190
190
  value: {
191
191
  'claude-code': { tool: 'claude-code', routeId: null },
192
192
  'codex': { tool: 'codex', routeId: null },
193
+ 'opencode': { tool: 'opencode', routeId: null },
193
194
  }
194
195
  });
195
196
  // 持久化统计数据
@@ -552,6 +553,7 @@ class FileSystemDatabaseManager {
552
553
  this.toolBindings = {
553
554
  'claude-code': parsed['claude-code'] || { tool: 'claude-code', routeId: null },
554
555
  'codex': parsed['codex'] || { tool: 'codex', routeId: null },
556
+ 'opencode': parsed['opencode'] || { tool: 'opencode', routeId: null },
555
557
  };
556
558
  }
557
559
  catch (_a) {
@@ -559,6 +561,7 @@ class FileSystemDatabaseManager {
559
561
  this.toolBindings = {
560
562
  'claude-code': { tool: 'claude-code', routeId: null },
561
563
  'codex': { tool: 'codex', routeId: null },
564
+ 'opencode': { tool: 'opencode', routeId: null },
562
565
  };
563
566
  }
564
567
  });
@@ -908,7 +911,7 @@ class FileSystemDatabaseManager {
908
911
  // Only migrate routes that are explicitly active and have a targetType
909
912
  if (route.isActive === true && route.targetType) {
910
913
  const tool = route.targetType;
911
- if (tool === 'claude-code' || tool === 'codex') {
914
+ if (tool === 'claude-code' || tool === 'codex' || tool === 'opencode') {
912
915
  // Only write if tool-bindings doesn't already have a binding
913
916
  // (avoid overwriting user's newer tool-binding choices)
914
917
  if (!((_a = this.toolBindings[tool]) === null || _a === void 0 ? void 0 : _a.routeId)) {