@yvhitxcel/opencode-remote 0.16.2 → 0.16.3

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.
@@ -26,6 +26,7 @@ export function loadConfig() {
26
26
 
27
27
  const appSecret = content.match(/FEISHU_APP_SECRET=(.+)/)?.[1]?.trim();
28
28
  if (appSecret) config.feishuAppSecret = appSecret;
29
+
29
30
  }
30
31
 
31
32
  // 2. Read ./.env (local project, lower priority)
@@ -56,6 +57,5 @@ export function loadConfig() {
56
57
  if (process.env.SESSION_IDLE_TIMEOUT_MS) config.sessionIdleTimeoutMs = parseInt(process.env.SESSION_IDLE_TIMEOUT_MS, 10);
57
58
  if (process.env.CLEANUP_INTERVAL_MS) config.cleanupIntervalMs = parseInt(process.env.CLEANUP_INTERVAL_MS, 10);
58
59
  if (process.env.APPROVAL_TIMEOUT_MS) config.approvalTimeoutMs = parseInt(process.env.APPROVAL_TIMEOUT_MS, 10);
59
-
60
60
  return config;
61
61
  }
@@ -1,6 +1,6 @@
1
1
  // Message router - full command definitions shared across all platforms
2
2
  import { registry } from './registry.js';
3
- import { initOpenCode, listProviders, updateGlobalModel, checkConnection, resumeSession, shareSession } from '../opencode/client.js';
3
+ import { initOpenCode, checkConnection, resumeSession, shareSession, setThreadModel, getThreadModel, getRecentModels, pushRecentModel } from '../opencode/client.js';
4
4
  import { formatTaskCompletion } from './notifications.js';
5
5
 
6
6
  const demoModeMap = new Map();
