@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 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
- import { watch } from 'fs';
4
- import { dirname } from 'path';
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
- /sessionsBrowse sessions
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
+ /diagnoseSystem 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
- console.log(`[parent] Child process closed with code: ${code}`);
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
- if (code === 200 || code === null) {
119
- console.log(`\n🔄 Bot exited (code ${code}), restarting...`);
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
- console.log(`[parent] Bot exited with code ${code}, not restarting`);
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
+ }