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 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
- console.log(`[cc2im] Created default config: ${AGENTS_JSON_PATH}`);
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: false,
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.1.0');
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.1.0 — IM gateway for multiple Claude Code instances
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
- // autoMode defaults to true auto-approve safe operations, deny risky ones
106
- const useAutoMode = agent.autoMode !== false;
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
- ...(useAutoMode ? ['--enable-auto-mode'] : []),
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-approve workspace trust prompt if it appears',
122
- 'set timeout 30',
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, CRED_PATH } from './qr-login.js';
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 = new WeixinBot();
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
- // Load per-channel credentials (falls back to global file)
74
- const channelCreds = loadCredentials(channelId);
75
- if (!channelCreds) {
76
- throw new Error('未找到微信登录凭证! 请先运行: cc2im login');
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
- // Write per-channel creds to the global path so the SDK picks them up.
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.
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc2im",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "IM gateway for multiple local Claude Code instances",
5
5
  "author": "roxorlt",
6
6
  "license": "MIT",