@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.
- package/LICENSE +21 -0
- package/README.md +70 -1
- package/dist/autonomous/decisions.js +73 -0
- package/dist/autonomous/index.js +141 -0
- package/dist/cli.js +121 -19
- package/dist/core/adapter.js +12 -0
- package/dist/core/agent-registry.js +77 -0
- package/dist/core/crypto.js +80 -0
- package/dist/core/git-push.js +143 -0
- package/dist/core/handler.js +293 -0
- package/dist/core/log.js +92 -0
- package/dist/core/lru.js +98 -0
- package/dist/core/notifications.js +2 -2
- package/dist/core/qiniu.js +2 -2
- package/dist/core/retry.js +46 -0
- package/dist/core/router.js +62 -296
- package/dist/core/state.js +190 -0
- package/dist/core/stats.js +115 -0
- package/dist/feishu/adapter.js +0 -1
- package/dist/feishu/bot.js +4 -4
- package/dist/feishu/commands.js +28 -397
- package/dist/feishu/handler.js +9 -369
- package/dist/opencode/client.js +172 -168
- package/dist/patch_spawn.js +1 -0
- package/dist/plugins/agents/claude-code/index.js +59 -47
- package/dist/plugins/agents/codex/index.js +32 -6
- package/dist/plugins/agents/copilot/index.js +32 -6
- package/dist/plugins/agents/opencode/index.js +38 -12
- package/dist/telegram/adapter.js +22 -9
- package/dist/telegram/bot.js +1 -6
- package/dist/weixin/adapter.js +37 -15
- package/dist/weixin/api.js +47 -19
- package/dist/weixin/bot.js +172 -83
- package/dist/weixin/commands.js +476 -597
- package/dist/weixin/handler.js +27 -541
- package/dist/weixin/user-adapter-map.js +12 -0
- package/package.json +5 -3
- package/dist/core/session.js +0 -403
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// AES-256-GCM credential encryption
|
|
2
|
+
// Key derived from machine fingerprint + user-level salt
|
|
3
|
+
// Format: {iv, tag, data} all base64
|
|
4
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash } from 'crypto';
|
|
5
|
+
import { hostname, userInfo, homedir, platform } from 'os';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
const SALT_FILE = join(homedir(), '.opencode-remote', '.cred_salt');
|
|
10
|
+
|
|
11
|
+
function getMachineFingerprint() {
|
|
12
|
+
const parts = [
|
|
13
|
+
hostname(),
|
|
14
|
+
userInfo().username,
|
|
15
|
+
platform(),
|
|
16
|
+
homedir(),
|
|
17
|
+
process.env.COMPUTERNAME || '',
|
|
18
|
+
].join('|');
|
|
19
|
+
return createHash('sha256').update(parts).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getOrCreateSalt() {
|
|
23
|
+
const dir = join(homedir(), '.opencode-remote');
|
|
24
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
25
|
+
if (existsSync(SALT_FILE)) {
|
|
26
|
+
return readFileSync(SALT_FILE);
|
|
27
|
+
}
|
|
28
|
+
const salt = randomBytes(32);
|
|
29
|
+
writeFileSync(SALT_FILE, salt, { mode: 0o600 });
|
|
30
|
+
return salt;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function deriveKey() {
|
|
34
|
+
const fp = getMachineFingerprint();
|
|
35
|
+
const salt = getOrCreateSalt();
|
|
36
|
+
return scryptSync(fp, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Encrypt a plain string with AES-256-GCM
|
|
41
|
+
* @param {string} plaintext
|
|
42
|
+
* @returns {string} JSON envelope {iv, tag, data} all base64
|
|
43
|
+
*/
|
|
44
|
+
export function encryptCredential(plaintext) {
|
|
45
|
+
if (plaintext == null) return null;
|
|
46
|
+
const key = deriveKey();
|
|
47
|
+
const iv = randomBytes(12);
|
|
48
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
49
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
50
|
+
const tag = cipher.getAuthTag();
|
|
51
|
+
return JSON.stringify({
|
|
52
|
+
v: 1,
|
|
53
|
+
iv: iv.toString('base64'),
|
|
54
|
+
tag: tag.toString('base64'),
|
|
55
|
+
data: enc.toString('base64'),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function decryptCredential(envelope) {
|
|
60
|
+
if (envelope == null) return null;
|
|
61
|
+
// 兼容旧明文 (不是 JSON envelope)
|
|
62
|
+
if (typeof envelope === 'string' && !envelope.startsWith('{')) {
|
|
63
|
+
return envelope;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const obj = typeof envelope === 'string' ? JSON.parse(envelope) : envelope;
|
|
67
|
+
if (obj.v !== 1) return null;
|
|
68
|
+
const key = deriveKey();
|
|
69
|
+
const iv = Buffer.from(obj.iv, 'base64');
|
|
70
|
+
const tag = Buffer.from(obj.tag, 'base64');
|
|
71
|
+
const data = Buffer.from(obj.data, 'base64');
|
|
72
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
73
|
+
decipher.setAuthTag(tag);
|
|
74
|
+
const dec = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
75
|
+
return dec.toString('utf8');
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.error('[crypto] decrypt failed:', e.message);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MIRRORS = [
|
|
7
|
+
'gh-proxy.com',
|
|
8
|
+
'ghfast.top',
|
|
9
|
+
'mirror.ghproxy.com',
|
|
10
|
+
'ghproxy.com',
|
|
11
|
+
'github.akams.cn',
|
|
12
|
+
'gh-proxy.ygxz.in',
|
|
13
|
+
'codeload.github.com',
|
|
14
|
+
'github.com',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function loadCustomMirrors() {
|
|
18
|
+
const localMirrors = join(process.cwd(), '.gitmirrors');
|
|
19
|
+
if (existsSync(localMirrors)) {
|
|
20
|
+
return readFileSync(localMirrors, 'utf-8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
|
21
|
+
}
|
|
22
|
+
const globalEnv = join(homedir(), '.opencode-remote', '.env');
|
|
23
|
+
if (existsSync(globalEnv)) {
|
|
24
|
+
const m = readFileSync(globalEnv, 'utf-8').match(/GIT_MIRRORS=(.+)/);
|
|
25
|
+
if (m) return m[1].split(',').map(s => s.trim()).filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
if (process.env.GIT_MIRRORS) return process.env.GIT_MIRRORS.split(',').map(s => s.trim()).filter(Boolean);
|
|
28
|
+
return DEFAULT_MIRRORS;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseOriginUrl(url) {
|
|
32
|
+
// HTTPS: https://TOKEN@github.com/user/repo.git
|
|
33
|
+
// https://github.com/user/repo.git
|
|
34
|
+
// SSH: git@github.com:user/repo.git
|
|
35
|
+
let auth = '';
|
|
36
|
+
let host = '';
|
|
37
|
+
let userRepo = '';
|
|
38
|
+
|
|
39
|
+
if (url.includes('://')) {
|
|
40
|
+
const match = url.match(/^(https?:\/\/)([^@]*@)?([^\/]+)(\/.*)$/);
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
const protocol = match[1];
|
|
43
|
+
const token = match[2] || '';
|
|
44
|
+
host = match[3];
|
|
45
|
+
userRepo = match[4];
|
|
46
|
+
auth = protocol + token;
|
|
47
|
+
} else {
|
|
48
|
+
const match = url.match(/^([^@]+@)?([^:]+):(.+)$/);
|
|
49
|
+
if (!match) return null;
|
|
50
|
+
auth = match[1] || '';
|
|
51
|
+
host = match[2];
|
|
52
|
+
userRepo = '/' + match[3];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { auth, host, userRepo };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Whitelist validation for git refs (branch names, remote names). Git itself
|
|
59
|
+
// also validates these, but we add a defensive check so a malicious caller
|
|
60
|
+
// can't pass things like "main && rm -rf /" (even though execFileSync + args
|
|
61
|
+
// array would block shell injection on its own, this is defense in depth).
|
|
62
|
+
function isValidGitRef(s) {
|
|
63
|
+
return typeof s === 'string' && /^[a-zA-Z0-9._\/-]+$/.test(s) && s.length > 0 && s.length < 256;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{ message?: string, branch?: string }} [opts]
|
|
68
|
+
*/
|
|
69
|
+
export function gitPush(opts) {
|
|
70
|
+
const { message, branch } = opts || {};
|
|
71
|
+
const cwd = process.cwd();
|
|
72
|
+
|
|
73
|
+
let currentBranch;
|
|
74
|
+
try {
|
|
75
|
+
currentBranch = execFileSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' }).trim();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { ok: false, error: '不在 git 仓库中' };
|
|
78
|
+
}
|
|
79
|
+
const targetBranch = branch || currentBranch;
|
|
80
|
+
|
|
81
|
+
// Defensive validation of branch name (caller-supplied)
|
|
82
|
+
if (branch && !isValidGitRef(branch)) {
|
|
83
|
+
return { ok: false, error: `非法 branch 名称: ${branch}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const status = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' }).trim();
|
|
87
|
+
if (status) {
|
|
88
|
+
execFileSync('git', ['add', '-A'], { cwd });
|
|
89
|
+
const msg = (message || `auto update ${new Date().toISOString().slice(0, 19)}`).replace(/"/g, '\\"');
|
|
90
|
+
// execFileSync with args array — msg is passed as a single argv element
|
|
91
|
+
// to git, never interpreted by shell. Even if msg contains `;` or `&`
|
|
92
|
+
// or shell metachars, git just stores it as the commit message.
|
|
93
|
+
execFileSync('git', ['commit', '-m', msg], { cwd, stdio: 'pipe' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let originUrl;
|
|
97
|
+
try {
|
|
98
|
+
originUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf-8' }).trim();
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return { ok: false, error: '没有找到 remote origin' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parsed = parseOriginUrl(originUrl);
|
|
104
|
+
if (!parsed) {
|
|
105
|
+
return { ok: false, error: `无法解析 remote URL: ${originUrl}` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 从 gh CLI 提取 token(如果 remote URL 里没有的话)
|
|
109
|
+
let pushAuth = parsed.auth;
|
|
110
|
+
if (originUrl.startsWith('https://') && !originUrl.includes('@')) {
|
|
111
|
+
try {
|
|
112
|
+
const ghToken = execFileSync('gh', ['auth', 'token'], { encoding: 'utf-8' }).trim();
|
|
113
|
+
if (ghToken) {
|
|
114
|
+
pushAuth = `https://${ghToken}@`;
|
|
115
|
+
}
|
|
116
|
+
} catch (_) { /* gh not available */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const mirrors = loadCustomMirrors();
|
|
120
|
+
const results = [];
|
|
121
|
+
|
|
122
|
+
for (const host of mirrors) {
|
|
123
|
+
const pushUrl = pushAuth + host + parsed.userRepo;
|
|
124
|
+
const remoteName = `m_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
125
|
+
console.log(`[git-push] trying ${host}...`);
|
|
126
|
+
try {
|
|
127
|
+
// All execFileSync calls below use args arrays — no shell interpolation.
|
|
128
|
+
// remoteName, pushUrl, targetBranch, branch are passed as argv elements,
|
|
129
|
+
// never interpreted by shell.
|
|
130
|
+
execFileSync('git', ['remote', 'add', remoteName, pushUrl], { cwd, stdio: 'pipe' });
|
|
131
|
+
execFileSync('git', ['push', remoteName, targetBranch, '--follow-tags'], { cwd, stdio: 'pipe', timeout: 30000 });
|
|
132
|
+
execFileSync('git', ['remote', 'remove', remoteName], { cwd, stdio: 'pipe' });
|
|
133
|
+
results.push({ host, ok: true, url: pushUrl });
|
|
134
|
+
return { ok: true, results, successUrl: pushUrl };
|
|
135
|
+
} catch (e) {
|
|
136
|
+
try { execFileSync('git', ['remote', 'remove', remoteName], { cwd, stdio: 'pipe' }); } catch (_) {}
|
|
137
|
+
const msg = e.stderr?.toString()?.trim() || e.message || '';
|
|
138
|
+
results.push({ host, ok: false, url: pushUrl, error: msg.slice(0, 150) });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { ok: false, results };
|
|
143
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Unified handler — platform-agnostic. Created via createHandler(deps).
|
|
2
|
+
// deps: { handleCommand, replyTo, wrapAdapterForShared }
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { splitMessage } from './notifications.js';
|
|
6
|
+
import { createSession, sendMessage as sendToOpenCode, checkConnection, shareSession, listOpenCodeSessions, resumeSession, initOpenCode, resetOpenCode } from '../opencode/client.js';
|
|
7
|
+
import { isAuthorized, hasOwner } from './auth.js';
|
|
8
|
+
import { threadHistory, threadAgent } from './state.js';
|
|
9
|
+
import { retryTransient, isTransientError } from './retry.js';
|
|
10
|
+
import { detectCommand, EXPERT_SYSTEM_PROMPT, startTypingPing } from './router.js';
|
|
11
|
+
import { hasPendingDecision, resolveDecision } from '../autonomous/decisions.js';
|
|
12
|
+
import { registry } from './registry.js';
|
|
13
|
+
import { incr, incrKey } from './stats.js';
|
|
14
|
+
|
|
15
|
+
const IDLE_MODEL_HINT_MS = 5 * 60 * 1000;
|
|
16
|
+
const threadLastActive = new Map();
|
|
17
|
+
const threadLock = new Set();
|
|
18
|
+
const THREAD_LOCK_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟线程锁超时
|
|
19
|
+
|
|
20
|
+
// 定期清理超过 10 分钟的线程锁(防卡死)
|
|
21
|
+
setInterval(() => {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const staleLock = [];
|
|
24
|
+
for (const tid of threadLock) {
|
|
25
|
+
const lastActive = threadLastActive.get(tid) || 0;
|
|
26
|
+
if (lastActive > 0 && now - lastActive > THREAD_LOCK_TIMEOUT_MS) {
|
|
27
|
+
staleLock.push(tid);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const tid of staleLock) {
|
|
31
|
+
threadLock.delete(tid);
|
|
32
|
+
threadLastActive.delete(tid);
|
|
33
|
+
console.log(`[handler] Force-unlocked stale thread ${tid.slice(0, 8)} (>${THREAD_LOCK_TIMEOUT_MS / 1000}s)`);
|
|
34
|
+
}
|
|
35
|
+
// 清理超过 1 小时的 threadLastActive 条目
|
|
36
|
+
const cutoff = now - 3600_000;
|
|
37
|
+
for (const [tid, ts] of threadLastActive) {
|
|
38
|
+
if (ts < cutoff) threadLastActive.delete(tid);
|
|
39
|
+
}
|
|
40
|
+
}, 60_000).unref?.();
|
|
41
|
+
|
|
42
|
+
export function createHandler(deps) {
|
|
43
|
+
const { handleCommand, replyTo, wrapAdapterForShared } = deps;
|
|
44
|
+
|
|
45
|
+
async function fwdToOpenCode(adapter, ctx, text, openCodeSessions, expertPrompt) {
|
|
46
|
+
adapter.sendTypingIndicator(ctx.threadId).catch(() => {});
|
|
47
|
+
let openCodeSession = null;
|
|
48
|
+
let pendingModelHint = null;
|
|
49
|
+
|
|
50
|
+
const isShared = deps.isSharedMember ? deps.isSharedMember(ctx.threadId) : false;
|
|
51
|
+
const sharedRoom = deps.sharedRoom || { busy: false, session: null, members: [] };
|
|
52
|
+
|
|
53
|
+
if (isShared) {
|
|
54
|
+
if (sharedRoom.busy) { await adapter.reply(ctx.threadId, '⏳ 当前有人在用,请稍等...'); return; }
|
|
55
|
+
if (sharedRoom.session) { openCodeSession = sharedRoom.session; }
|
|
56
|
+
else {
|
|
57
|
+
openCodeSession = await createSession(`shared-${Date.now()}`, '共享会话');
|
|
58
|
+
if (!openCodeSession) { await adapter.reply(ctx.threadId, '❌ 无法创建共享会话'); return; }
|
|
59
|
+
sharedRoom.session = openCodeSession;
|
|
60
|
+
console.log(`✅ 共享会话已创建: ${openCodeSession.sessionId.slice(0, 8)}`);
|
|
61
|
+
}
|
|
62
|
+
sharedRoom.busy = true;
|
|
63
|
+
} else {
|
|
64
|
+
const lastActive = threadLastActive.get(ctx.threadId) || 0;
|
|
65
|
+
const isIdle = !expertPrompt && lastActive > 0 && (Date.now() - lastActive) > IDLE_MODEL_HINT_MS;
|
|
66
|
+
if (isIdle) pendingModelHint = true;
|
|
67
|
+
|
|
68
|
+
if (expertPrompt) {
|
|
69
|
+
openCodeSession = await createSession(`expert-${Date.now()}`, `专家评审 ${Date.now()}`);
|
|
70
|
+
if (!openCodeSession) { await adapter.reply(ctx.threadId, '❌ 无法创建评审会话'); return; }
|
|
71
|
+
console.log(`✅ 新建评审会话: ${openCodeSession.sessionId.slice(0, 8)}`);
|
|
72
|
+
} else {
|
|
73
|
+
openCodeSession = openCodeSessions.get(ctx.threadId);
|
|
74
|
+
if (!openCodeSession) {
|
|
75
|
+
console.log(`[fwdToOpenCode] no session, trying to resume...`);
|
|
76
|
+
try {
|
|
77
|
+
const sessions = await listOpenCodeSessions();
|
|
78
|
+
if (sessions.length > 0) {
|
|
79
|
+
const latest = sessions.sort((a, b) => (b.lastActivity || 0) - (a.lastActivity || 0))[0];
|
|
80
|
+
const resumed = await resumeSession(latest.id);
|
|
81
|
+
if (resumed) { openCodeSession = resumed; openCodeSessions.set(ctx.threadId, openCodeSession); console.log(`[fwdToOpenCode] resumed session ${latest.id.slice(0, 8)}`); }
|
|
82
|
+
}
|
|
83
|
+
} catch (e) { console.log(`[fwdToOpenCode] failed to resume: ${e.message}`); }
|
|
84
|
+
if (!openCodeSession) {
|
|
85
|
+
console.log(`[fwdToOpenCode] creating new session for thread=${ctx.threadId}`);
|
|
86
|
+
openCodeSession = await createSession(ctx.threadId, `Session ${ctx.threadId}`);
|
|
87
|
+
if (!openCodeSession) { await adapter.reply(ctx.threadId, '❌ 无法创建 OpenCode 会话'); return; }
|
|
88
|
+
openCodeSessions.set(ctx.threadId, openCodeSession);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(`📤 Message: → ${text}`);
|
|
95
|
+
let scopedText = text;
|
|
96
|
+
if (expertPrompt) scopedText = `${expertPrompt}\n\n${scopedText}`;
|
|
97
|
+
|
|
98
|
+
const typingPing = startTypingPing(adapter, ctx.threadId);
|
|
99
|
+
const startTs = Date.now();
|
|
100
|
+
const projectDir = globalThis.__autoProjectDir || process.cwd();
|
|
101
|
+
const inOpenchat = existsSync(`${projectDir}/bridge/bin/lab.mjs`);
|
|
102
|
+
|
|
103
|
+
const runLabStatus = async () => {
|
|
104
|
+
if (!inOpenchat) return;
|
|
105
|
+
try {
|
|
106
|
+
const out = execSync('node bridge/bin/lab.mjs status', { cwd: projectDir, encoding: 'utf8', timeout: 5000 });
|
|
107
|
+
replyTo(ctx.threadId, `⏳ ${formatLabOutput(out, 'status')}`, adapter).catch(() => {});
|
|
108
|
+
} catch (e) { replyTo(ctx.threadId, `⏳ lab status 失败: ${e.message}`, adapter).catch(() => {}); }
|
|
109
|
+
};
|
|
110
|
+
const heartbeat = setInterval(runLabStatus, 50_000);
|
|
111
|
+
|
|
112
|
+
const isConnError = (e) => /ECONNREFUSED|ECONNRESET|fetch failed|socket hang up|EHOSTUNREACH|ENETUNREACH/i.test(e?.message || '');
|
|
113
|
+
|
|
114
|
+
const result = await retryTransient(async () => {
|
|
115
|
+
try {
|
|
116
|
+
return await sendToOpenCode(openCodeSession, scopedText, {
|
|
117
|
+
idleThreshold: expertPrompt ? 30 : 10,
|
|
118
|
+
onNewContent: () => typingPing.poke(),
|
|
119
|
+
onResponseMeta: (meta) => { if (pendingModelHint && meta.modelID) pendingModelHint = `🧠 ${meta.providerID}/${meta.modelID}`; },
|
|
120
|
+
onEvent: (event) => {
|
|
121
|
+
if (event.type === 'tool.call') {
|
|
122
|
+
const props = event.properties || {};
|
|
123
|
+
const tn = props.name || props.tool_name || 'unknown';
|
|
124
|
+
const inp = props.input || {};
|
|
125
|
+
let desc = `🔧 ${tn}${inp.path ? ` 📁${inp.path}` : ''}${inp.command ? ` 💻${inp.command}` : ''}`;
|
|
126
|
+
console.log(`[→tool] ${desc}`);
|
|
127
|
+
(isShared ? [...sharedRoom.members] : [ctx.threadId]).forEach(tid => replyTo(tid, desc, adapter).catch(e => console.error('[→tool] fail:', e.message)));
|
|
128
|
+
typingPing.poke();
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}, ctx.threadId);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (isConnError(e)) {
|
|
134
|
+
console.warn('[fwdToOpenCode] Connection error, reinit OpenCode...');
|
|
135
|
+
try { const fresh = await initOpenCode(); if (fresh?.client && openCodeSession) { openCodeSession.client = fresh.client; openCodeSession.server = fresh.server; } } catch (e) { console.debug('[fwdToOpenCode] reinit failed:', e.message); }
|
|
136
|
+
}
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
}, {
|
|
140
|
+
maxAttempts: 2, baseDelayMs: 2000,
|
|
141
|
+
onRetry: (err, attempt, delay) => {
|
|
142
|
+
incr('retries');
|
|
143
|
+
incrKey('errorsByCode', err.name || err.code || 'Unknown');
|
|
144
|
+
console.log(`[retry] error, attempt ${attempt}, retry in ${delay}ms: ${err.message}`);
|
|
145
|
+
adapter.reply(ctx.threadId, `⚠️ 请求失败,${Math.round(delay/1000)}s 后重试 (${attempt}/2)`).catch(() => {});
|
|
146
|
+
},
|
|
147
|
+
}).catch(e => {
|
|
148
|
+
console.error('[fwdToOpenCode] error:', e.message);
|
|
149
|
+
incrKey('errorsByCode', e.name || e.code || 'Unknown');
|
|
150
|
+
// 客户端超时 / AbortError → 自动重启 OpenCode 服务(防卡死)
|
|
151
|
+
if (/AbortError|aborted/i.test(e.message)) {
|
|
152
|
+
incr('opencodeRestarts');
|
|
153
|
+
resetOpenCode(); // 清空缓存 singleton,initOpenCode 才能真正重启
|
|
154
|
+
try { globalThis.__opencodeServer?.kill?.('SIGKILL'); } catch {}
|
|
155
|
+
setTimeout(() => initOpenCode().catch(() => {}), 1000);
|
|
156
|
+
}
|
|
157
|
+
return `❌ ${e.message || e}`;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
clearInterval(heartbeat);
|
|
161
|
+
typingPing.done();
|
|
162
|
+
|
|
163
|
+
const replyTargets = isShared ? [...sharedRoom.members] : [ctx.threadId];
|
|
164
|
+
if (isShared) sharedRoom.busy = false;
|
|
165
|
+
|
|
166
|
+
const finalText = (result || '').trim();
|
|
167
|
+
console.log(`📥 AI response (${finalText.length} chars): ${finalText.slice(0, 200)}...`);
|
|
168
|
+
const displayText = typeof pendingModelHint === 'string' ? `${pendingModelHint}\n${finalText}` : finalText;
|
|
169
|
+
|
|
170
|
+
if (!finalText || finalText.startsWith('⏰') || finalText.startsWith('❌')) {
|
|
171
|
+
if (!isShared) openCodeSessions.delete(ctx.threadId);
|
|
172
|
+
// 不替 LLM 编空响应消息:sendToOpenCode 已透传真实错误或返回空字符串
|
|
173
|
+
if (finalText) replyTargets.forEach(tid => replyTo(tid, finalText, adapter).catch(e => console.error('[reply] fail:', e.message)));
|
|
174
|
+
} else {
|
|
175
|
+
splitMessage(displayText).forEach(m => m.trim() && replyTargets.forEach(tid => replyTo(tid, m, adapter).catch(e => console.error('[reply] fail:', e.message))));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
threadLastActive.set(ctx.threadId, Date.now());
|
|
179
|
+
|
|
180
|
+
if (finalText.length > 0 && !finalText.startsWith('❌') && !finalText.startsWith('⏰')) {
|
|
181
|
+
const shareUrl = await shareSession(openCodeSession);
|
|
182
|
+
if (shareUrl) { try { await adapter.reply(ctx.threadId, `🔗 ${shareUrl}`); } catch (e) { console.error('[fwdToOpenCode] share URL reply failed:', e.message); } }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function handleMsg(adapter, ctx, text, openCodeSessions, platform = 'weixin') {
|
|
187
|
+
// Stats: count every incoming message
|
|
188
|
+
incr('messagesReceived');
|
|
189
|
+
|
|
190
|
+
// 阶段 1: 所有系统指令都不阻塞,先于 lock 处理
|
|
191
|
+
// - /z 调用 fwdToOpenCode(不 acquire lock)
|
|
192
|
+
// - detectCommand 命中任何命令都直接处理
|
|
193
|
+
const trimmedLower = text.trim().toLowerCase();
|
|
194
|
+
|
|
195
|
+
if (text.startsWith('/z')) {
|
|
196
|
+
const arg = text.slice(2).trim();
|
|
197
|
+
if (arg === 'off' || arg === 'reset' || arg === '关闭') { await adapter.reply(ctx.threadId, '⏹️ 自定义 prompt 已清除'); return; }
|
|
198
|
+
if (arg) {
|
|
199
|
+
await adapter.reply(ctx.threadId, `✅ 自定义专家 prompt (${arg.length}字),本消息生效`);
|
|
200
|
+
await fwdToOpenCode(adapter, ctx, text, openCodeSessions, arg);
|
|
201
|
+
} else {
|
|
202
|
+
await adapter.reply(ctx.threadId, '✅ 专家评审已启动');
|
|
203
|
+
await fwdToOpenCode(adapter, ctx, text, openCodeSessions, EXPERT_SYSTEM_PROMPT);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const detected = detectCommand(text);
|
|
209
|
+
if (detected) {
|
|
210
|
+
const cmdAdapter = deps.isSharedMember ? wrapAdapterForShared(adapter, ctx.threadId) : adapter;
|
|
211
|
+
const handled = await handleCommand(cmdAdapter, ctx, platform, detected.name, detected.arg, openCodeSessions);
|
|
212
|
+
if (handled) return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 阶段 2: 普通消息才加 lock
|
|
216
|
+
if (threadLock.has(ctx.threadId)) {
|
|
217
|
+
console.log(`[handler] thread ${ctx.threadId.slice(0, 8)} busy, skipping`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
threadLock.add(ctx.threadId);
|
|
221
|
+
try {
|
|
222
|
+
const expertTriggers = ['z', 'Z', '叫全部专家', '叫所有专家', '呼叫专家点评', '专家点评', '专家意见', 'call all experts', 'expert review', '专家会诊', '团队评审', '代码审查', '全员review', 'review all', '请专家', '叫专家', '找专家', 'expert'];
|
|
223
|
+
let expertPrompt = null;
|
|
224
|
+
|
|
225
|
+
if (expertTriggers.some(t => trimmedLower.includes(t))) expertPrompt = EXPERT_SYSTEM_PROMPT;
|
|
226
|
+
|
|
227
|
+
if (hasPendingDecision(ctx.threadId)) {
|
|
228
|
+
if (/^\d+$/.test(text.trim())) { if (resolveDecision(ctx.threadId, text.trim())) return; }
|
|
229
|
+
if (/^\/(\d+)$/.test(text.trim())) { if (resolveDecision(ctx.threadId, text.trim().slice(1))) return; }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!isAuthorized(platform, ctx.userId)) {
|
|
233
|
+
incr('authRejections');
|
|
234
|
+
if (!hasOwner(platform)) { await adapter.reply(ctx.threadId, '🔐 请先发送 /start 进行安全认证'); return; }
|
|
235
|
+
if (!deps.isSharedMember?.(ctx.threadId)) { await adapter.reply(ctx.threadId, '🚫 你无权使用此 bot'); return; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const activeAgentName = threadAgent.get(ctx.threadId);
|
|
239
|
+
|
|
240
|
+
if (activeAgentName && activeAgentName !== 'opencode') {
|
|
241
|
+
const agent = registry.findAgent(activeAgentName);
|
|
242
|
+
if (agent) {
|
|
243
|
+
const available = await agent.isAvailable().catch(() => false);
|
|
244
|
+
if (available) {
|
|
245
|
+
adapter.sendTypingIndicator(ctx.threadId).catch(() => {});
|
|
246
|
+
const t0 = Date.now();
|
|
247
|
+
const hbDir = globalThis.__autoProjectDir || process.cwd();
|
|
248
|
+
const hbLab = existsSync(`${hbDir}/bridge/bin/lab.mjs`);
|
|
249
|
+
const heartbeat = setInterval(() => {
|
|
250
|
+
if (hbLab) { try { const out = execSync('node bridge/bin/lab.mjs status', { cwd: hbDir, encoding: 'utf8', timeout: 5000 }); adapter.reply(ctx.threadId, `⏳ ${activeAgentName} · ${formatLabOutput(out, 'status')}`).catch(() => {}); return; } catch (e) { adapter.reply(ctx.threadId, `⏳ ${activeAgentName} lab status 失败: ${e.message}`).catch(() => {}); return; } }
|
|
251
|
+
}, 50_000);
|
|
252
|
+
|
|
253
|
+
const history = threadHistory.get(ctx.threadId) || [];
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const response = await retryTransient(() => agent.sendPrompt(activeAgentName, text, history, { projectDir: globalThis.__autoProjectDir, threadId: ctx.threadId }), {
|
|
257
|
+
maxAttempts: 2, baseDelayMs: 2000, onRetry: (err, attempt, delay) => { adapter.reply(ctx.threadId, `⚠️ ${activeAgentName} 失败,${Math.round(delay/1000)}s 后重试 (${attempt}/2)`).catch(() => {}); },
|
|
258
|
+
});
|
|
259
|
+
clearInterval(heartbeat);
|
|
260
|
+
console.log(`📥 AI response (${(response || '').length} chars): ${(response || '').slice(0, 200)}...`);
|
|
261
|
+
history.push({ role: 'user', content: text }, { role: 'assistant', content: response || '' });
|
|
262
|
+
threadHistory.set(ctx.threadId, history);
|
|
263
|
+
splitMessage(response || '无响应').forEach(chunk => adapter.reply(ctx.threadId, chunk));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
clearInterval(heartbeat);
|
|
266
|
+
console.error(`[${activeAgentName}] ❌ ${error.message}`);
|
|
267
|
+
await adapter.reply(ctx.threadId, `❌ ${activeAgentName} 错误: ${error.message}`);
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
threadAgent.delete(ctx.threadId);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const connected = await checkConnection();
|
|
276
|
+
if (!connected) { await adapter.reply(ctx.threadId, '❌ OpenCode 离线,请检查服务是否运行'); return; }
|
|
277
|
+
await fwdToOpenCode(adapter, ctx, text, openCodeSessions);
|
|
278
|
+
} finally { threadLock.delete(ctx.threadId); }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { handleMessage: handleMsg, forwardToOpenCode: fwdToOpenCode };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function formatLabOutput(out, subCmd) {
|
|
285
|
+
const trimmed = (out || '').trim();
|
|
286
|
+
if (!trimmed) return '(空)';
|
|
287
|
+
try {
|
|
288
|
+
const obj = JSON.parse(trimmed);
|
|
289
|
+
if (obj && typeof obj.total === 'number') return `总计 ${obj.total} · 待 ${obj.pending || 0} · 跑 ${obj.running || 0} · 完成 ${obj.done || 0} · 失败 ${obj.failed || 0}`;
|
|
290
|
+
} catch (e) { console.debug('[formatLabOutput] Not JSON:', e.message); }
|
|
291
|
+
const oneline = trimmed.replace(/\s+/g, ' ').trim();
|
|
292
|
+
return oneline.length > 1800 ? oneline.slice(0, 1800) + '...' : oneline;
|
|
293
|
+
}
|
package/dist/core/log.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// File-based logger with daily rotation
|
|
2
|
+
// Writes to ~/.opencode-remote/logs/bot-YYYY-MM-DD.log
|
|
3
|
+
import { existsSync, mkdirSync, statSync, createWriteStream, readdirSync, unlinkSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
|
|
7
|
+
const LOG_DIR = join(homedir(), '.opencode-remote', 'logs');
|
|
8
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB per file, rotate
|
|
9
|
+
const KEEP_FILES = 7; // 保留最近 7 天
|
|
10
|
+
|
|
11
|
+
let currentDate = '';
|
|
12
|
+
let currentStream = null;
|
|
13
|
+
let currentSize = 0;
|
|
14
|
+
|
|
15
|
+
function getDate() {
|
|
16
|
+
return new Date().toISOString().slice(0, 10);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ensureLogFile() {
|
|
20
|
+
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
|
21
|
+
const today = getDate();
|
|
22
|
+
if (currentDate === today && currentStream) return;
|
|
23
|
+
if (currentStream) {
|
|
24
|
+
try { currentStream.end(); } catch {}
|
|
25
|
+
}
|
|
26
|
+
currentDate = today;
|
|
27
|
+
const logPath = join(LOG_DIR, `bot-${today}.log`);
|
|
28
|
+
if (existsSync(logPath)) {
|
|
29
|
+
currentSize = statSync(logPath).size;
|
|
30
|
+
} else {
|
|
31
|
+
currentSize = 0;
|
|
32
|
+
}
|
|
33
|
+
currentStream = createWriteStream(logPath, { flags: 'a' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rotate() {
|
|
37
|
+
if (currentStream) {
|
|
38
|
+
try { currentStream.end(); } catch {}
|
|
39
|
+
currentStream = null;
|
|
40
|
+
}
|
|
41
|
+
ensureLogFile();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function format(level, msg, meta) {
|
|
45
|
+
const ts = new Date().toISOString();
|
|
46
|
+
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
|
|
47
|
+
return `${ts} [${level}] ${msg}${metaStr}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function write(level, msg, meta) {
|
|
51
|
+
try {
|
|
52
|
+
ensureLogFile();
|
|
53
|
+
const line = format(level, msg, meta);
|
|
54
|
+
if (currentSize + line.length > MAX_LOG_SIZE) {
|
|
55
|
+
rotate();
|
|
56
|
+
ensureLogFile();
|
|
57
|
+
}
|
|
58
|
+
currentStream.write(line);
|
|
59
|
+
currentSize += line.length;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// 写日志失败不能崩主进程
|
|
62
|
+
console.error('[log] Write failed:', e.message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const logger = {
|
|
67
|
+
info(msg, meta) { write('INFO', msg, meta); },
|
|
68
|
+
warn(msg, meta) { write('WARN', msg, meta); },
|
|
69
|
+
error(msg, meta) { write('ERROR', msg, meta); },
|
|
70
|
+
debug(msg, meta) { write('DEBUG', msg, meta); },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function initLogger() {
|
|
74
|
+
ensureLogFile();
|
|
75
|
+
console.log(`[log] Writing to ${LOG_DIR}/bot-${getDate()}.log`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 清理旧日志
|
|
79
|
+
export function cleanOldLogs() {
|
|
80
|
+
if (!existsSync(LOG_DIR)) return;
|
|
81
|
+
const files = readdirSync(LOG_DIR);
|
|
82
|
+
const today = new Date();
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
const m = f.match(/^bot-(\d{4}-\d{2}-\d{2})\.log$/);
|
|
85
|
+
if (!m) continue;
|
|
86
|
+
const fileDate = new Date(m[1]);
|
|
87
|
+
const daysAgo = Math.floor((today.getTime() - fileDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
88
|
+
if (daysAgo > KEEP_FILES) {
|
|
89
|
+
try { unlinkSync(join(LOG_DIR, f)); } catch {}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|