@yvhitxcel/opencode-remote 0.17.0 → 0.18.0

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.
@@ -7,13 +7,35 @@ function createWeixinAdapter(baseUrl, token, botId) {
7
7
  const processedMessages = new Map();
8
8
  const DEDUP_WINDOW_MS = 30_000;
9
9
 
10
- function isDuplicate(messageId) {
11
- if (!messageId) return false;
12
- const seenAt = processedMessages.get(messageId);
13
- if (seenAt && Date.now() - seenAt < DEDUP_WINDOW_MS) return true;
14
- processedMessages.set(messageId, Date.now());
10
+ // 定期清理 contextTokens 和 typingTickets,防内存泄漏
11
+ const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
12
+ let cleanupTimer = null;
13
+ function startCleanup() {
14
+ if (cleanupTimer) return;
15
+ cleanupTimer = setInterval(() => {
16
+ const cutoff = Date.now() - CLEANUP_INTERVAL_MS;
17
+ for (const [k, v] of contextTokens) { if (typeof v !== 'object' || !v._ts || v._ts < cutoff) contextTokens.delete(k); }
18
+ for (const [k, v] of typingTickets) { if (typeof v !== 'object' || !v._ts || v._ts < cutoff) typingTickets.delete(k); }
19
+ if (processedMessages.size > 1000) {
20
+ const now = Date.now();
21
+ for (const [id, ts] of processedMessages.entries()) {
22
+ if (now - ts > DEDUP_WINDOW_MS) processedMessages.delete(id);
23
+ }
24
+ }
25
+ }, CLEANUP_INTERVAL_MS);
26
+ if (cleanupTimer.unref) cleanupTimer.unref();
27
+ }
28
+ startCleanup();
29
+
30
+ function isDuplicate(messageId, contentKey) {
31
+ // 优先用内容去重: 微信两条消息可能 messageId 不同但内容相同
32
+ const key = contentKey || messageId;
33
+ if (!key) return false;
34
+ const now = Date.now();
35
+ const seenAt = processedMessages.get(key);
36
+ if (seenAt && now - seenAt < DEDUP_WINDOW_MS) return true;
37
+ processedMessages.set(key, now);
15
38
  if (processedMessages.size > 1000) {
16
- const now = Date.now();
17
39
  for (const [id, ts] of processedMessages.entries()) {
18
40
  if (now - ts > DEDUP_WINDOW_MS) processedMessages.delete(id);
19
41
  }
@@ -29,7 +51,8 @@ function createWeixinAdapter(baseUrl, token, botId) {
29
51
  _token: token,
30
52
  _botId: botId,
31
53
  async reply(threadId, text) {
32
- let contextToken = contextTokens.get(threadId);
54
+ let entry = contextTokens.get(threadId);
55
+ let contextToken = entry?.value || entry;
33
56
  let retryCount = 0;
34
57
  const maxRetries = 2;
35
58
 
@@ -40,7 +63,7 @@ function createWeixinAdapter(baseUrl, token, botId) {
40
63
  const r = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: undefined });
41
64
  contextToken = r.context_token || r.typing_ticket;
42
65
  if (contextToken) {
43
- contextTokens.set(threadId, contextToken);
66
+ contextTokens.set(threadId, { value: contextToken, _ts: Date.now() });
44
67
  console.log(`[Weixin] Got contextToken: ${contextToken.slice(0, 8)}...`);
45
68
  } else if (r.errcode === -14) {
46
69
  console.log(`[Weixin] Session timeout, retrying with fresh token...`);
@@ -88,24 +111,24 @@ function createWeixinAdapter(baseUrl, token, botId) {
88
111
  throw err;
89
112
  },
90
113
  async sendTypingIndicator(threadId) {
91
- const cachedTicket = typingTickets.get(threadId);
92
- let ticket = cachedTicket;
114
+ const entry = typingTickets.get(threadId);
115
+ let ticket = entry?.value || entry;
93
116
 
94
117
  if (!ticket) {
95
118
  try {
96
- const r = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: contextTokens.get(threadId) });
119
+ const r = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: (contextTokens.get(threadId) || {}).value });
97
120
  if (r.errcode === -14) {
98
121
  contextTokens.delete(threadId);
99
122
  typingTickets.delete(threadId);
100
123
  const freshConfig = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: undefined });
101
124
  ticket = freshConfig.typing_ticket;
102
125
  if (freshConfig.context_token) {
103
- contextTokens.set(threadId, freshConfig.context_token);
126
+ contextTokens.set(threadId, { value: freshConfig.context_token, _ts: Date.now() });
104
127
  }
105
128
  } else {
106
129
  ticket = r.typing_ticket;
107
130
  }
108
- if (ticket) typingTickets.set(threadId, ticket);
131
+ if (ticket) typingTickets.set(threadId, { value: ticket, _ts: Date.now() });
109
132
  } catch { console.debug('[typing] getConfig failed'); }
110
133
  }
111
134
  if (ticket) {
@@ -118,7 +141,7 @@ function createWeixinAdapter(baseUrl, token, botId) {
118
141
  try {
119
142
  const freshConfig = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: undefined });
120
143
  if (freshConfig.typing_ticket) {
121
- typingTickets.set(threadId, freshConfig.typing_ticket);
144
+ typingTickets.set(threadId, { value: freshConfig.typing_ticket, _ts: Date.now() });
122
145
  await sendTyping({ baseUrl, token, body: { ilink_user_id: threadId, typing_ticket: freshConfig.typing_ticket, status: 1 } });
123
146
  }
124
147
  } catch { console.debug('[typing] retry getConfig failed'); }
@@ -132,4 +155,3 @@ function createWeixinAdapter(baseUrl, token, botId) {
132
155
  }
133
156
 
134
157
  export { createWeixinAdapter };
135
- export default createWeixinAdapter;
@@ -129,26 +129,47 @@ export async function getUpdates(params) {
129
129
  }
130
130
  }
