@yvhitxcel/opencode-remote 0.16.3 → 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.
@@ -0,0 +1,190 @@
1
+ // Persistent state manager — saves threadHistory + threadAgent to disk
2
+ // Bot restart preserves conversation context and agent routing
3
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, renameSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { homedir } from 'os';
6
+ import { killAllAgentProcesses } from './agent-registry.js';
7
+
8
+ const STATE_DIR = join(homedir(), '.opencode-remote', 'state');
9
+ const STATE_FILE = join(STATE_DIR, 'state.json');
10
+ const STATE_TMP = join(STATE_DIR, 'state.json.tmp');
11
+ const STATE_BAK = join(STATE_DIR, 'state.json.bak');
12
+
13
+ const MAX_HISTORY_PER_THREAD = 20; // 同步 threadHistory 上限
14
+ const MAX_THREADS = 1000; // 总 thread 上限
15
+ const WRITE_DEBOUNCE_MS = 2000; // 写盘防抖
16
+
17
+ let writeTimer = null;
18
+ let dirty = false;
19
+
20
+ function ensureDir() {
21
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
22
+ }
23
+
24
+ export function loadState() {
25
+ ensureDir();
26
+ let raw = null;
27
+ if (existsSync(STATE_FILE)) raw = readFileSync(STATE_FILE, 'utf8');
28
+ else if (existsSync(STATE_BAK)) raw = readFileSync(STATE_BAK, 'utf8');
29
+ if (!raw) return { threadHistory: {}, threadAgent: {} };
30
+ try {
31
+ const s = JSON.parse(raw);
32
+ return {
33
+ threadHistory: s.threadHistory || {},
34
+ threadAgent: s.threadAgent || {},
35
+ };
36
+ } catch (e) {
37
+ console.error('[state] Corrupt state, starting fresh:', e.message);
38
+ return { threadHistory: {}, threadAgent: {} };
39
+ }
40
+ }
41
+
42
+ function doWrite() {
43
+ ensureDir();
44
+ const snapshot = {
45
+ threadHistory: Object.fromEntries(threadHistoryMap),
46
+ threadAgent: Object.fromEntries(threadAgentMap),
47
+ };
48
+ const json = JSON.stringify(snapshot);
49
+ try {
50
+ writeFileSync(STATE_TMP, json, 'utf8');
51
+ if (existsSync(STATE_FILE)) {
52
+ try { renameSync(STATE_FILE, STATE_BAK); } catch {}
53
+ }
54
+ renameSync(STATE_TMP, STATE_FILE);
55
+ } catch (e) {
56
+ console.error('[state] Write failed:', e.message);
57
+ }
58
+ }
59
+
60
+ export function scheduleWrite() {
61
+ dirty = true;
62
+ if (writeTimer) return;
63
+ writeTimer = setTimeout(() => {
64
+ writeTimer = null;
65
+ if (dirty) {
66
+ dirty = false;
67
+ doWrite();
68
+ }
69
+ }, WRITE_DEBOUNCE_MS);
70
+ }
71
+
72
+ export function flushWrite() {
73
+ if (writeTimer) {
74
+ clearTimeout(writeTimer);
75
+ writeTimer = null;
76
+ }
77
+ if (dirty) {
78
+ dirty = false;
79
+ doWrite();
80
+ }
81
+ }
82
+
83
+ // In-memory mirror of persistent state
84
+ const threadHistoryMap = new Map(); // threadId -> [{role, content}]
85
+ const threadAgentMap = new Map(); // threadId -> agentName
86
+
87
+ export const threadHistory = {
88
+ get(threadId) {
89
+ const h = threadHistoryMap.get(threadId);
90
+ return h ? h.slice() : [];
91
+ },
92
+ set(threadId, history) {
93
+ if (!Array.isArray(history) || history.length === 0) {
94
+ threadHistoryMap.delete(threadId);
95
+ } else {
96
+ const trimmed = history.slice(-MAX_HISTORY_PER_THREAD);
97
+ threadHistoryMap.set(threadId, trimmed);
98
+ // 上限淘汰: 删最旧的不活跃
99
+ if (threadHistoryMap.size > MAX_THREADS) {
100
+ const first = threadHistoryMap.keys().next().value;
101
+ threadHistoryMap.delete(first);
102
+ }
103
+ }
104
+ scheduleWrite();
105
+ },
106
+ delete(threadId) {
107
+ threadHistoryMap.delete(threadId);
108
+ scheduleWrite();
109
+ },
110
+ has(threadId) {
111
+ return threadHistoryMap.has(threadId);
112
+ },
113
+ size() {
114
+ return threadHistoryMap.size;
115
+ },
116
+ };
117
+
118
+ export const threadAgent = {
119
+ get(threadId) { return threadAgentMap.get(threadId); },
120
+ set(threadId, agentName) { threadAgentMap.set(threadId, agentName); scheduleWrite(); },
121
+ delete(threadId) { threadAgentMap.delete(threadId); scheduleWrite(); },
122
+ size() { return threadAgentMap.size; },
123
+ };
124
+
125
+ // 启动时加载
126
+ export function initState() {
127
+ const s = loadState();
128
+ let loaded = 0;
129
+ for (const [k, v] of Object.entries(s.threadHistory)) {
130
+ if (loaded >= MAX_THREADS) break;
131
+ if (Array.isArray(v)) { threadHistoryMap.set(k, v); loaded++; }
132
+ }
133
+ for (const [k, v] of Object.entries(s.threadAgent)) {
134
+ threadAgentMap.set(k, v);
135
+ }
136
+ console.log(`[state] Loaded ${threadHistoryMap.size} history, ${threadAgentMap.size} agent routes`);
137
+ }
138
+
139
+ // 退出时刷盘(exit 事件自动触发,无需额外信号处理)
140
+ process.on('exit', () => flushWrite());
141
+
142
+ // beforeExit: 异步清理窗口。所有 setImmediate / Promise 微任务完成时触发。
143
+ // 比 'exit' 更早,能在进程自然结束时先清理资源(杀 OpenCode server、刷状态)
144
+ process.on('beforeExit', () => {
145
+ flushWrite();
146
+ try { globalThis.__opencodeServer?.kill?.(); } catch {}
147
+ });
148
+
149
+ // 未捕获异常:写日志 + 尝试刷盘 + 退出(让父进程决定是否重启)
150
+ process.on('uncaughtException', (err) => {
151
+ console.error('[FATAL] uncaughtException:', err);
152
+ try {
153
+ const crashLog = join(STATE_DIR, 'crash.log');
154
+ const line = `[${new Date().toISOString()}] ${err.stack || err.message}\n`;
155
+ appendFileSync(crashLog, line);
156
+ } catch {}
157
+ flushWrite();
158
+ try { globalThis.__opencodeServer?.kill?.(); } catch {}
159
+ // 给 logger 一点时间刷盘,然后退出(非 0 表示异常,父进程会重启)
160
+ setTimeout(() => process.exit(1), 100);
161
+ });
162
+
163
+ process.on('unhandledRejection', (reason) => {
164
+ console.error('[ERROR] unhandledRejection:', reason);
165
+ // 不退出:unhandled rejection 不一定致命
166
+ });
167
+
168
+ // SIGTERM/SIGINT: 优雅关停(PM2 重启、Ctrl+C、kill <pid>)
169
+ // beforeExit 在 kill -9 / 异常崩溃时不可靠,必须独立处理信号
170
+ function gracefulShutdown(signal) {
171
+ console.log(`[shutdown] received ${signal}`);
172
+ try { flushWrite(); } catch (e) { console.error('[shutdown] flushWrite failed:', e); }
173
+ try { globalThis.__opencodeServer?.kill?.(); } catch (e) { console.error('[shutdown] kill OpenCode failed:', e); }
174
+ // 杀掉所有 agent 子进程(claude-code/copilot/codex/opencode plugins)
175
+ // 不杀的话,bot 重启后它们会变孤儿进程
176
+ try {
177
+ const killed = killAllAgentProcesses(1000);
178
+ if (killed.length > 0) {
179
+ console.log(`[shutdown] killed ${killed.length} agent subprocess(es):`,
180
+ killed.map(k => `${k.agentName}@${k.threadId}`).join(', '));
181
+ }
182
+ } catch (e) { console.error('[shutdown] killAllAgentProcesses failed:', e); }
183
+ // 给 logger / agent-registry / agent children 一点时间清理
184
+ setTimeout(() => process.exit(0), 500);
185
+ }
186
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
187
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
188
+ // Windows 没有 SIGTERM 但 npm start 转发 Ctrl+C 事件走 SIGINT
189
+ // SIGBREAK 是 Windows Ctrl+Break(PM2 偶尔发)
190
+ process.on('SIGBREAK', () => gracefulShutdown('SIGBREAK'));
@@ -0,0 +1,115 @@
1
+ // In-memory runtime stats — exposed via /info command for self-diagnostics.
2
+ // Reset only on process restart. Cheap to read, safe to write from any handler.
3
+
4
+ const counters = {
5
+ startedAt: Date.now(),
6
+ messagesReceived: 0,
7
+ messagesSent: 0,
8
+ errorsByCode: {}, // error type → count (last 24h, capped)
9
+ opencodeRestarts: 0,
10
+ retries: 0,
11
+ authRejections: 0,
12
+ commandsByType: {}, // command name → count
13
+ };
14
+
15
+ /**
16
+ * Increment a named counter. Safe to call from any handler.
17
+ * @param {string} name
18
+ * @param {number} [by=1]
19
+ */
20
+ export function incr(name, by = 1) {
21
+ if (typeof counters[name] !== 'number') counters[name] = 0;
22
+ counters[name] += by;
23
+ }
24
+
25
+ /**
26
+ * Increment a nested counter (e.g., errorsByCode['AbortError']).
27
+ * @param {string} group
28
+ * @param {string} key
29
+ */
30
+ export function incrKey(group, key) {
31
+ if (!counters[group] || typeof counters[group] !== 'object') counters[group] = {};
32
+ counters[group][key] = (counters[group][key] || 0) + 1;
33
+ }
34
+
35
+ /**
36
+ * Snapshot of all counters + computed uptime/memory.
37
+ * Cheap to call. Returns a fresh object each time so callers can mutate freely.
38
+ */
39
+ export function snapshot() {
40
+ const mem = process.memoryUsage();
41
+ return {
42
+ ...counters,
43
+ uptimeSec: Math.round((Date.now() - counters.startedAt) / 1000),
44
+ memoryMB: {
45
+ rss: Math.round(mem.rss / 1024 / 1024),
46
+ heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
47
+ heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
48
+ external: Math.round(mem.external / 1024 / 1024),
49
+ },
50
+ nodeVersion: process.version,
51
+ pid: process.pid,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Render a human-readable status block for /info reply.
57
+ * @param {object} [extra] - additional fields to include (e.g., bot version, agent children count)
58
+ * @returns {string}
59
+ */
60
+ export function formatInfo(extra = {}) {
61
+ const s = snapshot();
62
+ const fmt = (n) => n.toLocaleString();
63
+ const lines = [
64
+ `📊 opencode-remote 状态`,
65
+ ``,
66
+ `⏱️ 启动时间: ${new Date(s.startedAt).toISOString().slice(0, 19)}Z`,
67
+ `⏳ 运行: ${formatDuration(s.uptimeSec)}`,
68
+ `🆔 PID: ${s.pid} · Node ${s.nodeVersion}`,
69
+ ``,
70
+ `📨 收到消息: ${fmt(s.messagesReceived)}`,
71
+ `📤 已回复: ${fmt(s.messagesSent)}`,
72
+ `🔄 OpenCode 重启: ${fmt(s.opencodeRestarts)}`,
73
+ `♻️ 重试次数: ${fmt(s.retries)}`,
74
+ `🚫 auth 拒绝: ${fmt(s.authRejections)}`,
75
+ ];
76
+
77
+ if (Object.keys(s.commandsByType).length > 0) {
78
+ lines.push('', '📋 命令统计:');
79
+ const top = Object.entries(s.commandsByType)
80
+ .sort((a, b) => b[1] - a[1])
81
+ .slice(0, 5);
82
+ for (const [cmd, count] of top) {
83
+ lines.push(` /${cmd}: ${fmt(count)}`);
84
+ }
85
+ }
86
+
87
+ if (Object.keys(s.errorsByCode).length > 0) {
88
+ lines.push('', '⚠️ 错误类型:');
89
+ const top = Object.entries(s.errorsByCode)
90
+ .sort((a, b) => b[1] - a[1])
91
+ .slice(0, 5);
92
+ for (const [err, count] of top) {
93
+ lines.push(` ${err}: ${fmt(count)}`);
94
+ }
95
+ }
96
+
97
+ lines.push(
98
+ '',
99
+ `💾 内存: heap ${s.memoryMB.heapUsed}/${s.memoryMB.heapTotal}MB · rss ${s.memoryMB.rss}MB`,
100
+ );
101
+
102
+ if (extra.version) lines.push(`📦 版本: ${extra.version}`);
103
+ if (typeof extra.activeThreads === 'number') lines.push(`💬 活跃线程: ${fmt(extra.activeThreads)}`);
104
+ if (typeof extra.agentChildren === 'number') lines.push(`🧒 agent 子进程: ${fmt(extra.agentChildren)}`);
105
+
106
+ return lines.join('\n');
107
+ }
108
+
109
+ function formatDuration(sec) {
110
+ if (sec < 60) return `${sec}秒`;
111
+ if (sec < 3600) return `${Math.floor(sec / 60)}分${sec % 60}秒`;
112
+ const h = Math.floor(sec / 3600);
113
+ const m = Math.floor((sec % 3600) / 60);
114
+ return `${h}小时${m}分`;
115
+ }
@@ -69,4 +69,3 @@ function createFeishuAdapter(client) {
69
69
  }
70
70
 
71
71
  export { createFeishuAdapter };
72
- export default createFeishuAdapter;
@@ -1,9 +1,9 @@
1
1
  import * as lark from '@larksuiteoapi/node-sdk';
2
- import { initSessionManager } from '../core/session.js';
3
2
  import { initOpenCode } from '../opencode/client.js';
4
3
  import { getAuthStatus } from '../core/auth.js';
5
4
  import { createFeishuAdapter } from './adapter.js';
6
5
  import { handleMessage } from './handler.js';
6
+ import { LRUSessionMap } from '../core/lru.js';
7
7
 
8
8
  let feishuClient = null;
9
9
  let wsClient = null;
@@ -46,8 +46,8 @@ export async function startFeishuBot(botConfig) {
46
46
  appId: config.feishuAppId,
47
47
  appSecret: config.feishuAppSecret,
48
48
  });
49
- initSessionManager(config);
50
- openCodeSessions = new Map();
49
+ openCodeSessions = new LRUSessionMap({ maxSize: 100, ttlMs: 30 * 60 * 1000, name: 'feishu-sessions' });
50
+ setInterval(() => openCodeSessions.cleanup(), 5 * 60 * 1000).unref();
51
51
  console.log('🔧 正在初始化 OpenCode...');
52
52
  try {
53
53
  await initOpenCode();
@@ -86,7 +86,7 @@ export async function startFeishuBot(botConfig) {
86
86
  return { code: 0 };
87
87
  }
88
88
  const ctx = feishuEventToContext(data);
89
- handleMessage(adapter, ctx, text, openCodeSessions).catch(error => {
89
+ handleMessage(adapter, ctx, text, openCodeSessions, 'feishu').catch(error => {
90
90
  console.error('处理飞书消息失败:', error);
91
91
  });
92
92
  return { code: 0 };