cc2im 0.2.0 → 0.2.2
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/README.md +14 -0
- package/dist/cli.js +23 -16
- package/dist/hub/agent-manager.d.ts +9 -0
- package/dist/hub/agent-manager.js +81 -5
- package/dist/plugins/channel-manager/index.js +75 -1
- package/dist/plugins/weixin/connection.d.ts +6 -0
- package/dist/plugins/weixin/connection.js +44 -12
- package/dist/plugins/weixin/weixin-channel.js +9 -0
- package/dist/spoke/index.js +2 -1
- package/dist/spoke/socket-client.d.ts +4 -0
- package/dist/spoke/socket-client.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,6 +115,20 @@ cc2im install
|
|
|
115
115
|
|
|
116
116
|
cc2im 为**单用户场景**设计(一个人通过微信控制自己的多个 agent)。多用户并发时,权限审批和回复路由可能出现竞争。
|
|
117
117
|
|
|
118
|
+
## 更新日志
|
|
119
|
+
|
|
120
|
+
### v0.2.2 (2026-04-20)
|
|
121
|
+
|
|
122
|
+
- **fix**: CC 启动时若 `--continue` 触发 "Resume from summary" 会话选择器,expect 脚本现在会自动选第一项;新增 60s 连接超时兜底,卡住时自动 kill 并用新会话重试
|
|
123
|
+
- **fix**: 每个微信 channel 使用独立凭证文件(`credentials-{channelId}.json`),不再互相覆盖,解决新增第二个账号导致第一个掉线的问题
|
|
124
|
+
|
|
125
|
+
### v0.2.1 (2026-04-07)
|
|
126
|
+
|
|
127
|
+
- 新用户首次启动引导文案优化
|
|
128
|
+
- 休眠/唤醒后自动重连微信,连续 poll 超时时触发重连
|
|
129
|
+
- Hub 启动时清理孤儿 agent 进程
|
|
130
|
+
- 重启后通过 `--continue` 恢复 agent 的最近 session
|
|
131
|
+
|
|
118
132
|
## License
|
|
119
133
|
|
|
120
134
|
[MIT](./LICENSE)
|
package/dist/cli.js
CHANGED
|
@@ -47,20 +47,18 @@ function loadAgentsJson() {
|
|
|
47
47
|
function ensureDefaultConfig() {
|
|
48
48
|
ensureSocketDir();
|
|
49
49
|
if (!existsSync(AGENTS_JSON_PATH)) {
|
|
50
|
-
const defaultConfig = {
|
|
51
|
-
defaultAgent: 'brain',
|
|
52
|
-
agents: {
|
|
53
|
-
brain: {
|
|
54
|
-
name: 'brain',
|
|
55
|
-
cwd: join(homedir(), 'brain'),
|
|
56
|
-
claudeArgs: ['--effort', 'max'],
|
|
57
|
-
createdAt: new Date().toISOString().split('T')[0],
|
|
58
|
-
autoStart: true,
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
};
|
|
50
|
+
const defaultConfig = { defaultAgent: '', agents: {} };
|
|
62
51
|
writeFileSync(AGENTS_JSON_PATH, JSON.stringify(defaultConfig, null, 2) + '\n');
|
|
63
|
-
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function checkAgentsExist() {
|
|
55
|
+
const config = loadAgentsJson();
|
|
56
|
+
if (!config || Object.keys(config.agents).length === 0) {
|
|
57
|
+
console.log(`\n No agents registered yet. Quick setup:\n`);
|
|
58
|
+
console.log(` 1. cc2im login # WeChat QR login`);
|
|
59
|
+
console.log(` 2. cc2im agent register <name> <dir> # e.g. cc2im agent register mybot ~/projects/mybot`);
|
|
60
|
+
console.log(` 3. cc2im start # start hub + agents\n`);
|
|
61
|
+
process.exit(1);
|
|
64
62
|
}
|
|
65
63
|
}
|
|
66
64
|
// --- Commands ---
|
|
@@ -118,11 +116,13 @@ async function login() {
|
|
|
118
116
|
}
|
|
119
117
|
async function runHub() {
|
|
120
118
|
ensureDefaultConfig();
|
|
119
|
+
checkAgentsExist();
|
|
121
120
|
const { startHub } = await import('./hub/index.js');
|
|
122
121
|
await startHub({ autoStartAgents: false });
|
|
123
122
|
}
|
|
124
123
|
async function runStart() {
|
|
125
124
|
ensureDefaultConfig();
|
|
125
|
+
checkAgentsExist();
|
|
126
126
|
console.log('[cc2im] Starting hub + auto-start agents...');
|
|
127
127
|
const { startHub } = await import('./hub/index.js');
|
|
128
128
|
await startHub({ autoStartAgents: true });
|
|
@@ -167,15 +167,22 @@ function agentRegister(name, cwd) {
|
|
|
167
167
|
console.error(`Directory "${cwd}" does not exist`);
|
|
168
168
|
process.exit(1);
|
|
169
169
|
}
|
|
170
|
+
const isFirst = Object.keys(config.agents).length === 0;
|
|
170
171
|
config.agents[name] = {
|
|
171
172
|
name,
|
|
172
173
|
cwd,
|
|
173
174
|
claudeArgs: [],
|
|
174
175
|
createdAt: new Date().toISOString().split('T')[0],
|
|
175
|
-
autoStart:
|
|
176
|
+
autoStart: isFirst,
|
|
176
177
|
};
|
|
178
|
+
if (isFirst || !config.defaultAgent) {
|
|
179
|
+
config.defaultAgent = name;
|
|
180
|
+
}
|
|
177
181
|
writeFileSync(AGENTS_JSON_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
178
182
|
console.log(`[cc2im] Registered agent "${name}" → ${cwd}`);
|
|
183
|
+
if (isFirst) {
|
|
184
|
+
console.log(`[cc2im] Set as default agent (autoStart: true)`);
|
|
185
|
+
}
|
|
179
186
|
}
|
|
180
187
|
function agentDeregister(name) {
|
|
181
188
|
ensureDefaultConfig();
|
|
@@ -214,7 +221,7 @@ const arg = process.argv[4];
|
|
|
214
221
|
switch (command) {
|
|
215
222
|
case '--version':
|
|
216
223
|
case '-v':
|
|
217
|
-
console.log('cc2im v0.
|
|
224
|
+
console.log('cc2im v0.2.0');
|
|
218
225
|
break;
|
|
219
226
|
case 'login':
|
|
220
227
|
await login();
|
|
@@ -294,7 +301,7 @@ switch (command) {
|
|
|
294
301
|
case '--help':
|
|
295
302
|
case '-h':
|
|
296
303
|
default:
|
|
297
|
-
console.log(`cc2im v0.
|
|
304
|
+
console.log(`cc2im v0.2.0 — IM gateway for multiple Claude Code instances
|
|
298
305
|
|
|
299
306
|
Usage:
|
|
300
307
|
cc2im login 微信扫码登录
|
|
@@ -11,7 +11,16 @@ export declare class AgentManager {
|
|
|
11
11
|
private stoppedManually;
|
|
12
12
|
private shuttingDown;
|
|
13
13
|
private restartAttempts;
|
|
14
|
+
private skipContinueOnce;
|
|
14
15
|
constructor(getConnectedAgents: () => string[], onEvent?: (kind: string, agentId: string, extra?: Record<string, any>) => void);
|
|
16
|
+
/**
|
|
17
|
+
* Kill orphan agent processes from a previous hub session.
|
|
18
|
+
* Reads PGIDs saved by the previous hub, kills any still-alive process groups,
|
|
19
|
+
* then clears the file. Called once in constructor before any agents are started.
|
|
20
|
+
*/
|
|
21
|
+
private killOrphanProcesses;
|
|
22
|
+
/** Persist current agent PGIDs to disk for orphan cleanup on next startup. */
|
|
23
|
+
private savePgids;
|
|
15
24
|
private loadConfig;
|
|
16
25
|
private saveConfig;
|
|
17
26
|
getConfig(): AgentsConfig;
|
|
@@ -8,10 +8,14 @@ import { spawn } from 'node:child_process';
|
|
|
8
8
|
import { SOCKET_DIR } from '../shared/socket.js';
|
|
9
9
|
import { ensureMcpJson } from '../shared/mcp-config.js';
|
|
10
10
|
const AGENTS_JSON_PATH = join(SOCKET_DIR, 'agents.json');
|
|
11
|
+
const PGID_FILE_PATH = join(SOCKET_DIR, 'agent-pgids.json');
|
|
11
12
|
const STOP_TIMEOUT_MS = 5000;
|
|
12
13
|
const RESTART_DELAY_MS = 5000;
|
|
13
14
|
const MAX_RESTART_ATTEMPTS = 5;
|
|
14
15
|
const RESTART_WINDOW_MS = 5 * 60_000; // 5 min — reset counter if stable for this long
|
|
16
|
+
const CONNECT_TIMEOUT_MS = 60_000; // if spoke doesn't connect within this window after spawn,
|
|
17
|
+
// assume init got stuck (e.g. unknown interactive prompt)
|
|
18
|
+
// and restart without --continue to unblock
|
|
15
19
|
export class AgentManager {
|
|
16
20
|
processes = new Map();
|
|
17
21
|
config;
|
|
@@ -20,10 +24,54 @@ export class AgentManager {
|
|
|
20
24
|
stoppedManually = new Set(); // agents stopped by user intent
|
|
21
25
|
shuttingDown = false; // suppress auto-restart during hub shutdown
|
|
22
26
|
restartAttempts = new Map(); // backoff tracking
|
|
27
|
+
skipContinueOnce = new Set(); // agents to start without --continue on next spawn
|
|
28
|
+
// (used when previous session stalled during init)
|
|
23
29
|
constructor(getConnectedAgents, onEvent) {
|
|
24
30
|
this.config = this.loadConfig();
|
|
25
31
|
this.getConnectedAgents = getConnectedAgents;
|
|
26
32
|
this.onEvent = onEvent;
|
|
33
|
+
this.killOrphanProcesses();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Kill orphan agent processes from a previous hub session.
|
|
37
|
+
* Reads PGIDs saved by the previous hub, kills any still-alive process groups,
|
|
38
|
+
* then clears the file. Called once in constructor before any agents are started.
|
|
39
|
+
*/
|
|
40
|
+
killOrphanProcesses() {
|
|
41
|
+
try {
|
|
42
|
+
if (!existsSync(PGID_FILE_PATH))
|
|
43
|
+
return;
|
|
44
|
+
const pgids = JSON.parse(readFileSync(PGID_FILE_PATH, 'utf8'));
|
|
45
|
+
let killed = 0;
|
|
46
|
+
for (const [name, pgid] of Object.entries(pgids)) {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(-pgid, 'SIGKILL'); // kill entire process group
|
|
49
|
+
killed++;
|
|
50
|
+
console.log(`[agent-manager] Killed orphan "${name}" (pgid ${pgid})`);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// ESRCH = process group doesn't exist (already dead) — expected
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (killed > 0) {
|
|
57
|
+
console.log(`[agent-manager] Cleaned up ${killed} orphan process group(s)`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
// Clear the file — we'll write fresh PGIDs as agents start
|
|
62
|
+
this.savePgids();
|
|
63
|
+
}
|
|
64
|
+
/** Persist current agent PGIDs to disk for orphan cleanup on next startup. */
|
|
65
|
+
savePgids() {
|
|
66
|
+
const pgids = {};
|
|
67
|
+
for (const [name, child] of this.processes) {
|
|
68
|
+
if (child.pid)
|
|
69
|
+
pgids[name] = child.pid; // detached child.pid === pgid
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(PGID_FILE_PATH, JSON.stringify(pgids) + '\n');
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
27
75
|
}
|
|
28
76
|
loadConfig() {
|
|
29
77
|
if (existsSync(AGENTS_JSON_PATH)) {
|
|
@@ -102,11 +150,14 @@ export class AgentManager {
|
|
|
102
150
|
// Ensure agent log directory
|
|
103
151
|
const agentDir = join(SOCKET_DIR, 'agents', name);
|
|
104
152
|
mkdirSync(agentDir, { recursive: true });
|
|
105
|
-
//
|
|
106
|
-
const
|
|
153
|
+
// Skip --continue if the previous spawn stalled during init — start fresh instead
|
|
154
|
+
const skipContinue = this.skipContinueOnce.delete(name);
|
|
155
|
+
if (skipContinue) {
|
|
156
|
+
console.log(`[agent-manager] "${name}": starting without --continue (previous session stalled during init)`);
|
|
157
|
+
}
|
|
107
158
|
const claudeArgs = [
|
|
108
159
|
'--dangerously-load-development-channels', 'server:cc2im',
|
|
109
|
-
...(
|
|
160
|
+
...(skipContinue ? [] : ['--continue']), // resume most recent session unless last attempt stalled
|
|
110
161
|
...(agent.claudeArgs || []),
|
|
111
162
|
];
|
|
112
163
|
// Use `expect` to allocate a pseudo-tty so CC enters interactive mode.
|
|
@@ -118,12 +169,22 @@ export class AgentManager {
|
|
|
118
169
|
`log_file -a {${logPath}}`,
|
|
119
170
|
`spawn claude ${claudeArgs.map(a => `{${a}}`).join(' ')}`,
|
|
120
171
|
'',
|
|
121
|
-
'# Auto-
|
|
122
|
-
'
|
|
172
|
+
'# Auto-handle initialization prompts:',
|
|
173
|
+
'# - Workspace trust prompt ("confirm" text)',
|
|
174
|
+
'# - "Resume from summary" session picker (shown by --continue for old/large sessions)',
|
|
175
|
+
'# exp_continue keeps listening so multiple prompts in sequence are all handled.',
|
|
176
|
+
'# If 60s passes with no further known prompts, assume CC is up and switch to eof wait.',
|
|
177
|
+
'set timeout 60',
|
|
123
178
|
'expect {',
|
|
179
|
+
' "Resume from summary" {',
|
|
180
|
+
' after 500',
|
|
181
|
+
' send "1\\r"',
|
|
182
|
+
' exp_continue',
|
|
183
|
+
' }',
|
|
124
184
|
' "confirm" {',
|
|
125
185
|
' after 500',
|
|
126
186
|
' send "\\r"',
|
|
187
|
+
' exp_continue',
|
|
127
188
|
' }',
|
|
128
189
|
' timeout {}',
|
|
129
190
|
'}',
|
|
@@ -144,9 +205,23 @@ export class AgentManager {
|
|
|
144
205
|
stdio: ['pipe', 'ignore', 'ignore'],
|
|
145
206
|
detached: true, // new process group — allows killing entire tree with -pid
|
|
146
207
|
});
|
|
208
|
+
// Fallback: if spoke doesn't connect within CONNECT_TIMEOUT_MS, assume init got stuck
|
|
209
|
+
// (e.g. an unknown interactive prompt blocked CC) and kill the process tree.
|
|
210
|
+
// On next auto-restart, --continue is skipped so CC starts with a fresh session.
|
|
211
|
+
const connectTimer = setTimeout(() => {
|
|
212
|
+
if (!this.processes.has(name))
|
|
213
|
+
return; // already exited/stopped
|
|
214
|
+
if (this.getConnectedAgents().includes(name))
|
|
215
|
+
return; // healthy, no action needed
|
|
216
|
+
console.warn(`[agent-manager] "${name}" did not connect within ${CONNECT_TIMEOUT_MS / 1000}s — killing to restart without --continue`);
|
|
217
|
+
this.skipContinueOnce.add(name);
|
|
218
|
+
this.killProcessTree(child);
|
|
219
|
+
}, CONNECT_TIMEOUT_MS);
|
|
147
220
|
child.on('exit', (code) => {
|
|
221
|
+
clearTimeout(connectTimer);
|
|
148
222
|
console.log(`[agent-manager] Agent "${name}" exited (code ${code})`);
|
|
149
223
|
this.processes.delete(name);
|
|
224
|
+
this.savePgids();
|
|
150
225
|
this.onEvent?.('agent_stopped', name, { code });
|
|
151
226
|
// Don't restart if: shutting down, or user explicitly stopped
|
|
152
227
|
if (this.shuttingDown)
|
|
@@ -187,6 +262,7 @@ export class AgentManager {
|
|
|
187
262
|
}, delay);
|
|
188
263
|
});
|
|
189
264
|
this.processes.set(name, child);
|
|
265
|
+
this.savePgids();
|
|
190
266
|
this.onEvent?.('agent_started', name);
|
|
191
267
|
return { success: true };
|
|
192
268
|
}
|
|
@@ -10,14 +10,20 @@ import { copyFileSync, mkdirSync } from 'node:fs';
|
|
|
10
10
|
import { SOCKET_DIR } from '../../shared/socket.js';
|
|
11
11
|
import { PermissionManager } from '../weixin/permission.js';
|
|
12
12
|
const TYPING_ACK_DELAY_MS = 10_000; // 10s before "processing..." ack
|
|
13
|
+
const SLEEP_DETECT_INTERVAL_MS = 10_000; // check every 10s
|
|
14
|
+
const SLEEP_THRESHOLD_MS = 30_000; // 30s gap = likely sleep/wake
|
|
13
15
|
export function createChannelManagerPlugin(channels) {
|
|
14
16
|
let permissionMgr;
|
|
15
17
|
let cleanupInterval;
|
|
18
|
+
let sleepDetectInterval;
|
|
16
19
|
const lastUserByAgent = new Map();
|
|
17
20
|
const lastChannelByUser = new Map(); // userId -> channelId
|
|
18
21
|
let lastGlobalUser = null;
|
|
19
22
|
// Per-agent pending ack timer: agentId -> { ref, timer }
|
|
20
23
|
const pendingAck = new Map();
|
|
24
|
+
// Auto-reconnect state per channel
|
|
25
|
+
const reconnectTimers = new Map();
|
|
26
|
+
const reconnectAttempts = new Map();
|
|
21
27
|
// Channel lookup by id
|
|
22
28
|
const channelMap = new Map();
|
|
23
29
|
for (const ch of channels) {
|
|
@@ -213,7 +219,7 @@ export function createChannelManagerPlugin(channels) {
|
|
|
213
219
|
await channelSendText(ref, `\uD83D\uDCEC ${routed.agentId} \u6682\u65F6\u79BB\u7EBF\uFF0C\u6D88\u606F\u5DF2\u6392\u961F\uFF0C\u4E0A\u7EBF\u540E\u81EA\u52A8\u6295\u9012\u3002`);
|
|
214
220
|
}
|
|
215
221
|
});
|
|
216
|
-
// Channel status change -> monitor broadcast
|
|
222
|
+
// Channel status change -> monitor broadcast + auto-reconnect on expired
|
|
217
223
|
ch.onStatusChange((status, detail) => {
|
|
218
224
|
console.log(`[channel-manager] ${ch.label} status: ${status}${detail ? ` (${detail})` : ''}`);
|
|
219
225
|
ctx.broadcastMonitor({
|
|
@@ -222,11 +228,49 @@ export function createChannelManagerPlugin(channels) {
|
|
|
222
228
|
timestamp: new Date().toISOString(),
|
|
223
229
|
text: `${ch.label}: ${status}${detail ? ` — ${detail}` : ''}`,
|
|
224
230
|
});
|
|
231
|
+
if (status === 'expired') {
|
|
232
|
+
scheduleReconnect(ch);
|
|
233
|
+
}
|
|
234
|
+
else if (status === 'connected') {
|
|
235
|
+
// Reset backoff on successful connect
|
|
236
|
+
reconnectAttempts.delete(ch.id);
|
|
237
|
+
const timer = reconnectTimers.get(ch.id);
|
|
238
|
+
if (timer) {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
reconnectTimers.delete(ch.id);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
225
243
|
});
|
|
226
244
|
}
|
|
227
245
|
for (const ch of channels) {
|
|
228
246
|
wireChannel(ch);
|
|
229
247
|
}
|
|
248
|
+
// --- Auto-reconnect with exponential backoff ---
|
|
249
|
+
function scheduleReconnect(ch, overrideDelaySec) {
|
|
250
|
+
if (reconnectTimers.has(ch.id))
|
|
251
|
+
return; // already scheduled
|
|
252
|
+
const attempt = (reconnectAttempts.get(ch.id) ?? 0) + 1;
|
|
253
|
+
reconnectAttempts.set(ch.id, attempt);
|
|
254
|
+
const delaySec = overrideDelaySec ?? Math.min(10 * Math.pow(2, attempt - 1), 300); // 10s, 20s, 40s, ..., max 5min
|
|
255
|
+
console.log(`[channel-manager] Scheduling reconnect for "${ch.id}" in ${delaySec}s (attempt ${attempt})`);
|
|
256
|
+
const timer = setTimeout(async () => {
|
|
257
|
+
reconnectTimers.delete(ch.id);
|
|
258
|
+
console.log(`[channel-manager] Auto-reconnecting "${ch.id}" (attempt ${attempt})...`);
|
|
259
|
+
try {
|
|
260
|
+
await ch.disconnect();
|
|
261
|
+
}
|
|
262
|
+
catch { }
|
|
263
|
+
try {
|
|
264
|
+
await ch.connect();
|
|
265
|
+
console.log(`[channel-manager] "${ch.id}" auto-reconnected successfully`);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
console.error(`[channel-manager] "${ch.id}" auto-reconnect failed: ${err.message}`);
|
|
269
|
+
// onStatusChange will fire 'expired' or 'disconnected' → re-schedule
|
|
270
|
+
}
|
|
271
|
+
}, delaySec * 1000);
|
|
272
|
+
reconnectTimers.set(ch.id, timer);
|
|
273
|
+
}
|
|
230
274
|
// --- Runtime channel add/remove ---
|
|
231
275
|
ctx.on('channel:add', async (type, channelId, accountName) => {
|
|
232
276
|
if (channelMap.has(channelId))
|
|
@@ -342,6 +386,30 @@ export function createChannelManagerPlugin(channels) {
|
|
|
342
386
|
});
|
|
343
387
|
// Permission cleanup interval
|
|
344
388
|
cleanupInterval = setInterval(() => permissionMgr.cleanup(), 60_000);
|
|
389
|
+
// --- Sleep/wake detection ---
|
|
390
|
+
// macOS sleep kills TCP connections; detect wake via timer gap and reconnect immediately
|
|
391
|
+
let lastTick = Date.now();
|
|
392
|
+
sleepDetectInterval = setInterval(() => {
|
|
393
|
+
const now = Date.now();
|
|
394
|
+
const elapsed = now - lastTick;
|
|
395
|
+
lastTick = now;
|
|
396
|
+
if (elapsed > SLEEP_THRESHOLD_MS) {
|
|
397
|
+
const gapSec = Math.round(elapsed / 1000);
|
|
398
|
+
console.log(`[channel-manager] System wake detected (${gapSec}s gap), reconnecting all channels...`);
|
|
399
|
+
for (const ch of channelMap.values()) {
|
|
400
|
+
if (ch.getStatus() === 'connected' || ch.getStatus() === 'connecting') {
|
|
401
|
+
// Cancel any pending scheduled reconnect — we're doing it now
|
|
402
|
+
const pending = reconnectTimers.get(ch.id);
|
|
403
|
+
if (pending) {
|
|
404
|
+
clearTimeout(pending);
|
|
405
|
+
reconnectTimers.delete(ch.id);
|
|
406
|
+
}
|
|
407
|
+
reconnectAttempts.delete(ch.id);
|
|
408
|
+
scheduleReconnect(ch, 2); // 2s delay — let network stack settle after wake
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}, SLEEP_DETECT_INTERVAL_MS);
|
|
345
413
|
// --- Connect all channels ---
|
|
346
414
|
for (const ch of channels) {
|
|
347
415
|
try {
|
|
@@ -356,6 +424,12 @@ export function createChannelManagerPlugin(channels) {
|
|
|
356
424
|
async destroy() {
|
|
357
425
|
if (cleanupInterval)
|
|
358
426
|
clearInterval(cleanupInterval);
|
|
427
|
+
if (sleepDetectInterval)
|
|
428
|
+
clearInterval(sleepDetectInterval);
|
|
429
|
+
// Cancel pending reconnect timers
|
|
430
|
+
for (const timer of reconnectTimers.values())
|
|
431
|
+
clearTimeout(timer);
|
|
432
|
+
reconnectTimers.clear();
|
|
359
433
|
// Disconnect all channels
|
|
360
434
|
for (const ch of channels) {
|
|
361
435
|
try {
|
|
@@ -19,6 +19,12 @@ export declare class WeixinConnection {
|
|
|
19
19
|
private onIncoming;
|
|
20
20
|
private listening;
|
|
21
21
|
private cleanupTimer;
|
|
22
|
+
private consecutiveErrors;
|
|
23
|
+
private onStalledCallback;
|
|
24
|
+
constructor(channelId?: string);
|
|
25
|
+
/** Register a callback fired when polling hits too many consecutive errors. */
|
|
26
|
+
onStalled(handler: () => void): void;
|
|
27
|
+
private handlePollError;
|
|
22
28
|
setMessageHandler(handler: OnMessageCallback): void;
|
|
23
29
|
/** Persist context tokens to disk so replies work after hub restart */
|
|
24
30
|
saveContextCache(channelId?: string): void;
|
|
@@ -7,7 +7,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
7
7
|
import { join, basename } from 'node:path';
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { downloadMedia, cleanupMedia } from './media.js';
|
|
10
|
-
import { loadCredentials,
|
|
10
|
+
import { loadCredentials, CRED_DIR } from './qr-login.js';
|
|
11
11
|
import { splitIntoChunks, formatChunks } from './chunker.js';
|
|
12
12
|
import { uploadMedia } from './media-upload.js';
|
|
13
13
|
import { SOCKET_DIR } from '../../shared/socket.js';
|
|
@@ -15,12 +15,40 @@ const ALLOWED_USERS = process.env.CC2IM_ALLOWED_USERS
|
|
|
15
15
|
? process.env.CC2IM_ALLOWED_USERS.split(',').map(s => s.trim())
|
|
16
16
|
: [];
|
|
17
17
|
const CONTEXT_CACHE_PATH = join(SOCKET_DIR, 'weixin-context.json');
|
|
18
|
+
const STALL_THRESHOLD = 5; // consecutive errors before declaring stalled
|
|
18
19
|
export class WeixinConnection {
|
|
19
|
-
bot
|
|
20
|
+
bot;
|
|
20
21
|
recentMessages = new Map(); // userId -> raw msg for reply
|
|
21
22
|
onIncoming = null;
|
|
22
23
|
listening = false;
|
|
23
24
|
cleanupTimer = null;
|
|
25
|
+
consecutiveErrors = 0;
|
|
26
|
+
onStalledCallback = null;
|
|
27
|
+
constructor(channelId) {
|
|
28
|
+
// Each channel gets its own tokenPath so the SDK reads/writes isolated credentials.
|
|
29
|
+
// Without this, all channels share ~/.weixin-bot/credentials.json and the last
|
|
30
|
+
// login overwrites everyone else's credentials.
|
|
31
|
+
const tokenPath = channelId
|
|
32
|
+
? join(CRED_DIR, `credentials-${channelId}.json`)
|
|
33
|
+
: undefined; // SDK default (~/.weixin-bot/credentials.json)
|
|
34
|
+
this.bot = new WeixinBot({
|
|
35
|
+
tokenPath,
|
|
36
|
+
onError: (error) => this.handlePollError(error),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Register a callback fired when polling hits too many consecutive errors. */
|
|
40
|
+
onStalled(handler) {
|
|
41
|
+
this.onStalledCallback = handler;
|
|
42
|
+
}
|
|
43
|
+
handlePollError(_error) {
|
|
44
|
+
this.consecutiveErrors++;
|
|
45
|
+
if (this.consecutiveErrors >= STALL_THRESHOLD && this.onStalledCallback) {
|
|
46
|
+
console.error(`[weixin] ${this.consecutiveErrors} consecutive poll errors, signalling stall`);
|
|
47
|
+
this.onStalledCallback();
|
|
48
|
+
// Reset so callback isn't fired on every subsequent error
|
|
49
|
+
this.consecutiveErrors = 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
24
52
|
setMessageHandler(handler) {
|
|
25
53
|
this.onIncoming = handler;
|
|
26
54
|
}
|
|
@@ -70,17 +98,19 @@ export class WeixinConnection {
|
|
|
70
98
|
catch { }
|
|
71
99
|
}
|
|
72
100
|
async login(channelId) {
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
101
|
+
// Per-channel credentials are handled by the SDK's tokenPath (set in constructor).
|
|
102
|
+
// For backward compat: if no per-channel file exists, copy from global file.
|
|
103
|
+
if (channelId) {
|
|
104
|
+
const channelCredPath = join(CRED_DIR, `credentials-${channelId}.json`);
|
|
105
|
+
if (!existsSync(channelCredPath)) {
|
|
106
|
+
// Fall back: try loading from our qr-login module (checks per-channel then global)
|
|
107
|
+
const fallback = loadCredentials(channelId);
|
|
108
|
+
if (fallback) {
|
|
109
|
+
writeFileSync(channelCredPath, JSON.stringify(fallback, null, 2) + '\n', { mode: 0o600 });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
77
112
|
}
|
|
78
|
-
|
|
79
|
-
// TODO: Race condition if two channels call login() concurrently — channel B
|
|
80
|
-
// could overwrite the global file before channel A's bot.login() reads it.
|
|
81
|
-
// Currently safe because channel-manager starts channels sequentially.
|
|
82
|
-
writeFileSync(CRED_PATH, JSON.stringify(channelCreds, null, 2) + '\n', { mode: 0o600 });
|
|
83
|
-
console.log('[hub] 使用已保存的凭证登录微信...');
|
|
113
|
+
console.log(`[hub] 使用已保存的凭证登录微信 (${channelId || 'default'})...`);
|
|
84
114
|
const creds = await this.bot.login();
|
|
85
115
|
console.log(`[hub] 微信连接成功! accountId=${creds.accountId}`);
|
|
86
116
|
if (ALLOWED_USERS.length === 0) {
|
|
@@ -97,6 +127,8 @@ export class WeixinConnection {
|
|
|
97
127
|
cleanupMedia();
|
|
98
128
|
this.cleanupTimer = setInterval(cleanupMedia, 6 * 60 * 60 * 1000);
|
|
99
129
|
this.bot.onMessage(async (msg) => {
|
|
130
|
+
// Successful message = connection healthy
|
|
131
|
+
this.consecutiveErrors = 0;
|
|
100
132
|
// Allowlist check
|
|
101
133
|
if (ALLOWED_USERS.length > 0 && !ALLOWED_USERS.includes(msg.userId)) {
|
|
102
134
|
console.log(`[hub] Blocked message from unlisted user: ${msg.userId}`);
|
|
@@ -24,9 +24,18 @@ export class WeixinChannel {
|
|
|
24
24
|
async connect() {
|
|
25
25
|
this.setStatus('connecting');
|
|
26
26
|
try {
|
|
27
|
+
// Fresh connection on every connect() — avoids Node.js fetch reusing
|
|
28
|
+
// stale TCP connections from a previous session (e.g. after macOS sleep/wake)
|
|
29
|
+
this.weixin = new WeixinConnection(this.id);
|
|
27
30
|
await this.weixin.login(this.id);
|
|
28
31
|
this.weixin.restoreContextCache(this.id);
|
|
29
32
|
this.registerMessageBridge();
|
|
33
|
+
// Detect stalled polling (too many consecutive timeouts) → mark expired
|
|
34
|
+
this.weixin.onStalled(() => {
|
|
35
|
+
if (this.status !== 'disconnected') {
|
|
36
|
+
this.setStatus('expired', 'Long-poll stalled: consecutive timeouts');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
30
39
|
this.weixin.startListening();
|
|
31
40
|
// startPolling is a long-running loop — fire-and-forget.
|
|
32
41
|
// If it exits (session expired / network error), mark status.
|
package/dist/spoke/index.js
CHANGED
|
@@ -99,7 +99,8 @@ const ppidCheck = setInterval(() => {
|
|
|
99
99
|
async function main() {
|
|
100
100
|
// Connect MCP stdio first (CC is waiting for it)
|
|
101
101
|
await connectTransport(server);
|
|
102
|
-
// Then connect to hub
|
|
102
|
+
// Then connect to hub (exit if hub is unreachable for too long)
|
|
103
|
+
socketClient.onReconnectGiveUp(() => gracefulExit('Hub unreachable, giving up reconnect'));
|
|
103
104
|
await socketClient.connect();
|
|
104
105
|
// Report ready
|
|
105
106
|
socketClient.send({ type: 'status', agentId, status: 'ready' });
|
|
@@ -6,6 +6,8 @@ export declare class SpokeSocketClient {
|
|
|
6
6
|
private reconnectTimer;
|
|
7
7
|
private reconnectDelay;
|
|
8
8
|
private connected;
|
|
9
|
+
private disconnectedSince;
|
|
10
|
+
private onGiveUp;
|
|
9
11
|
constructor(agentId: string, onMessage: (msg: HubToSpoke) => void);
|
|
10
12
|
/**
|
|
11
13
|
* Start connecting to hub. Resolves immediately — connection happens
|
|
@@ -14,6 +16,8 @@ export declare class SpokeSocketClient {
|
|
|
14
16
|
*/
|
|
15
17
|
connect(): Promise<void>;
|
|
16
18
|
private doConnect;
|
|
19
|
+
/** Register a callback invoked when reconnect attempts are exhausted (hub unreachable for too long). */
|
|
20
|
+
onReconnectGiveUp(handler: () => void): void;
|
|
17
21
|
private scheduleReconnect;
|
|
18
22
|
/** Send a message. Returns false if not connected (message dropped). */
|
|
19
23
|
send(msg: SpokeToHub): boolean;
|
|
@@ -2,6 +2,7 @@ import { createConnection } from 'node:net';
|
|
|
2
2
|
import { HUB_SOCKET_PATH, encodeFrame, createFrameParser } from '../shared/socket.js';
|
|
3
3
|
const RECONNECT_INTERVAL = 3000;
|
|
4
4
|
const MAX_RECONNECT_INTERVAL = 30000;
|
|
5
|
+
const MAX_RECONNECT_DURATION_MS = 60_000; // give up after 60s of failed reconnects
|
|
5
6
|
export class SpokeSocketClient {
|
|
6
7
|
socket = null;
|
|
7
8
|
agentId;
|
|
@@ -9,6 +10,8 @@ export class SpokeSocketClient {
|
|
|
9
10
|
reconnectTimer = null;
|
|
10
11
|
reconnectDelay = RECONNECT_INTERVAL;
|
|
11
12
|
connected = false;
|
|
13
|
+
disconnectedSince = null;
|
|
14
|
+
onGiveUp = null;
|
|
12
15
|
constructor(agentId, onMessage) {
|
|
13
16
|
this.agentId = agentId;
|
|
14
17
|
this.onMessage = onMessage;
|
|
@@ -27,6 +30,7 @@ export class SpokeSocketClient {
|
|
|
27
30
|
this.socket = socket;
|
|
28
31
|
this.connected = true;
|
|
29
32
|
this.reconnectDelay = RECONNECT_INTERVAL;
|
|
33
|
+
this.disconnectedSince = null; // reset on successful connect
|
|
30
34
|
socket.write(encodeFrame({ type: 'register', agentId: this.agentId, pid: process.pid }));
|
|
31
35
|
console.log(`[spoke:${this.agentId}] Connected to hub`);
|
|
32
36
|
});
|
|
@@ -50,9 +54,23 @@ export class SpokeSocketClient {
|
|
|
50
54
|
this.scheduleReconnect();
|
|
51
55
|
});
|
|
52
56
|
}
|
|
57
|
+
/** Register a callback invoked when reconnect attempts are exhausted (hub unreachable for too long). */
|
|
58
|
+
onReconnectGiveUp(handler) {
|
|
59
|
+
this.onGiveUp = handler;
|
|
60
|
+
}
|
|
53
61
|
scheduleReconnect() {
|
|
54
62
|
if (this.reconnectTimer)
|
|
55
63
|
return;
|
|
64
|
+
// Track how long we've been disconnected
|
|
65
|
+
if (this.disconnectedSince === null) {
|
|
66
|
+
this.disconnectedSince = Date.now();
|
|
67
|
+
}
|
|
68
|
+
const elapsed = Date.now() - this.disconnectedSince;
|
|
69
|
+
if (elapsed > MAX_RECONNECT_DURATION_MS) {
|
|
70
|
+
console.log(`[spoke:${this.agentId}] Hub unreachable for ${Math.round(elapsed / 1000)}s, giving up`);
|
|
71
|
+
this.onGiveUp?.();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
56
74
|
console.log(`[spoke:${this.agentId}] Reconnecting in ${this.reconnectDelay / 1000}s...`);
|
|
57
75
|
this.reconnectTimer = setTimeout(() => {
|
|
58
76
|
this.reconnectTimer = null;
|