131
131
  /**
132
- * Send a message
132
+ * Send a message with rate limit retry
133
133
  */
134
134
  export async function sendMessage(params) {
135
- const rawText = await apiFetch({
136
- baseUrl: params.baseUrl,
137
- endpoint: 'ilink/bot/sendmessage',
138
- body: JSON.stringify({
139
- ...params.body,
140
- base_info: { channel_version: '1.0.0' },
141
- }),
142
- token: params.token,
143
- timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
144
- label: 'sendMessage',
145
- });
146
- try {
147
- JSON.parse(rawText);
148
- }
149
- catch (e) {
150
- console.debug('[api] Non-JSON response:', e.message);
135
+ const MAX_ATTEMPTS = 3;
136
+ let lastErr;
137
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
138
+ try {
139
+ const rawText = await apiFetch({
140
+ baseUrl: params.baseUrl,
141
+ endpoint: 'ilink/bot/sendmessage',
142
+ body: JSON.stringify({
143
+ ...params.body,
144
+ base_info: { channel_version: '1.0.0' },
145
+ }),
146
+ token: params.token,
147
+ timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
148
+ label: 'sendMessage',
149
+ });
150
+ // 微信返回限流错误码时,rawText errcode,重试
151
+ try {
152
+ const j = JSON.parse(rawText);
153
+ if (j && typeof j.errcode === 'number' && (j.errcode === -1001 || j.errcode === -1002 || j.errcode === 45009 || j.errcode === 45047)) {
154
+ const backoff = 1000 * attempt;
155
+ console.warn(`[sendMessage] rate-limited (errcode=${j.errcode}), retry in ${backoff}ms (${attempt}/${MAX_ATTEMPTS})`);
156
+ await new Promise(r => setTimeout(r, backoff));
157
+ continue;
158
+ }
159
+ } catch (e) { console.debug('[sendMessage] parse error:', e.message); }
160
+ return;
161
+ } catch (err) {
162
+ lastErr = err;
163
+ if (attempt < MAX_ATTEMPTS && /AbortError|fetch failed|ECONNRESET/i.test(err.message || '')) {
164
+ const backoff = 1000 * attempt;
165
+ console.warn(`[sendMessage] transient error, retry in ${backoff}ms: ${err.message}`);
166
+ await new Promise(r => setTimeout(r, backoff));
167
+ continue;
168
+ }
169
+ throw err;
170
+ }
151
171
  }
172
+ throw lastErr;
152
173
  }