@@ -499,30 +499,68 @@ export async function routeMessage(parsed, ctx) {
499
499
 
500
500
  case 'model': {
501
501
  try {
502
+ const opencode = await initOpenCode();
503
+ if (!opencode) return '❌ OpenCode 不可用';
504
+
502
505
  if (parsed.arg) {
503
- const modelStr = parsed.arg.trim();
504
- const ok = await updateGlobalModel(modelStr);
505
- return ok ? `✅ 已切换模型至: ${modelStr}` : '❌ 切换失败';
506
+ const arg = parsed.arg.trim();
507
+
508
+ // Search mode: /model <keyword>
509
+ if (!arg.includes('/')) {
510
+ const result = await opencode.client.provider.list();
511
+ if (result.error || !result.data?.all) return '❌ 无法获取模型列表';
512
+ const q = arg.toLowerCase();
513
+ const matches = [];
514
+ for (const p of result.data.all) {
515
+ for (const mid of Object.keys(p.models || {})) {
516
+ const label = `${p.id}/${mid}`;
517
+ if (label.toLowerCase().includes(q)) {
518
+ matches.push(label);
519
+ }
520
+ }
521
+ }
522
+ if (matches.length === 0) return `🔍 未找到包含 "${arg}" 的模型`;
523
+ matches.sort().splice(30);
524
+ let msg = `🔍 搜索 "${arg}" (${matches.length > 30 ? '30+' : matches.length} 个):\n`;
525
+ for (const m of matches.slice(0, 30)) {
526
+ msg += ` ${m}\n`;
527
+ }
528
+ msg += '\n切换: /model <provider>/<modelID>';
529
+ return msg;
530
+ }
531
+
532
+ // Switch mode: /model <provider>/<modelID>
533
+ const entry = setThreadModel(ctx.threadId, arg);
534
+ if (entry) {
535
+ return `✅ 已切换模型至: ${entry.providerID}/${entry.modelID}`;
536
+ }
537
+ return '❌ 格式错误,请使用: /model <provider>/<modelID>';
506
538
  }
507
- const providers = await listProviders();
508
- if (!providers || providers.length === 0) return '❌ 无法获取模型列表';
509
- let msg = '🧠 可用模型:\n\n';
510
- for (const p of providers) {
511
- const modelIds = Object.keys(p.models || {});
512
- if (modelIds.length === 0) continue;
513
- msg += `${p.name} (${p.id}):\n`;
514
- for (const mid of modelIds.slice(0, 5)) {
515
- msg += ` ${p.id}/${mid}\n`;
539
+
540
+ // No arg: show current + recent
541
+ const current = getThreadModel(ctx.threadId);
542
+ let msg = current
543
+ ? `🧠 当前模型: ${current.providerID}/${current.modelID}\n━━━━━━━━━━━━━━━━\n\n`
544
+ : '━━━━━━━━━━━━━━━━\n\n';
545
+ const recent = getRecentModels();
546
+ if (recent.length > 0) {
547
+ msg += '最近使用:\n';
548
+ for (const r of recent) {
549
+ const mark = (current && r.providerID === current.providerID && r.modelID === current.modelID) ? ' ←' : '';
550
+ msg += ` ${r.providerID}/${r.modelID}${mark}\n`;
516
551
  }
517
- if (modelIds.length > 5) msg += ` ...还有 ${modelIds.length - 5} 个\n`;
552
+ msg += '\n';
553
+ }
554
+ if (!current) {
555
+ msg += '提示: 用 /model <关键词> 搜索模型,/model <provider>/<modelID> 切换\n';
556
+ } else {
557
+ msg += '用法:\n /model <关键词> — 搜索\n /model <provider>/<modelID> — 切换';
518
558
  }
519
- msg += '\n用法: /model <provider/model>';
520
559
  return msg;
521
560
  } catch (e) {
522
561
  return `❌ 模型操作失败: ${e.message}`;
523
562
  }
524
563
  }
525
-
526
564
  case 'diagnose': {
527
565
  const diag = ['🔍 诊断报告\n'];
528
566
  const qiniuOk = !!process.env.QINIU_ACCESS_KEY;
@@ -1,7 +1,7 @@
1
1
  import { getOrCreateSession } from '../core/session.js';
2
2
  import { splitMessage } from '../core/notifications.js';
3
3
  import { EMOJI } from '../core/types.js';
4
- import { initOpenCode, createSession, sendMessage, checkConnection, abortSession, resumeSession, revertSessionMessage, unrevertSession, listProviders, updateGlobalModel } from '../opencode/client.js';
4
+ import { initOpenCode, createSession, sendMessage, checkConnection, abortSession, resumeSession, revertSessionMessage, unrevertSession, setThreadModel, getThreadModel, getRecentModels } from '../opencode/client.js';
5
5
  import { claimOwnership } from '../core/auth.js';
6
6
  import { COMMAND_ALIASES, detectCommand, getHelpText, DEMO_RESPONSES, setDemoMode, isDemoMode } from '../core/router.js';
7
7
  import { registry } from '../core/registry.js';
@@ -110,43 +110,77 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
110
110
  try {
111
111
  if (arg) {
112
112
  const modelStr = arg.trim();
113
- const ok = await updateGlobalModel(modelStr);
114
- if (ok) {
115
- const parts = modelStr.split('/');
116
- if (parts.length === 2) {
117
- session.modelOverride = { providerID: parts[0], modelID: parts[1] };
113
+
114
+ // Search mode: /model <keyword>
115
+ if (!modelStr.includes('/')) {
116
+ const opencode = await initOpenCode();
117
+ if (!opencode) {
118
+ await adapter.reply(ctx.threadId, '❌ OpenCode 不可用');
119
+ return true;
120
+ }
121
+ const result = await opencode.client.provider.list();
122
+ if (result.error || !result.data?.all) {
123
+ await adapter.reply(ctx.threadId, '❌ 无法获取模型列表');
124
+ return true;
125
+ }
126
+ const q = modelStr.toLowerCase();
127
+ const matches = [];
128
+ for (const p of result.data.all) {
129
+ for (const mid of Object.keys(p.models || {})) {
130
+ if (`${p.id}/${mid}`.toLowerCase().includes(q)) {
131
+ matches.push(`${p.id}/${mid}`);
132
+ }
133
+ }
134
+ }
135
+ if (matches.length === 0) {
136
+ await adapter.reply(ctx.threadId, `🔍 未找到包含 "${modelStr}" 的模型`);
137
+ return true;
118
138
  }
119
- await adapter.reply(ctx.threadId, `✅ 已切换模型至: ${modelStr}`);
139
+ matches.sort();
140
+ let msg = `🔍 搜索 "${modelStr}" (${matches.length} 个):\n`;
141
+ for (const m of matches.slice(0, 30)) {
142
+ msg += ` ${m}\n`;
143
+ }
144
+ msg += '\n切换: /model <provider>/<modelID>';
145
+ const msgs = splitMessage(msg);
146
+ for (const m of msgs) await adapter.reply(ctx.threadId, m);
147
+ return true;
148
+ }
149
+
150
+ const entry = setThreadModel(ctx.threadId, modelStr);
151
+ if (entry) {
152
+ await adapter.reply(ctx.threadId, `✅ 已切换模型至: ${entry.providerID}/${entry.modelID}`);
120
153
  } else {
121
- await adapter.reply(ctx.threadId, '❌ 切换模型失败,请检查模型名称是否正确');
154
+ await adapter.reply(ctx.threadId, '❌ 格式错误,请使用: /model <provider>/<modelID>');
122
155
  }
123
156
  return true;
124
157
  }
125
- const providers = await listProviders();
126
- if (!providers || providers.length === 0) {
127
- await adapter.reply(ctx.threadId, '❌ 无法获取模型列表');
128
- return true;
129
- }
130
- let msg = '🧠 可用模型:\n\n';
131
- for (const p of providers) {
132
- const modelIds = Object.keys(p.models || {});
133
- if (modelIds.length === 0) continue;
134
- msg += `${p.name} (${p.id}):\n`;
135
- for (const mid of modelIds.slice(0, 5)) {
136
- msg += ` ${p.id}/${mid}\n`;
158
+ const current = getThreadModel(ctx.threadId);
159
+ let msg = current
160
+ ? `🧠 当前模型: ${current.providerID}/${current.modelID}\n\n`
161
+ : '';
162
+
163
+ const recent = getRecentModels();
164
+ if (recent.length > 0) {
165
+ msg += '最近使用:\n';
166
+ for (const r of recent) {
167
+ const mark = (current && r.providerID === current.providerID && r.modelID === current.modelID) ? ' ←' : '';
168
+ msg += ` ${r.providerID}/${r.modelID}${mark}\n`;
137
169
  }
138
- if (modelIds.length > 5) msg += ` ...还有 ${modelIds.length - 5} 个\n`;
139
170
  msg += '\n';
140
171
  }
141
- msg += '用法: /model <provider/model>';
142
- const msgs = splitMessage(msg);
143
- for (const m of msgs) {
144
- await adapter.reply(ctx.threadId, m);
172
+ if (!current) {
173
+ msg += '提示: 用 /model <关键词> 搜索模型,/model <provider>/<modelID> 切换\n';
174
+ } else {
175
+ msg += '用法: /model <关键词> — 搜索\n /model <provider>/<modelID> — 切换';
145
176
  }
177
+ const msgs = splitMessage(msg);
178
+ for (const m of msgs) await adapter.reply(ctx.threadId, m);
179
+ return true;
146
180
  } catch (e) {
147
181
  await adapter.reply(ctx.threadId, `❌ 模型操作失败: ${e.message}`);
182
+ return true;
148
183
  }
149
- return true;
150
184
  }
151
185
  case 'oc':
152
186
  case 'cc':
@@ -292,7 +292,7 @@ async function forwardToOpenCode(adapter, ctx, text, openCodeSessions, session,
292
292
  adapter.updateMessage(ctx.threadId, '', `⏳ 重试中 (${status.attempt})...`).catch(() => {});
293
293
  }
294
294
  },
295
- });
295
+ }, ctx.threadId);
296
296
  session.taskStartTime = null;
297
297
  session.currentTool = null;
298
298
  if (session.modifiedFiles instanceof Set) {
@@ -11,6 +11,41 @@ import { homedir } from 'os';
11
11
  const CONFIG_DIR = join(homedir(), '.opencode-remote');
12
12
  const CONFIG_FILE = join(CONFIG_DIR, '.env');
13
13
 
14
+ const threadModels = new Map();
15
+ const recentModels = [];
16
+
17
+ export function setThreadModel(threadId, modelStr) {
18
+ if (!modelStr || !modelStr.includes('/')) {
19
+ threadModels.delete(threadId);
20
+ return null;
21
+ }
22
+ const parts = modelStr.split('/');
23
+ const entry = { providerID: parts[0], modelID: parts.slice(1).join('/') };
24
+ threadModels.set(threadId, entry);
25
+ pushRecent(entry);
26
+ return entry;
27
+ }
28
+
29
+ export function getThreadModel(threadId) {
30
+ return threadModels.get(threadId);
31
+ }
32
+
33
+ export function getRecentModels() {
34
+ return [...recentModels];
35
+ }
36
+
37
+ export function pushRecentModel(entry) {
38
+ pushRecent(entry);
39
+ }
40
+
41
+ function pushRecent(entry) {
42
+ const key = `${entry.providerID}/${entry.modelID}`;
43
+ const idx = recentModels.findIndex(e => `${e.providerID}/${e.modelID}` === key);
44
+ if (idx !== -1) recentModels.splice(idx, 1);
45
+ recentModels.unshift(entry);
46
+ if (recentModels.length > 5) recentModels.length = 5;
47
+ }
48
+
14
49
  // Find opencode.exe binary
15
50
  function findOpenCodeExe() {
16
51
  const isWindows = platform() === 'win32';
@@ -349,7 +384,7 @@ export async function createSession(_threadId, title = `Remote control session`)
349
384
  }
350
385
  }
351
386
  // Send message - use promptAsync then poll for response
352
- export async function sendMessage(session, message, callbacks) {
387
+ export async function sendMessage(session, message, callbacks, threadId) {
353
388
  const TIMEOUT_MS = 5 * 60 * 1000; // 5 minute timeout
354
389
  const POLL_INTERVAL = 2000; // 2 seconds between polls
355
390
 
@@ -381,6 +416,11 @@ export async function sendMessage(session, message, callbacks) {
381
416
  const promptBody = {
382
417
  parts: [{ type: 'text', text: message }]
383
418
  };
419
+ // Inject local model preference if set
420
+ if (threadId && threadModels.has(threadId)) {
421
+ session.model = threadModels.get(threadId);
422
+ pushRecent(session.model);
423
+ }
384
424
  // Per-message model override if set on session
385
425
  if (session.model?.providerID && session.model?.modelID) {
386
426
  promptBody.model = {
@@ -22,7 +22,7 @@ export class OpenCodeAgentAdapter {
22
22
  });
23
23
  }
24
24
 
25
- async sendPrompt(_sessionId, prompt, history) {
25
+ async sendPrompt(_sessionId, prompt, history, options = {}) {
26
26
  const contextualPrompt = this.buildContextualPrompt(prompt, history);
27
27
  return this.callOpenCode(contextualPrompt);
28
28
  }
@@ -45,7 +45,7 @@ export class OpenCodeAgentAdapter {
45
45
  .find(l => /Error|error|ERROR|^\d{3}/.test(l));
46
46
  return first || null;
47
47
  }
48
-
48
+
49
49
  callOpenCode(prompt) {
50
50
  return new Promise((resolve) => {
51
51
  const proc = spawn('opencode', ['run', '--format', 'json', prompt], {
@@ -56,11 +56,33 @@ export class OpenCodeAgentAdapter {
56
56
  let stdout = '';
57
57
  let stderr = '';
58
58
  let fullText = '';
59
+ let resigned = false;
60
+
61
+ const STUCK_PATTERNS = [
62
+ 'Free usage exceeded', 'quota exceeded', 'rate limit',
63
+ 'retrying in', 'retry attempt',
64
+ '429', '401', '403', '402', 'Payment Required',
65
+ 'subscription required', 'insufficient_quota',
66
+ ];
67
+
68
+ const checkStuck = (stderrText) => {
69
+ if (resigned) return;
70
+ for (const pattern of STUCK_PATTERNS) {
71
+ if (stderrText.toLowerCase().includes(pattern.toLowerCase())) {
72
+ resigned = true;
73
+ proc.kill();
74
+ const detail = this.extractErrorMessage('', stderrText);
75
+ resolve(`❌ OpenCode 无法继续: ${detail || pattern}`);
76
+ return true;
77
+ }
78
+ }
79
+ return false;
80
+ };
59
81
 
60
82
  proc.stdout?.on('data', (data) => {
61
- stdout += data.toString();
62
- const lines = stdout.split('\n');
63
- stdout = lines.pop() || '';
83
+ const chunk = data.toString();
84
+ stdout += chunk;
85
+ const lines = chunk.split('\n');
64
86
  for (const line of lines) {
65
87
  if (!line.trim()) continue;
66
88
  try {
@@ -70,15 +92,19 @@ export class OpenCodeAgentAdapter {
70
92
  }
71
93
  });
72
94
 
73
- proc.stderr?.on('data', (data) => { stderr += data.toString(); });
95
+ proc.stderr?.on('data', (data) => {
96
+ stderr += data.toString();
97
+ checkStuck(stderr);
98
+ });
74
99
 
75
100
  proc.on('close', (code) => {
101
+ if (resigned) return;
76
102
  if (code !== 0) {
77
103
  const detail = this.extractErrorMessage(stdout, stderr);
78
104
  const hint = detail
79
105
  ? `: ${detail}`
80
106
  : '。请运行 `opencode auth login` 配置认证。';
81
- resolve(`❌ OpenCode 错误${hint}`);
107
+ resolve(`❌ OpenCode 错误 (exit code ${code})${hint}`);
82
108
  } else {
83
109
  resolve(fullText || '完成');
84
110
  }
@@ -1,7 +1,7 @@
1
1
  import { detectCommand, COMMAND_ALIASES, getHelpText, DEMO_RESPONSES, setDemoMode, isDemoMode } from '../core/router.js';
2
2
  import { getOrCreateSession, saveSessionMapping, sessionManager } from '../core/session.js';
3
3
  import { splitMessage } from '../core/notifications.js';
4
- import { initOpenCode, checkConnection, abortSession, resumeSession, revertSessionMessage, unrevertSession, listProviders, updateGlobalModel } from '../opencode/client.js';
4
+ import { initOpenCode, checkConnection, abortSession, resumeSession, revertSessionMessage, unrevertSession, setThreadModel, getThreadModel, getRecentModels } from '../opencode/client.js';
5
5
  import { claimOwnership } from '../core/auth.js';
6
6
  import { registry } from '../core/registry.js';
7
7
  import { uploadToQiniu, findBuildOutputs, formatSize, deleteFromQiniu } from '../core/qiniu.js';
@@ -658,58 +658,82 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
658
658
 
659
659
  case 'model': {
660
660
  try {
661
- const opencode = await initOpenCode();
662
- if (!opencode) {
663
- await adapter.reply(ctx.threadId, '❌ 无法连接 OpenCode');
664
- return true;
665
- }
666
661
  if (arg) {
667
662
  const modelStr = arg.trim();
668
- const ok = await updateGlobalModel(modelStr);
669
- if (ok) {
670
- const parts = modelStr.split('/');
671
- if (parts.length === 2) {
672
- session.modelOverride = { providerID: parts[0], modelID: parts[1] };
663
+
664
+ // Search mode: /model <keyword>
665
+ if (!modelStr.includes('/')) {
666
+ const opencode = await initOpenCode();
667
+ if (!opencode) {
668
+ await adapter.reply(ctx.threadId, '❌ OpenCode 不可用');
669
+ return true;
670
+ }
671
+ const result = await opencode.client.provider.list();
672
+ if (result.error || !result.data?.all) {
673
+ await adapter.reply(ctx.threadId, '❌ 无法获取模型列表');
674
+ return true;
675
+ }
676
+ const q = modelStr.toLowerCase();
677
+ const matches = [];
678
+ for (const p of result.data.all) {
679
+ for (const mid of Object.keys(p.models || {})) {
680
+ if (`${p.id}/${mid}`.toLowerCase().includes(q)) {
681
+ matches.push(`${p.id}/${mid}`);
682
+ }
683
+ }
684
+ }
685
+ if (matches.length === 0) {
686
+ await adapter.reply(ctx.threadId, `🔍 未找到包含 "${modelStr}" 的模型`);
687
+ return true;
673
688
  }
674
- await adapter.reply(ctx.threadId, `✅ 已切换模型至: ${modelStr}`);
689
+ matches.sort();
690
+ let msg = `🔍 搜索 "${modelStr}" (${matches.length} 个):\n`;
691
+ for (const m of matches.slice(0, 30)) {
692
+ msg += ` ${m}\n`;
693
+ }
694
+ msg += '\n切换: /model <provider>/<modelID>';
695
+ const msgs = splitMessage(msg);
696
+ for (const m of msgs) await adapter.reply(ctx.threadId, m);
697
+ return true;
698
+ }
699
+
700
+ const entry = setThreadModel(ctx.threadId, modelStr);
701
+ if (entry) {
702
+ await adapter.reply(ctx.threadId, `✅ 已切换模型至: ${entry.providerID}/${entry.modelID}`);
675
703
  } else {
676
- await adapter.reply(ctx.threadId, `❌ 切换模型失败,请检查模型名称是否正确`);
704
+ await adapter.reply(ctx.threadId, '❌ 格式错误,请使用: /model <provider>/<modelID>');
677
705
  }
678
706
  return true;
679
707
  }
680
- const providers = await listProviders();
681
- if (!providers || providers.length === 0) {
682
- await adapter.reply(ctx.threadId, '❌ 无法获取模型列表');
683
- return true;
684
- }
685
- let msg = '🧠 可用模型:\n\n';
686
- for (const p of providers) {
687
- const modelIds = Object.keys(p.models || {});
688
- if (modelIds.length === 0) continue;
689
- msg += `${p.name} (${p.id}):\n`;
690
- for (const mid of modelIds.slice(0, 8)) {
691
- const m = p.models[mid];
692
- const tags = [];
693
- if (m.reasoning) tags.push('推理');
694
- if (m.attachment) tags.push('附件');
695
- msg += ` ${p.id}/${mid}${tags.length ? ` (${tags.join(', ')})` : ''}\n`;
708
+ const current = getThreadModel(ctx.threadId);
709
+ let msg = current
710
+ ? `🧠 当前模型: ${current.providerID}/${current.modelID}\n\n`
711
+ : '';
712
+
713
+ const recent = getRecentModels();
714
+ if (recent.length > 0) {
715
+ msg += '最近使用:\n';
716
+ for (const r of recent) {
717
+ const mark = (current && r.providerID === current.providerID && r.modelID === current.modelID) ? ' ←' : '';
718
+ msg += ` ${r.providerID}/${r.modelID}${mark}\n`;
696
719
  }
697
- if (modelIds.length > 8) msg += ` ...还有 ${modelIds.length - 8} 个\n`;
698
720
  msg += '\n';
699
721
  }
700
- msg += '用法: /model <provider/model>';
701
- const msgs = splitMessage(msg);
702
- for (const m of msgs) {
703
- await adapter.reply(ctx.threadId, m);
722
+ if (!current) {
723
+ msg += '提示: 用 /model <关键词> 搜索模型,/model <provider>/<modelID> 切换\n';
724
+ } else {
725
+ msg += '用法: /model <关键词> — 搜索\n /model <provider>/<modelID> — 切换';
704
726
  }
727
+ const msgs = splitMessage(msg);
728
+ for (const m of msgs) await adapter.reply(ctx.threadId, m);
729
+ return true;
705
730
  } catch (e) {
706
731
  await adapter.reply(ctx.threadId, `❌ 模型操作失败: ${e.message}`);
732
+ return true;
707
733
  }
708
- return true;
709
734
  }
710
735
 
711
736
 
712
-
713
737
  case 'demo': {
714
738
  const argText = (arg || '').trim().toLowerCase();
715
739
  if (argText === 'off' || argText === 'exit' || argText === 'stop') {
@@ -138,7 +138,7 @@ async function forwardToOpenCode(adapter, ctx, text, openCodeSessions, session,
138
138
  onStatusChange: (status) => {
139
139
  if (status.hasToolActivity) hasToolActivity = true;
140
140
  },
141
- }).catch((e) => {
141
+ }, ctx.threadId).catch((e) => {
142
142
  console.error('[forwardToOpenCode] Task error:', e.message);
143
143
  return '';
144
144
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yvhitxcel/opencode-remote",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "description": "🤖 AI 专家团队随时待命!只需输入 /z,自动分析项目、诊断问题、给出改进方案。支持微信/飞书/Telegram 远程控制 OpenCode、Claude Code、Codex、Copilot。手机也能搞开发。",
5
5
  "type": "module",
6
6
  "bin": {