@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenCode Remote Control Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -156,6 +156,59 @@ A: 需要一台电脑运行 bot,手机上通过 IM 控制。OpenCode 也运行
|
|
|
156
156
|
**Q: 如何更新?**
|
|
157
157
|
A: `npm update -g @yvhitxcel/opencode-remote`
|
|
158
158
|
|
|
159
|
+
## 全局命令
|
|
160
|
+
|
|
161
|
+
安装后使用 `opencode-remote` 命令(不是 `remote-control`)。
|
|
162
|
+
|
|
163
|
+
### 命令原理
|
|
164
|
+
|
|
165
|
+
`package.json` 的 `bin` 字段声明了全局命令:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
"bin": {
|
|
169
|
+
"opencode-remote": "bin/opencode-remote.js",
|
|
170
|
+
"opencode-weixin": "bin/opencode-weixin.js"
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`npm install -g` 时,npm 在全局目录(Windows: `%APPDATA%\npm\`)创建三个包装脚本:
|
|
175
|
+
|
|
176
|
+
| 文件 | 作用 |
|
|
177
|
+
|------|------|
|
|
178
|
+
| `opencode-remote.cmd` | CMD 入口 |
|
|
179
|
+
| `opencode-remote.ps1` | PowerShell 入口 |
|
|
180
|
+
| `opencode-remote` | Unix Shell 入口 |
|
|
181
|
+
|
|
182
|
+
包装脚本调用 `node <pkg>/dist/cli.js <args>`。`cli.js` 内置了自动重启机制:
|
|
183
|
+
- 首次启动以父进程模式运行,`spawn` 子进程执行实际逻辑
|
|
184
|
+
- 子进程退出码 `200` 时父进程自动重启
|
|
185
|
+
|
|
186
|
+
### 多 Bot 实例
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
opencode-remote weixin # 启动微信
|
|
190
|
+
opencode-remote weixin --id bot1 # 多账号:第一个
|
|
191
|
+
opencode-remote weixin --id bot2 # 多账号:第二个
|
|
192
|
+
opencode-remote telegram # 启动 Telegram
|
|
193
|
+
opencode-remote feishu # 启动飞书
|
|
194
|
+
opencode-remote # 启动所有已配置的 Bot
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### 别名
|
|
198
|
+
|
|
199
|
+
如果习惯 `remote-control` 这个命令名,创建别名文件:
|
|
200
|
+
|
|
201
|
+
**PowerShell** (`$PROFILE`):
|
|
202
|
+
```powershell
|
|
203
|
+
Set-Alias -Name remote-control -Value opencode-remote
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**CMD** (`remote-control.cmd`):
|
|
207
|
+
```bat
|
|
208
|
+
@echo off
|
|
209
|
+
opencode-remote %*
|
|
210
|
+
```
|
|
211
|
+
|
|
159
212
|
## 系统要求
|
|
160
213
|
|
|
161
214
|
- Node.js >= 18.0.0
|
|
@@ -164,6 +217,22 @@ A: `npm update -g @yvhitxcel/opencode-remote`
|
|
|
164
217
|
|
|
165
218
|
本项目基于 [opencode-remote-control](https://github.com/ceociocto/opencode-remote-control) 开发。
|
|
166
219
|
|
|
220
|
+
## 文档
|
|
221
|
+
|
|
222
|
+
- [架构总览](docs/ARCHITECTURE.md) — 进程模型、模块图、消息流、错误传播理念
|
|
223
|
+
- [故障排查](docs/TROUBLESHOOTING.md) — 常见问题诊断与解决
|
|
224
|
+
- [配置说明](docs/CONFIG.md) — 环境变量、路径、超时矩阵
|
|
225
|
+
- [错误处理规范](docs/ERROR_HANDLING.md) — 错误处理契约,新代码必须遵循
|
|
226
|
+
- [快速开始](QUICKSTART.md) — 入门指南
|
|
227
|
+
|
|
228
|
+
## 质量保障
|
|
229
|
+
|
|
230
|
+
- ✅ TypeScript 类型检查 (`npm run typecheck`)
|
|
231
|
+
- ✅ 32 项自动化测试 (`npm test`)
|
|
232
|
+
- ✅ 多平台 CI(GitHub Actions: Ubuntu + Windows × Node 20/22)
|
|
233
|
+
- ✅ 容器化部署 (`docker compose up`)
|
|
234
|
+
- ✅ MIT 许可证
|
|
235
|
+
|
|
167
236
|
## 许可证
|
|
168
237
|
|
|
169
|
-
MIT License
|
|
238
|
+
MIT License — 详见 [LICENSE](LICENSE) 文件
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const pendingDecisions = new Map();
|
|
2
|
+
|
|
3
|
+
export function hasPendingDecision(threadId) {
|
|
4
|
+
return pendingDecisions.has(threadId);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveDecision(threadId, input) {
|
|
8
|
+
const pd = pendingDecisions.get(threadId);
|
|
9
|
+
if (!pd) return false;
|
|
10
|
+
const num = parseInt(input, 10);
|
|
11
|
+
if (isNaN(num) || num < 1 || num > pd.options.length) return false;
|
|
12
|
+
clearTimeout(pd.timer);
|
|
13
|
+
pendingDecisions.delete(threadId);
|
|
14
|
+
pd.resolve(num);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sendDecision({ adapter, threadId, question, options, recommended, timeoutMs = 5 * 60 * 1000, broadcastTo = [] }) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
if (pendingDecisions.has(threadId)) {
|
|
21
|
+
pendingDecisions.get(threadId).resolve(0);
|
|
22
|
+
clearTimeout(pendingDecisions.get(threadId).timer);
|
|
23
|
+
pendingDecisions.delete(threadId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const lines = [
|
|
27
|
+
`🤖 需要你决定:`,
|
|
28
|
+
`${question}`,
|
|
29
|
+
``,
|
|
30
|
+
];
|
|
31
|
+
options.forEach((opt, i) => {
|
|
32
|
+
const mark = i + 1 === recommended ? ' ✅ 推荐' : '';
|
|
33
|
+
lines.push(` ${i + 1}. ${opt}${mark}`);
|
|
34
|
+
});
|
|
35
|
+
const timeoutMin = Math.round(timeoutMs / 60000);
|
|
36
|
+
lines.push(``, `⏱ ${timeoutMin}分钟无回复自动选择推荐项`);
|
|
37
|
+
|
|
38
|
+
const msg = lines.join('\n');
|
|
39
|
+
adapter.reply(threadId, msg).catch(() => {});
|
|
40
|
+
|
|
41
|
+
const targets = new Set([threadId, ...broadcastTo]);
|
|
42
|
+
for (const tid of targets) {
|
|
43
|
+
if (tid !== threadId) {
|
|
44
|
+
adapter.reply(tid, msg).catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
if (pendingDecisions.has(threadId)) {
|
|
50
|
+
pendingDecisions.delete(threadId);
|
|
51
|
+
const choice = recommended;
|
|
52
|
+
const timeoutMsg = `⏰ 超时,自动选择推荐项: ${options[choice - 1]}`;
|
|
53
|
+
adapter.reply(threadId, timeoutMsg).catch(() => {});
|
|
54
|
+
for (const tid of targets) {
|
|
55
|
+
if (tid !== threadId) {
|
|
56
|
+
adapter.reply(tid, timeoutMsg).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
resolve(choice);
|
|
60
|
+
}
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
|
|
63
|
+
pendingDecisions.set(threadId, { resolve, timer, options, recommended });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cancelDecision(threadId, reason = '') {
|
|
68
|
+
const pd = pendingDecisions.get(threadId);
|
|
69
|
+
if (!pd) return;
|
|
70
|
+
clearTimeout(pd.timer);
|
|
71
|
+
pendingDecisions.delete(threadId);
|
|
72
|
+
pd.resolve(0);
|
|
73
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createSession, sendMessage } from '../opencode/client.js';
|
|
2
|
+
import { sendDecision, cancelDecision } from './decisions.js';
|
|
3
|
+
|
|
4
|
+
let autoLoopAbort = null;
|
|
5
|
+
let autoContext = null;
|
|
6
|
+
|
|
7
|
+
export function isAutoRunning() {
|
|
8
|
+
return autoLoopAbort !== null && !autoLoopAbort.signal.aborted;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function stopAutoLoop() {
|
|
12
|
+
if (autoLoopAbort) {
|
|
13
|
+
autoLoopAbort.abort();
|
|
14
|
+
autoLoopAbort = null;
|
|
15
|
+
}
|
|
16
|
+
autoContext = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function startAutoLoop({ adapter, threadId, goal, openCodeSessions, broadcastTo = [] }) {
|
|
20
|
+
stopAutoLoop();
|
|
21
|
+
autoLoopAbort = new AbortController();
|
|
22
|
+
const signal = autoLoopAbort.signal;
|
|
23
|
+
autoContext = { adapter, threadId, goal, openCodeSessions, broadcastTo };
|
|
24
|
+
|
|
25
|
+
const session = await createSession(`auto-${Date.now()}`, `自主研发: ${goal.slice(0, 40)}`);
|
|
26
|
+
if (!session) {
|
|
27
|
+
await adapter.reply(threadId, '❌ 无法创建自主研发会话');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
openCodeSessions.set(threadId, session);
|
|
31
|
+
|
|
32
|
+
const sysPrompt = `当前项目: 遥控器 (opencode-remote-control)
|
|
33
|
+
目标: ${goal}
|
|
34
|
+
|
|
35
|
+
你是自主开发模式,可以读取项目代码、修改文件、运行命令。
|
|
36
|
+
当遇到需要人类决定的选项时,在回复中包含以下格式:
|
|
37
|
+
|
|
38
|
+
[DECISION]
|
|
39
|
+
问题描述
|
|
40
|
+
1. 选项一
|
|
41
|
+
2. 选项二
|
|
42
|
+
推荐: 1
|
|
43
|
+
[DECISION]
|
|
44
|
+
|
|
45
|
+
除此之外,请自主推进工作。完成后输出: ✅ 任务完成: 简要总结`;
|
|
46
|
+
|
|
47
|
+
const taskRunner = async () => {
|
|
48
|
+
try {
|
|
49
|
+
await adapter.reply(threadId, `🤖 开始自主开发: ${goal}`);
|
|
50
|
+
let fullText = '';
|
|
51
|
+
await sendMessage(session, sysPrompt, {
|
|
52
|
+
onResponseMeta: (meta) => {
|
|
53
|
+
if (meta.providerID && meta.modelID) {
|
|
54
|
+
console.log(`[auto] using ${meta.providerID}/${meta.modelID}`);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
onText: (text) => {
|
|
58
|
+
fullText += text;
|
|
59
|
+
const decisionMatch = fullText.match(/\[DECISION\]([\s\S]*?)\[\/DECISION\]/);
|
|
60
|
+
if (decisionMatch) {
|
|
61
|
+
throw new DecisionRequired(decisionMatch[1].trim());
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}, threadId);
|
|
65
|
+
|
|
66
|
+
await adapter.reply(threadId, `✅ 自主开发完成`);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (e.name === 'DecisionRequired') {
|
|
69
|
+
await handleDecision(e.message);
|
|
70
|
+
} else if (!signal.aborted) {
|
|
71
|
+
console.error('[auto] error:', e.message);
|
|
72
|
+
await adapter.reply(threadId, `❌ 自主开发出错: ${e.message}`);
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
if (!signal.aborted) {
|
|
76
|
+
autoLoopAbort = null;
|
|
77
|
+
autoContext = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
taskRunner();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class DecisionRequired extends Error {
|
|
86
|
+
constructor(text) { super(text); this.name = 'DecisionRequired'; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function handleDecision(decisionText) {
|
|
90
|
+
const ctx = autoContext;
|
|
91
|
+
if (!ctx || ctx.signal?.aborted) return;
|
|
92
|
+
const { adapter, threadId, broadcastTo } = ctx;
|
|
93
|
+
|
|
94
|
+
const lines = decisionText.split('\n').map(l => l.trim()).filter(Boolean);
|
|
95
|
+
const question = lines[0] || '请选择';
|
|
96
|
+
const options = [];
|
|
97
|
+
let recommended = 1;
|
|
98
|
+
for (const l of lines) {
|
|
99
|
+
const optMatch = l.match(/^(\d+)\.\s*(.+)/);
|
|
100
|
+
if (optMatch) {
|
|
101
|
+
options.push(optMatch[2]);
|
|
102
|
+
if (optMatch[1]) recommended = parseInt(optMatch[1], 10);
|
|
103
|
+
}
|
|
104
|
+
const recMatch = l.match(/推荐:\s*(\d+)/);
|
|
105
|
+
if (recMatch) recommended = parseInt(recMatch[1], 10);
|
|
106
|
+
}
|
|
107
|
+
if (options.length === 0) {
|
|
108
|
+
options.push('继续');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const choice = await sendDecision({ adapter, threadId, question, options, recommended, timeoutMs: 5 * 60 * 1000, broadcastTo });
|
|
112
|
+
if (ctx.signal?.aborted) return;
|
|
113
|
+
|
|
114
|
+
if (choice === 0) {
|
|
115
|
+
await adapter.reply(threadId, '⏹ 决策取消,自主开发停止');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const chosen = options[choice - 1];
|
|
120
|
+
const userChoice = `选择了: ${choice}. ${chosen}`;
|
|
121
|
+
|
|
122
|
+
const { session } = ctx;
|
|
123
|
+
sendMessage(session, userChoice, {
|
|
124
|
+
onText: (text) => {
|
|
125
|
+
const decisionMatch = text.match(/\[DECISION\]([\s\S]*?)\[\/DECISION\]/);
|
|
126
|
+
if (decisionMatch) {
|
|
127
|
+
throw new DecisionRequired(decisionMatch[1].trim());
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
}, threadId).then(async (result) => {
|
|
131
|
+
if (result && result.includes('✅')) {
|
|
132
|
+
await adapter.reply(threadId, `✅ 自主开发完成`);
|
|
133
|
+
}
|
|
134
|
+
}).catch(async (e) => {
|
|
135
|
+
if (e.name === 'DecisionRequired') {
|
|
136
|
+
await handleDecision(e.message);
|
|
137
|
+
} else if (!ctx.signal?.aborted) {
|
|
138
|
+
console.error('[auto] post-decision error:', e.message);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// OpenCode Remote Control - CLI entry point
|
|
3
|
-
|
|
4
|
-
import {
|
|
3
|
+
// @ts-nocheck — process.env spread has type issues with strict @types/node
|
|
4
|
+
import { watch, existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
5
7
|
import { fileURLToPath } from 'url';
|
|
6
|
-
import { spawn } from 'child_process';
|
|
8
|
+
import { spawn, execSync } from 'child_process';
|
|
9
|
+
import { createServer } from 'http';
|
|
7
10
|
import { setGlobalProxy } from './opencode/client.js';
|
|
8
11
|
import { printBanner, VERSION, runConfig, runConfigTimeout } from './core/setup.js';
|
|
9
12
|
import { runStart, runTelegramOnly, runFeishuOnly, runWeixinOnly, runAgentsCommand } from './bot-runner.js';
|
|
@@ -47,17 +50,9 @@ Multi-Bot Support:
|
|
|
47
50
|
Weixin Bot Commands (send in WeChat):
|
|
48
51
|
/start — Claim ownership
|
|
49
52
|
/help — Show all commands
|
|
50
|
-
/status — Check connection
|
|
51
|
-
/stop — Interrupt task
|
|
52
53
|
/reset — Reset session
|
|
53
54
|
/restart — Restart bot
|
|
54
|
-
/
|
|
55
|
-
/delsessions — Delete sessions
|
|
56
|
-
/loop — Loop task
|
|
57
|
-
/refresh — Refresh context
|
|
58
|
-
/copy — Copy latest reply
|
|
59
|
-
/revert — Undo last message
|
|
60
|
-
/upload — Upload build artifacts
|
|
55
|
+
/diagnose — System diagnostics
|
|
61
56
|
/model — Switch AI model
|
|
62
57
|
|
|
63
58
|
Multi-Agent Commands:
|
|
@@ -65,7 +60,6 @@ Multi-Agent Commands:
|
|
|
65
60
|
/cc <prompt> — Use Claude Code
|
|
66
61
|
/cx <prompt> — Use Codex
|
|
67
62
|
/copilot <prompt> — Use GitHub Copilot
|
|
68
|
-
/agents — List all available agents
|
|
69
63
|
|
|
70
64
|
Examples:
|
|
71
65
|
opencode-remote # Start all bots
|
|
@@ -85,11 +79,79 @@ Examples:
|
|
|
85
79
|
// Main CLI
|
|
86
80
|
// 父进程管理:如果不是子进程,则启动父进程模式
|
|
87
81
|
if (process.env.OPENCODE_CHILD !== '1') {
|
|
82
|
+
process.on('unhandledRejection', (reason) => { console.error('[parent] Unhandled Rejection:', reason); });
|
|
83
|
+
|
|
84
|
+
// PID 文件锁:确保只有一个父进程实例
|
|
85
|
+
const PID_FILE = join(homedir(), '.opencode-remote', 'parent.pid');
|
|
86
|
+
try {
|
|
87
|
+
if (existsSync(PID_FILE)) {
|
|
88
|
+
const oldPid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
89
|
+
if (oldPid && oldPid !== process.pid) {
|
|
90
|
+
try { process.kill(oldPid, 'SIGTERM'); } catch { console.debug('[pid] old process already dead'); }
|
|
91
|
+
try { execSync(`taskkill /F /T /PID ${oldPid}`, { timeout: 2000 }); } catch { console.debug('[pid] taskkill failed'); }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (e) { console.debug('[pid] Failed to read PID file:', e.message); }
|
|
95
|
+
try { writeFileSync(PID_FILE, String(process.pid), 'utf8'); } catch (e) { console.debug('[pid] Failed to write PID file:', e.message); }
|
|
96
|
+
process.on('exit', () => { try { unlinkSync(PID_FILE); } catch {} });
|
|
97
|
+
|
|
98
|
+
// Health check HTTP server (for Docker healthcheck / monitoring)
|
|
99
|
+
function startHealthServer() {
|
|
100
|
+
const port = parseInt(process.env.HEALTH_PORT || '9090', 10);
|
|
101
|
+
if (isNaN(port) || port < 1 || port > 65535) return;
|
|
102
|
+
const server = createServer((req, res) => {
|
|
103
|
+
if (req.url !== '/health' && req.url !== '/') {
|
|
104
|
+
res.writeHead(404); res.end('Not found');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const childAlive = childProc && childProc.exitCode === null && childProc.killed === false;
|
|
108
|
+
const hbOk = Date.now() - lastHeartbeatTs < 120_000;
|
|
109
|
+
const healthy = childAlive && hbOk && !shuttingDown;
|
|
110
|
+
const status = healthy ? 200 : 503;
|
|
111
|
+
const body = JSON.stringify({
|
|
112
|
+
status: healthy ? 'ok' : 'degraded',
|
|
113
|
+
pid: process.pid,
|
|
114
|
+
uptime: process.uptime(),
|
|
115
|
+
childAlive,
|
|
116
|
+
lastHeartbeatAgo: Date.now() - lastHeartbeatTs,
|
|
117
|
+
shuttingDown,
|
|
118
|
+
});
|
|
119
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
120
|
+
res.end(body);
|
|
121
|
+
});
|
|
122
|
+
server.listen(port, '127.0.0.1', () => {
|
|
123
|
+
console.log(`[health] HTTP health endpoint at http://127.0.0.1:${port}/health`);
|
|
124
|
+
});
|
|
125
|
+
server.unref();
|
|
126
|
+
}
|
|
127
|
+
|
|
88
128
|
let childProc = null;
|
|
89
129
|
let shuttingDown = false;
|
|
90
130
|
let isRestart = false;
|
|
131
|
+
let crashCount = 0;
|
|
132
|
+
let lastCrashTs = 0;
|
|
133
|
+
let lastSpawnTs = 0;
|
|
134
|
+
let lastHeartbeatTs = Date.now();
|
|
135
|
+
let heartbeatCheckTimer = null;
|
|
136
|
+
|
|
137
|
+
function cleanupPorts() {
|
|
138
|
+
for (const port of [4096, 4097, 4098]) {
|
|
139
|
+
try {
|
|
140
|
+
const out = execSync(`netstat -ano | findstr ":${port} "`, { timeout: 3000 });
|
|
141
|
+
for (const line of out.toString().trim().split('\n')) {
|
|
142
|
+
const parts = line.trim().split(/\s+/);
|
|
143
|
+
const pid = parts[parts.length - 1];
|
|
144
|
+
if (pid && pid !== '0') {
|
|
145
|
+
try { execSync(`taskkill /F /PID ${pid}`, { timeout: 2000 }); } catch { console.debug('[cleanup] kill failed (already dead?)', pid); }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch { console.debug('[cleanup] no process on port', port); }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
91
151
|
|
|
92
152
|
const spawnChild = (fromRestart = false) => {
|
|
153
|
+
cleanupPorts();
|
|
154
|
+
lastSpawnTs = Date.now();
|
|
93
155
|
if (shuttingDown) return;
|
|
94
156
|
if (childProc) {
|
|
95
157
|
try { childProc.kill('SIGTERM'); } catch {}
|
|
@@ -110,30 +172,68 @@ if (process.env.OPENCODE_CHILD !== '1') {
|
|
|
110
172
|
childProc.stderr.on('data', (d) => process.stderr.write(d));
|
|
111
173
|
|
|
112
174
|
childProc.on('close', (code) => {
|
|
113
|
-
|
|
175
|
+
const wasSignal = code === null;
|
|
176
|
+
console.log(`[parent] Child process closed with code: ${code}${wasSignal ? ' (signal)' : ''}`);
|
|
114
177
|
if (shuttingDown) {
|
|
115
178
|
console.log('[parent] Shutting down, not restarting');
|
|
116
179
|
return;
|
|
117
180
|
}
|
|
118
|
-
|
|
119
|
-
|
|
181
|
+
|
|
182
|
+
// 区分:200=主动重启请求, null=信号杀, 其他=异常退出
|
|
183
|
+
if (code === 200) {
|
|
184
|
+
// 主动 /restart
|
|
120
185
|
isRestart = true;
|
|
121
186
|
setTimeout(() => spawnChild(true), 1000);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 崩溃检测: 60 秒内连续多次崩溃 → 不再重启 (避免崩循环)
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (now - lastCrashTs < 60_000) {
|
|
193
|
+
crashCount++;
|
|
122
194
|
} else {
|
|
123
|
-
|
|
195
|
+
crashCount = 1;
|
|
196
|
+
}
|
|
197
|
+
lastCrashTs = now;
|
|
198
|
+
|
|
199
|
+
if (crashCount >= 5) {
|
|
200
|
+
console.error(`[parent] ${crashCount} crashes in 60s, giving up. Manual restart required.`);
|
|
201
|
+
process.exit(1);
|
|
124
202
|
}
|
|
203
|
+
|
|
204
|
+
// 退避重启: 1s, 2s, 4s, 8s
|
|
205
|
+
const backoff = Math.min(8000, 1000 * Math.pow(2, crashCount - 1));
|
|
206
|
+
console.log(`[parent] Bot ${wasSignal ? 'killed by signal' : `crashed (code ${code})`}, restarting in ${backoff}ms (crash #${crashCount})`);
|
|
207
|
+
isRestart = true;
|
|
208
|
+
setTimeout(() => spawnChild(true), backoff);
|
|
125
209
|
});
|
|
126
210
|
|
|
127
211
|
childProc.on('error', (err) => {
|
|
128
212
|
console.error('[parent] Child error:', err.message);
|
|
129
213
|
});
|
|
214
|
+
|
|
215
|
+
// IPC 心跳:子进程每 30s 发心跳,超过 120s 无心跳视为卡死
|
|
216
|
+
childProc.on('message', (msg) => {
|
|
217
|
+
if (msg?.type === 'heartbeat') lastHeartbeatTs = Date.now();
|
|
218
|
+
});
|
|
219
|
+
lastHeartbeatTs = Date.now();
|
|
220
|
+
clearInterval(heartbeatCheckTimer);
|
|
221
|
+
heartbeatCheckTimer = setInterval(() => {
|
|
222
|
+
if (shuttingDown) return;
|
|
223
|
+
if (Date.now() - lastHeartbeatTs > 120_000) {
|
|
224
|
+
console.error('[parent] No heartbeat for 120s, killing stuck child...');
|
|
225
|
+
isRestart = true;
|
|
226
|
+
try { childProc.kill('SIGKILL'); } catch {}
|
|
227
|
+
}
|
|
228
|
+
}, 30_000);
|
|
229
|
+
if (heartbeatCheckTimer.unref) heartbeatCheckTimer.unref();
|
|
130
230
|
};
|
|
131
231
|
|
|
132
|
-
// 文件监控 - 代码变化时自动重启
|
|
232
|
+
// 文件监控 - 代码变化时自动重启 (spawn 后 3s 静默, 避免重启循环)
|
|
133
233
|
const distDir = __dirname;
|
|
134
234
|
let debounceTimer = null;
|
|
135
235
|
watch(distDir, { recursive: true }, (eventType, filename) => {
|
|
136
|
-
if (filename && filename.endsWith('.js') && !shuttingDown) {
|
|
236
|
+
if (filename && filename.endsWith('.js') && !shuttingDown && Date.now() - lastSpawnTs > 3000) {
|
|
137
237
|
clearTimeout(debounceTimer);
|
|
138
238
|
debounceTimer = setTimeout(() => {
|
|
139
239
|
console.log(`\n📝 ${filename} changed, restarting...`);
|
|
@@ -145,6 +245,8 @@ if (process.env.OPENCODE_CHILD !== '1') {
|
|
|
145
245
|
}
|
|
146
246
|
});
|
|
147
247
|
|
|
248
|
+
startHealthServer();
|
|
249
|
+
|
|
148
250
|
process.on('SIGINT', () => {
|
|
149
251
|
if (shuttingDown) return;
|
|
150
252
|
shuttingDown = true;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// BotAdapter interface — all platform adapters must implement these methods
|
|
2
|
+
// reply(threadId, text) — send a text message to user
|
|
3
|
+
// sendTypingIndicator(threadId) — show typing indicator (start)
|
|
4
|
+
// sendTypingEnd?(threadId) — stop typing indicator [optional]
|
|
5
|
+
// updateMessage?(threadId, msgId, text) — edit a message [optional]
|
|
6
|
+
// deleteMessage?(threadId, msgId) — delete a message [optional]
|
|
7
|
+
// platform — string identifier ('weixin'|'feishu'|'telegram')
|
|
8
|
+
|
|
9
|
+
export {};
|
|
10
|
+
|
|
11
|
+
// This module only exists as the contract reference.
|
|
12
|
+
// Each platform adapter (weixin/adapter.js etc.) independently satisfies this shape.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Global registry of running agent/CLI processes
|
|
2
|
+
// /esc uses this to kill the actual subprocess, not just abort the SDK session
|
|
3
|
+
import { logger } from './log.js';
|
|
4
|
+
|
|
5
|
+
const _registry = new Map(); // threadId -> { process, agentName, killed, killTimer }
|
|
6
|
+
|
|
7
|
+
export function registerAgentProcess(threadId, proc, agentName) {
|
|
8
|
+
// 若已存在先杀掉旧的
|
|
9
|
+
const old = _registry.get(threadId);
|
|
10
|
+
if (old && !old.killed) {
|
|
11
|
+
try { old.process.kill('SIGKILL'); } catch {}
|
|
12
|
+
}
|
|
13
|
+
_registry.set(threadId, { process: proc, agentName, killed: false, killTimer: null });
|
|
14
|
+
logger.info('agent-process:registered', { threadId, agentName, pid: proc.pid });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function unregisterAgentProcess(threadId) {
|
|
18
|
+
const e = _registry.get(threadId);
|
|
19
|
+
if (!e) return;
|
|
20
|
+
if (e.killTimer) clearTimeout(e.killTimer);
|
|
21
|
+
_registry.delete(threadId);
|
|
22
|
+
logger.info('agent-process:unregistered', { threadId });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Kill the running agent process for a thread.
|
|
27
|
+
* @param {string} threadId
|
|
28
|
+
* @param {number} forceAfterMs - if process doesn't die, SIGKILL after this many ms
|
|
29
|
+
* @returns {{ killed: boolean, agentName: string|null }}
|
|
30
|
+
*/
|
|
31
|
+
export function killAgentProcess(threadId, forceAfterMs = 3000) {
|
|
32
|
+
const e = _registry.get(threadId);
|
|
33
|
+
if (!e) return { killed: false, agentName: null };
|
|
34
|
+
if (e.killed) return { killed: true, agentName: e.agentName };
|
|
35
|
+
e.killed = true;
|
|
36
|
+
try {
|
|
37
|
+
e.process.kill('SIGTERM');
|
|
38
|
+
logger.warn('agent-process:sigterm', { threadId, agentName: e.agentName, pid: e.process.pid });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
logger.error('agent-process:sigterm-failed', { threadId, error: err.message });
|
|
41
|
+
}
|
|
42
|
+
e.killTimer = setTimeout(() => {
|
|
43
|
+
try {
|
|
44
|
+
e.process.kill('SIGKILL');
|
|
45
|
+
logger.warn('agent-process:sigkill', { threadId, agentName: e.agentName });
|
|
46
|
+
} catch {}
|
|
47
|
+
}, forceAfterMs);
|
|
48
|
+
return { killed: true, agentName: e.agentName };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getAgentProcess(threadId) {
|
|
52
|
+
return _registry.get(threadId) || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listAgentProcesses() {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const [tid, e] of _registry.entries()) {
|
|
58
|
+
out.push({ threadId: tid, agentName: e.agentName, pid: e.process.pid, killed: e.killed });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Kill ALL registered agent processes (used during graceful shutdown).
|
|
65
|
+
* Sends SIGTERM first, escalates to SIGKILL after 1s to avoid hanging on stuck children.
|
|
66
|
+
* @param {number} forceAfterMs
|
|
67
|
+
* @returns {Array<{ threadId: string, agentName: string, killed: boolean }>}
|
|
68
|
+
*/
|
|
69
|
+
export function killAllAgentProcesses(forceAfterMs = 1000) {
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const [tid, e] of _registry.entries()) {
|
|
72
|
+
if (e.killed) continue;
|
|
73
|
+
const r = killAgentProcess(tid, forceAfterMs);
|
|
74
|
+
results.push({ threadId: tid, agentName: r.agentName || e.agentName, killed: r.killed });
|
|
75
|
+
}
|
|
76
|
+
return results;
|
|
77
|
+
}
|