153
174
  /**
154
175
  * Get bot config (includes typing_ticket)
@@ -9,8 +9,22 @@ import { DEFAULT_BASE_URL } from './types.js';
9
9
  import { createWeixinAdapter } from './adapter.js';
10
10
  import { handleMessage } from './handler.js';
11
11
  import { userAdapterMap } from './user-adapter-map.js';
12
+ import { initState } from '../core/state.js';
13
+ import { initLogger, cleanOldLogs, logger } from '../core/log.js';
14
+ import { LRUSessionMap } from '../core/lru.js';
15
+ import { encryptCredential, decryptCredential } from '../core/crypto.js';
12
16
  export { COMMAND_ALIASES, detectCommand } from '../core/router.js';
13
17
 
18
+ let _initialized = false;
19
+ function initBot() {
20
+ if (_initialized) return;
21
+ _initialized = true;
22
+ initLogger();
23
+ cleanOldLogs();
24
+ initState();
25
+ logger.info('Bot starting', { ts: new Date().toISOString() });
26
+ }
27
+
14
28
  const CONFIG_DIR = join(homedir(), '.opencode-remote');
15
29
  const WEIXIN_DIR = join(CONFIG_DIR, 'weixin');
16
30
  const CREDENTIALS_DIR = join(WEIXIN_DIR, 'credentials');
@@ -18,7 +32,6 @@ const INSTANCE_ID = process.env.OPENCODE_INSTANCE_ID || 'default';
18
32
  const CREDENTIALS_FILE = INSTANCE_ID === 'default'
19
33
  ? join(WEIXIN_DIR, 'credentials.json')
20
34
  : join(WEIXIN_DIR, `credentials-${INSTANCE_ID}.json`);
21
- const RESTART_NOTIFY_FILE = join(WEIXIN_DIR, 'restart-notify.json');
22
35
 
23
36
  const botInstances = [];
24
37
 
@@ -62,7 +75,16 @@ export function loadAllCredentials() {
62
75
  const files = readdirSync(CREDENTIALS_DIR).filter(f => f.endsWith('.json'));
63
76
  if (files.length > 0) {
64
77
  return files.map(f => {
65
- try { return JSON.parse(readFileSync(join(CREDENTIALS_DIR, f), 'utf-8')); }
78
+ try {
79
+ const raw = readFileSync(join(CREDENTIALS_DIR, f), 'utf-8');
80
+ const obj = JSON.parse(raw);
81
+ // 检测是否加密信封 → 解密
82
+ if (obj && obj.v === 1 && obj.enc) {
83
+ const decrypted = decryptCredential(obj.enc);
84
+ if (decrypted) return JSON.parse(decrypted);
85
+ }
86
+ return obj;
87
+ }
66
88
  catch (e) { console.debug('[credentials] Failed to parse:', f, e.message); return null; }
67
89
  }).filter(Boolean);
68
90
  }
@@ -81,7 +103,10 @@ export function loadWeixinCredentials() {
81
103
  export function saveWeixinCredentials(creds) {
82
104
  ensureDirs();
83
105
  const filePath = join(CREDENTIALS_DIR, `credentials-${creds.accountId}.json`);
84
- writeFileSync(filePath, JSON.stringify({ ...creds, savedAt: new Date().toISOString() }, null, 2), 'utf-8');
106
+ const plain = JSON.stringify({ ...creds, savedAt: new Date().toISOString() }, null, 2);
107
+ const enc = encryptCredential(plain);
108
+ const envelope = { v: 1, enc, savedAt: new Date().toISOString() };
109
+ writeFileSync(filePath, JSON.stringify(envelope, null, 2), 'utf-8');
85
110
  try { const s = statSync(filePath); chmodSync(filePath, (s.mode & 0o777) | 0o600); } catch (e) { console.warn('[credentials] chmod failed:', e.message); }
86
111
  }
87
112
 
@@ -108,9 +133,9 @@ async function runPollingLoop(adapter, baseUrl, token, openCodeSessions, signal)
108
133
  if (!fromUserId || !text) continue;
109
134
  userAdapterMap.set(fromUserId, adapter);
110
135
  const messageId = msg.message_id?.toString();
111
- if (adapter.isDuplicate(messageId)) continue;
112
- if (msg.context_token) adapter.contextTokens.set(fromUserId, msg.context_token);
113
- handleMessage(adapter, { platform: 'weixin', threadId: fromUserId, userId: fromUserId, messageId }, text, openCodeSessions).catch(e => console.error('Handle error:', e));
136
+ if (adapter.isDuplicate(messageId, `${fromUserId}:${text}`)) continue;
137
+ if (msg.context_token) adapter.contextTokens.set(fromUserId, { value: msg.context_token, _ts: Date.now() });
138
+ try { await handleMessage(adapter, { platform: 'weixin', threadId: fromUserId, userId: fromUserId, messageId }, text, openCodeSessions); } catch (e) { console.error('Handle error:', e); }
114
139
  }
115
140
  } catch (e) {
116
141
  if (signal.aborted) break;
@@ -142,6 +167,9 @@ export function addBotInstance(creds, openCodeSessions) {
142
167
  }
143
168
 
144
169
  export async function startWeixinBot(botConfig, restartFn) {
170
+ // 仅在子进程启动时初始化日志和状态 (不在父进程 import 时)
171
+ initBot();
172
+ process.on('unhandledRejection', (reason) => { console.error('[bot] Unhandled Rejection:', reason); });
145
173
  if (restartFn) _restartCallback = restartFn;
146
174
  console.log('');
147
175
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
@@ -160,25 +188,32 @@ export async function startWeixinBot(botConfig, restartFn) {
160
188
  }
161
189
  const firstCreds = credentialsList[0];
162
190
  console.log(`Using account: ${firstCreds.accountId}${credentialsList.length > 1 ? ` (+${credentialsList.length - 1} more)` : ''}`);
163
- const openCodeSessions = new Map();
164
-
165
- try { await initOpenCode(); console.log('OpenCode ready'); } catch (e) { console.error('Failed to init OpenCode:', e); }
191
+ const openCodeSessions = new LRUSessionMap({ maxSize: 100, ttlMs: 30 * 60 * 1000, name: 'opencode-sessions' });
192
+ // 定期清理过期 session (每 5 分钟)
193
+ setInterval(() => openCodeSessions.cleanup(), 5 * 60 * 1000).unref();
166
194
 
195
+ let opencodeServer = null;
167
196
  try {
168
197
  const opencode = await initOpenCode();
169
198
  if (opencode) {
199
+ opencodeServer = opencode.server;
200
+ globalThis.__opencodeServer = opencode.server;
201
+ console.log('OpenCode ready');
170
202
  const result = await opencode.client.session.list();
171
203
  if (!result.error && result.data && result.data.length > 0) {
172
204
  const sorted = result.data.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
173
205
  const latest = sorted[0];
174
206
  console.log(`Latest OpenCode session: ${latest.title || 'Untitled'} (${latest.id.slice(0, 8)}...)`);
175
207
  if (latest.directory) {
176
- console.log(`Project directory: ${latest.directory}`);
177
- globalThis.__autoProjectDir = latest.directory;
208
+ // resume 的目录可能指向 bot 自身而非 openchat 项目
209
+ // 如果 resume 目录没 lab.mjs,尝试 fallback 到 openchat 项目
210
+ const projectDir = existsSync(`${latest.directory}/bridge/bin/lab.mjs`) ? latest.directory : (existsSync('F:\\openchat\\bridge\\bin\\lab.mjs') ? 'F:\\openchat' : latest.directory);
211
+ console.log(`Project directory: ${projectDir}`);
212
+ globalThis.__autoProjectDir = projectDir;
178
213
  }
179
214
  }
180
215
  }
181
- } catch (e) { console.warn('⚠️ Auto-resume failed:', e.message); }
216
+ } catch (e) { console.error('Failed to init OpenCode:', e); }
182
217
 
183
218
  if (!getAuthStatus().weixin) {
184
219
  console.log('\n🔒 Bot not secured! First user to send /start becomes owner.\n');
@@ -189,16 +224,9 @@ export async function startWeixinBot(botConfig, restartFn) {
189
224
  addBotInstance(creds, openCodeSessions);
190
225
  }
191
226
 
192
- const firstAdapter = botInstances[0]?.adapter;
193
- try {
194
- if (existsSync(RESTART_NOTIFY_FILE)) {
195
- const data = JSON.parse(readFileSync(RESTART_NOTIFY_FILE, 'utf8'));
196
- if (data.threadId && Date.now() - data.time < 60000 && firstAdapter) {
197
- await firstAdapter.reply(data.threadId, '✅ Bot 重启完成!');
198
- }
199
- unlinkSync(RESTART_NOTIFY_FILE);
200
- }
201
- } catch (e) { console.warn('[restart-notify] Failed to read restart file:', e.message); }
227
+ // IPC 心跳:每 30s 通知父进程还活着
228
+ const hbTimer = setInterval(() => { try { process.send?.({ type: 'heartbeat', ts: Date.now() }); } catch {} }, 30_000);
229
+ if (hbTimer.unref) hbTimer.unref();
202
230
 
203
231
  let shouldRestart = false;
204
232
  const shutdown = (restart = false) => {
@@ -207,7 +235,8 @@ export async function startWeixinBot(botConfig, restartFn) {
207
235
  for (const instance of botInstances) {
208
236
  try { instance.abortController.abort(); } catch (e) { }
209
237
  }
210
- for (const [, s] of openCodeSessions.entries()) { try { s.server?.shutdown?.(); } catch (e) { console.warn('[shutdown] Server shutdown error:', e.message); } }
238
+ // 关掉 opencode server 进程,防重启后端口冲突
239
+ try { globalThis.__opencodeServer?.kill?.(); } catch (e) { console.warn('[shutdown] Server kill error:', e.message); }
211
240
  openCodeSessions.clear();
212
241
  };
213
242
 
@@ -216,6 +245,7 @@ export async function startWeixinBot(botConfig, restartFn) {
216
245
 
217
246
  if (process.env.OPENCODE_RESTART === '1') {
218
247
  try {
248
+ const firstAdapter = botInstances[0]?.adapter;
219
249
  const restartInfoPath = join(process.env.HOME || process.cwd(), '.opencode-remote', '.restart_user.json');
220
250
  if (existsSync(restartInfoPath)) {
221
251
  const restartInfo = JSON.parse(readFileSync(restartInfoPath, 'utf8'));
@@ -231,12 +261,17 @@ export async function startWeixinBot(botConfig, restartFn) {
231
261
  }
232
262
 
233
263
  console.log(`✅ ${botInstances.length} bot instance(s) running`);
264
+ console.log('📡 Listening for WeChat messages...');
234
265
 
235
266
  await new Promise(resolve => {
236
267
  globalThis.__weixinBotShutdownAndExit = (restart) => {
237
268
  shutdown(restart);
238
269
  resolve();
239
270
  };
271
+ // 收到信号时清理 opencode server 后再退出
272
+ const handleSignal = () => { shutdown(false); resolve(); };
273
+ process.on('SIGINT', handleSignal);
274
+ process.on('SIGTERM', handleSignal);
240
275
  });
241
276
 
242
277
  if (shouldRestart) {
@@ -4,10 +4,14 @@ import { abortSession, initOpenCode, listProviders, getThreadModel, setThreadMod
4
4
  import { claimOwnership, hasOwner } from '../core/auth.js';
5
5
  import { registry } from '../core/registry.js';
6
6
  import { deleteFromQiniu } from '../core/qiniu.js';
7
+ import { formatInfo, incr, incrKey } from '../core/stats.js';
8
+ import { listAgentProcesses } from '../core/agent-registry.js';
9
+ import { threadHistory } from '../core/state.js';
7
10
  import { join } from 'path';
8
11
  import { existsSync } from 'fs';
9
12
  import { homedir } from 'os';
10
13
  import { DEFAULT_BASE_URL } from './types.js';
14
+ import { threadAgent } from '../core/state.js';
11
15
  import { userAdapterMap } from './user-adapter-map.js';
12
16
 
13
17
  // 共享会话
@@ -27,7 +31,7 @@ export function removeSharedMember(threadId) {
27
31
  }
28
32
 
29
33
  // 线程级活跃 agent 追踪
30
- export const threadAgent = new Map();
34
+ export { threadAgent };
31
35
 
32
36
  async function handleAgentSwitch(adapter, ctx, agentName, prompt) {
33
37
  const agent = registry.findAgent(agentName);
@@ -65,12 +69,21 @@ async function handleAgentSwitch(adapter, ctx, agentName, prompt) {
65
69
  threadAgent.set(ctx.threadId, agentName);
66
70
  }
67
71
 
68
- await adapter.sendTyping?.(ctx.threadId, true);
72
+ // 兼容旧版 -c 前缀(清理历史中残留的 -c)
73
+ const cleanPrompt = prompt.replace(/^-c\s+/, '').trim();
74
+
75
+ adapter.sendTypingIndicator(ctx.threadId).catch(() => {});
76
+
77
+ // 传入 threadHistory 让对话有上下文
78
+ const history = threadHistory.get(ctx.threadId) || [];
79
+ const recentHistory = history.slice(-20);
69
80
 
70
81
  try {
71
- const response = await agent.sendPrompt(agentName, prompt, [], { projectDir: globalThis.__autoProjectDir });
82
+ const response = await agent.sendPrompt(agentName, cleanPrompt, recentHistory, { projectDir: globalThis.__autoProjectDir, threadId: ctx.threadId });
72
83
 
73
- await adapter.sendTyping?.(ctx.threadId, false);
84
+ // 更新历史(用清理后的 prompt)
85
+ history.push({ role: 'user', content: cleanPrompt }, { role: 'assistant', content: response || '' });
86
+ threadHistory.set(ctx.threadId, history);
74
87
 
75
88
  const chunks = splitMessage(response || '无响应');
76
89
  for (const chunk of chunks) {
@@ -85,11 +98,15 @@ async function handleAgentSwitch(adapter, ctx, agentName, prompt) {
85
98
  return true;
86
99
  }
87
100
 
88
- async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
101
+ async function handleCommand(adapter, ctx, platform, command, arg, openCodeSessions) {
102
+ // Count this command invocation
103
+ incr('messagesSent');
104
+ incrKey('commandsByType', command);
105
+
89
106
  const session = {};
90
107
  switch (command) {
91
108
  case 'start': {
92
- const result = claimOwnership('weixin', ctx.userId);
109
+ const result = claimOwnership(platform, ctx.userId);
93
110
  if (result.success) {
94
111
  if (result.message === 'claimed') {
95
112
  await adapter.reply(ctx.threadId, `🔐 安全设置完成!你是此 bot 的唯一所有者。\n\n发送消息给 OpenCode 开始工作\n/help 查看指令`);
@@ -107,6 +124,8 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
107
124
  case 'restart': {
108
125
  console.log('[bot] restart command received');
109
126
  await adapter.reply(ctx.threadId, '🔄 正在重启 bot...');
127
+ // 关掉 opencode server,防新子进程端口冲突
128
+ try { globalThis.__opencodeServer?.kill?.(); } catch {}
110
129
  const fs = await import('fs');
111
130
  const remoteDir = join(process.env.HOME || process.cwd(), '.opencode-remote');
112
131
  if (!fs.existsSync(remoteDir)) {
@@ -131,6 +150,73 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
131
150
  return true;
132
151
  }
133
152
 
153
+ case 'esc': {
154
+ const session = openCodeSessions?.get(ctx.threadId);
155
+ const parts = [];
156
+ // 1. 杀 CLI 进程 (cc/cx/copilot 模式)
157
+ const { killAgentProcess, getAgentProcess } = await import('../core/agent-registry.js');
158
+ const ap = getAgentProcess(ctx.threadId);
159
+ if (ap) {
160
+ const r = killAgentProcess(ctx.threadId);
161
+ parts.push(`🛑 ${r.agentName} 子进程已终止 (pid ${ap.process.pid})`);
162
+ }
163
+ // 2. 中断 OpenCode SDK session
164
+ if (session) {
165
+ const ok = await abortSession(session);
166
+ parts.push(ok ? '🛑 OpenCode session 已中断' : '⚠️ OpenCode session 中断失败');
167
+ }
168
+ if (parts.length === 0) {
169
+ await adapter.reply(ctx.threadId, '⚠️ 没有活跃任务');
170
+ return true;
171
+ }
172
+ await adapter.reply(ctx.threadId, parts.join('\n'));
173
+ return true;
174
+ }
175
+
176
+ case 'status': {
177
+ const session = openCodeSessions?.get(ctx.threadId);
178
+ if (!session) {
179
+ await adapter.reply(ctx.threadId, '⚠️ 当前线程无 session\n发送任意消息创建 session');
180
+ return true;
181
+ }
182
+ try {
183
+ const r = await session.client.session.status();
184
+ const all = r.data || {};
185
+ const s = all[session.sessionId];
186
+ if (!s) {
187
+ await adapter.reply(ctx.threadId, `📊 Session: ${session.sessionId.slice(0, 8)}\n状态: unknown (server 未返回)`);
188
+ return true;
189
+ }
190
+ const icon = s.type === 'idle' ? '🟢' : s.type === 'busy' ? '🔴' : '🟡';
191
+ const label = s.type === 'idle' ? '待命' : s.type === 'busy' ? '活跃' : `重试中 (attempt ${s.attempt})`;
192
+ let msg = `${icon} ${label}\nSession: ${session.sessionId.slice(0, 8)}`;
193
+ if (s.type === 'retry' && s.next) {
194
+ const wait = Math.max(0, Math.round((s.next - Date.now()) / 1000));
195
+ msg += `\n下次重试: ${wait}s 后`;
196
+ }
197
+ await adapter.reply(ctx.threadId, msg);
198
+ } catch (e) {
199
+ await adapter.reply(ctx.threadId, `❌ 状态查询失败: ${e.message}`);
200
+ }
201
+ return true;
202
+ }
203
+
204
+ case 'info': {
205
+ try {
206
+ const agentChildren = listAgentProcesses().length;
207
+ const activeThreads = threadHistory.size;
208
+ const msg = formatInfo({
209
+ version: process.env.npm_package_version || 'dev',
210
+ activeThreads,
211
+ agentChildren,
212
+ });
213
+ await adapter.reply(ctx.threadId, msg);
214
+ } catch (e) {
215
+ await adapter.reply(ctx.threadId, `❌ /info 失败: ${e.message}`);
216
+ }
217
+ return true;
218
+ }
219
+
134
220
  case 'delete': {
135
221
  const keyToDelete = arg ? arg.trim() : null;
136
222
 
@@ -335,7 +421,7 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
335
421
 
336
422
  case 'share': {
337
423
  const val = arg?.trim().toLowerCase();
338
- const isOwner = hasOwner('weixin') && claimOwnership('weixin', ctx.userId);
424
+ const isOwner = hasOwner(platform) && claimOwnership(platform, ctx.userId);
339
425
 
340
426
  if (!val || val === 'status') {
341
427
  const members = [...sharedRoom.members].join(', ') || '无';
@@ -501,10 +587,48 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
501
587
  return true;
502
588
  }
503
589
 
590
+ case 'lab': {
591
+ const { spawnSync } = await import('child_process');
592
+ const { existsSync } = await import('fs');
593
+ const projectDir = globalThis.__autoProjectDir || process.cwd();
594
+ if (!existsSync(`${projectDir}/bridge/bin/lab.mjs`)) {
595
+ await adapter.reply(ctx.threadId, '❌ 此指令仅在 openchat 项目下可用');
596
+ return true;
597
+ }
598
+ const subCmd = ctx.arg || 'status';
599
+ // Whitelist: only alnum + - and _ allowed. Defense-in-depth even though
600
+ // spawnSync with shell:false would block shell injection on its own.
601
+ if (!/^[a-zA-Z0-9_-]+$/.test(subCmd)) {
602
+ await adapter.reply(ctx.threadId, `❌ /lab 子命令非法: "${subCmd}"\n仅允许字母数字 + -_`);
603
+ return true;
604
+ }
605
+ try {
606
+ // spawnSync with shell:false — user input is passed as argv element,
607
+ // never interpreted by cmd.exe. lab.mjs validates the cmd via its
608
+ // own if/else chain and falls through to showUsage() for unknown cmds.
609
+ const result = spawnSync('node', ['bridge/bin/lab.mjs', subCmd], {
610
+ cwd: projectDir,
611
+ encoding: 'utf8',
612
+ timeout: 15000,
613
+ maxBuffer: 2048 * 1024,
614
+ shell: false,
615
+ });
616
+ if (result.error) throw result.error;
617
+ const out = result.stdout || '';
618
+ // 格式化输出
619
+ const formatted = formatLabOutput(out, subCmd);
620
+ await adapter.reply(ctx.threadId, `📋 Lab ${subCmd}\n${formatted}`);
621
+ } catch (e) {
622
+ const errMsg = e.stderr || e.message || String(e);
623
+ await adapter.reply(ctx.threadId, `❌ Lab 错误: ${errMsg.slice(0, 500)}`);
624
+ }
625
+ return true;
626
+ }
627
+
504
628
  case 'deploy': {
505
629
  const { gitPush } = await import('../core/git-push.js');
506
630
  await adapter.reply(ctx.threadId, '📤 正在推送代码...');
507
- const result = gitPush({ message: ctx.arg || undefined });
631
+ const result = gitPush({ message: ctx.arg || undefined, branch: undefined });
508
632
  if (result.ok) {
509
633
  await adapter.reply(ctx.threadId, `✅ 推送成功: ${result.successUrl}`);
510
634
  } else {
@@ -519,4 +643,6 @@ async function handleCommand(adapter, ctx, command, arg, openCodeSessions) {
519
643
  }
520
644
  }
521
645
 
646
+ import { formatLabOutput } from '../core/handler.js';
647
+
522
648
  export { handleAgentSwitch, handleCommand };