cc2im 0.2.1 → 0.2.3

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,24 @@ cc2im install
115
115
 
116
116
  cc2im 为**单用户场景**设计(一个人通过微信控制自己的多个 agent)。多用户并发时,权限审批和回复路由可能出现竞争。
117
117
 
118
+ ## 更新日志
119
+
120
+ ### v0.2.3 (2026-04-20)
121
+
122
+ - **feat**: 启动 CC 实例时默认注入 `--permission-mode auto --allowedTools '*' --effort max`,让 agent 在微信场景下自主执行不卡权限审批。per-agent `claudeArgs`(`~/.cc2im/agents.json`)仍可按 flag 名覆盖
123
+
124
+ ### v0.2.2 (2026-04-20)
125
+
126
+ - **fix**: CC 启动时若 `--continue` 触发 "Resume from summary" 会话选择器,expect 脚本现在会自动选第一项;新增 60s 连接超时兜底,卡住时自动 kill 并用新会话重试
127
+ - **fix**: 每个微信 channel 使用独立凭证文件(`credentials-{channelId}.json`),不再互相覆盖,解决新增第二个账号导致第一个掉线的问题
128
+
129
+ ### v0.2.1 (2026-04-07)
130
+
131
+ - 新用户首次启动引导文案优化
132
+ - 休眠/唤醒后自动重连微信,连续 poll 超时时触发重连
133
+ - Hub 启动时清理孤儿 agent 进程
134
+ - 重启后通过 `--continue` 恢复 agent 的最近 session
135
+
118
136
  ## License
119
137
 
120
138
  [MIT](./LICENSE)
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ import { spawn } from 'node:child_process';
20
20
  import qrterm from 'qrcode-terminal';
21
21
  import { SOCKET_DIR, ensureSocketDir } from './shared/socket.js';
22
22
  import { ensureMcpJson } from './shared/mcp-config.js';
23
+ import { DEFAULT_CLAUDE_ARGS, mergeClaudeArgs } from './shared/claude-args.js';
23
24
  const AGENTS_JSON_PATH = join(SOCKET_DIR, 'agents.json');
24
25
  const BASE_URL = 'https://ilinkai.weixin.qq.com';
25
26
  const BANNER = [
@@ -142,7 +143,7 @@ function startAgentForeground(name) {
142
143
  ensureMcpJson(agent.cwd, spokeScript, name);
143
144
  const claudeArgs = [
144
145
  '--dangerously-load-development-channels', 'server:cc2im',
145
- ...(agent.claudeArgs || []),
146
+ ...mergeClaudeArgs(DEFAULT_CLAUDE_ARGS, agent.claudeArgs || []),
146
147
  ];
147
148
  const cmd = process.platform === 'darwin' ? 'caffeinate' : 'claude';
148
149
  const args = process.platform === 'darwin' ? ['-i', 'claude', ...claudeArgs] : claudeArgs;
@@ -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;
@@ -7,11 +7,16 @@ import { join } from 'node:path';
7
7
  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
+ import { DEFAULT_CLAUDE_ARGS, mergeClaudeArgs } from '../shared/claude-args.js';
10
11
  const AGENTS_JSON_PATH = join(SOCKET_DIR, 'agents.json');
12
+ const PGID_FILE_PATH = join(SOCKET_DIR, 'agent-pgids.json');
11
13
  const STOP_TIMEOUT_MS = 5000;
12
14
  const RESTART_DELAY_MS = 5000;
13
15
  const MAX_RESTART_ATTEMPTS = 5;
14
16
  const RESTART_WINDOW_MS = 5 * 60_000; // 5 min — reset counter if stable for this long
17
+ const CONNECT_TIMEOUT_MS = 60_000; // if spoke doesn't connect within this window after spawn,
18
+ // assume init got stuck (e.g. unknown interactive prompt)
19
+ // and restart without --continue to unblock
15
20
  export class AgentManager {
16
21
  processes = new Map();
17
22
  config;
@@ -20,10 +25,54 @@ export class AgentManager {
20
25
  stoppedManually = new Set(); // agents stopped by user intent
21
26
  shuttingDown = false; // suppress auto-restart during hub shutdown
22
27
  restartAttempts = new Map(); // backoff tracking
28
+ skipContinueOnce = new Set(); // agents to start without --continue on next spawn
29
+ // (used when previous session stalled during init)
23
30
  constructor(getConnectedAgents, onEvent) {
24
31
  this.config = this.loadConfig();
25
32
  this.getConnectedAgents = getConnectedAgents;
26
33
  this.onEvent = onEvent;
34
+ this.killOrphanProcesses();
35
+ }
36
+ /**
37
+ * Kill orphan agent processes from a previous hub session.
38
+ * Reads PGIDs saved by the previous hub, kills any still-alive process groups,
39
+ * then clears the file. Called once in constructor before any agents are started.
40
+ */
41
+ killOrphanProcesses() {
42
+ try {
43
+ if (!existsSync(PGID_FILE_PATH))
44
+ return;
45
+ const pgids = JSON.parse(readFileSync(PGID_FILE_PATH, 'utf8'));
46
+ let killed = 0;
47
+ for (const [name, pgid] of Object.entries(pgids)) {
48
+ try {
49
+ process.kill(-pgid, 'SIGKILL'); // kill entire process group
50
+ killed++;
51
+ console.log(`[agent-manager] Killed orphan "${name}" (pgid ${pgid})`);
52
+ }
53
+ catch {
54
+ // ESRCH = process group doesn't exist (already dead) — expected
55
+ }
56
+ }
57
+ if (killed > 0) {
58
+ console.log(`[agent-manager] Cleaned up ${killed} orphan process group(s)`);
59
+ }
60
+ }
61
+ catch { }
62
+ // Clear the file — we'll write fresh PGIDs as agents start
63
+ this.savePgids();
64
+ }
65
+ /** Persist current agent PGIDs to disk for orphan cleanup on next startup. */
66
+ savePgids() {
67
+ const pgids = {};
68
+ for (const [name, child] of this.processes) {
69
+ if (child.pid)
70
+ pgids[name] = child.pid; // detached child.pid === pgid
71
+ }
72
+ try {
73
+ writeFileSync(PGID_FILE_PATH, JSON.stringify(pgids) + '\n');
74
+ }
75
+ catch { }
27
76
  }
28
77
  loadConfig() {
29
78
  if (existsSync(AGENTS_JSON_PATH)) {
@@ -102,12 +151,15 @@ export class AgentManager {
102
151
  // Ensure agent log directory
103
152
  const agentDir = join(SOCKET_DIR, 'agents', name);
104
153
  mkdirSync(agentDir, { recursive: true });
105
- // autoMode defaults to true auto-approve safe operations, deny risky ones
106
- const useAutoMode = agent.autoMode !== false;
154
+ // Skip --continue if the previous spawn stalled during init start fresh instead
155
+ const skipContinue = this.skipContinueOnce.delete(name);
156
+ if (skipContinue) {
157
+ console.log(`[agent-manager] "${name}": starting without --continue (previous session stalled during init)`);
158
+ }
107
159
  const claudeArgs = [
108
160
  '--dangerously-load-development-channels', 'server:cc2im',
109
- ...(useAutoMode ? ['--enable-auto-mode'] : []),
110
- ...(agent.claudeArgs || []),
161
+ ...(skipContinue ? [] : ['--continue']), // resume most recent session unless last attempt stalled
162
+ ...mergeClaudeArgs(DEFAULT_CLAUDE_ARGS, agent.claudeArgs || []), // permission-mode/allowedTools/effort defaults + per-agent override
111
163
  ];
112
164
  // Use `expect` to allocate a pseudo-tty so CC enters interactive mode.
113
165
  // Unlike `script`, `expect` creates its own pty without needing a tty stdin.
@@ -118,12 +170,22 @@ export class AgentManager {
118
170
  `log_file -a {${logPath}}`,
119
171
  `spawn claude ${claudeArgs.map(a => `{${a}}`).join(' ')}`,
120
172
  '',
121
- '# Auto-approve workspace trust prompt if it appears',
122
- 'set timeout 30',
173
+ '# Auto-handle initialization prompts:',
174
+ '# - Workspace trust prompt ("confirm" text)',
175
+ '# - "Resume from summary" session picker (shown by --continue for old/large sessions)',
176
+ '# exp_continue keeps listening so multiple prompts in sequence are all handled.',
177
+ '# If 60s passes with no further known prompts, assume CC is up and switch to eof wait.',
178
+ 'set timeout 60',
123
179
  'expect {',
180
+ ' "Resume from summary" {',
181
+ ' after 500',
182
+ ' send "1\\r"',
183
+ ' exp_continue',
184
+ ' }',
124
185
  ' "confirm" {',
125
186
  ' after 500',
126
187
  ' send "\\r"',
188
+ ' exp_continue',
127
189
  ' }',
128
190
  ' timeout {}',
129
191
  '}',
@@ -144,9 +206,23 @@ export class AgentManager {
144
206
  stdio: ['pipe', 'ignore', 'ignore'],
145
207
  detached: true, // new process group — allows killing entire tree with -pid
146
208
  });
209
+ // Fallback: if spoke doesn't connect within CONNECT_TIMEOUT_MS, assume init got stuck
210
+ // (e.g. an unknown interactive prompt blocked CC) and kill the process tree.
211
+ // On next auto-restart, --continue is skipped so CC starts with a fresh session.
212
+ const connectTimer = setTimeout(() => {
213
+ if (!this.processes.has(name))
214
+ return; // already exited/stopped
215
+ if (this.getConnectedAgents().includes(name))
216
+ return; // healthy, no action needed
217
+ console.warn(`[agent-manager] "${name}" did not connect within ${CONNECT_TIMEOUT_MS / 1000}s — killing to restart without --continue`);
218
+ this.skipContinueOnce.add(name);
219
+ this.killProcessTree(child);
220
+ }, CONNECT_TIMEOUT_MS);
147
221
  child.on('exit', (code) => {
222
+ clearTimeout(connectTimer);
148
223
  console.log(`[agent-manager] Agent "${name}" exited (code ${code})`);
149
224
  this.processes.delete(name);
225
+ this.savePgids();
150
226
  this.onEvent?.('agent_stopped', name, { code });
151
227
  // Don't restart if: shutting down, or user explicitly stopped
152
228
  if (this.shuttingDown)
@@ -187,6 +263,7 @@ export class AgentManager {
187
263
  }, delay);
188
264
  });
189
265
  this.processes.set(name, child);
266
+ this.savePgids();
190
267
  this.onEvent?.('agent_started', name);
191
268
  return { success: true };
192
269
  }
@@ -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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Default CC CLI args injected on every spawn.
3
+ * Per-agent claudeArgs (in agents.json) override these by flag name.
4
+ */
5
+ export declare const DEFAULT_CLAUDE_ARGS: readonly string[];
6
+ /**
7
+ * Merge defaults with per-agent overrides. If a flag name appears in `userArgs`,
8
+ * its default (flag + value) is dropped so the user's value wins.
9
+ */
10
+ export declare function mergeClaudeArgs(defaults: readonly string[], userArgs: readonly string[]): string[];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Default CC CLI args injected on every spawn.
3
+ * Per-agent claudeArgs (in agents.json) override these by flag name.
4
+ */
5
+ export const DEFAULT_CLAUDE_ARGS = [
6
+ '--permission-mode', 'auto',
7
+ '--allowedTools', '*',
8
+ '--effort', 'max',
9
+ ];
10
+ /**
11
+ * Merge defaults with per-agent overrides. If a flag name appears in `userArgs`,
12
+ * its default (flag + value) is dropped so the user's value wins.
13
+ */
14
+ export function mergeClaudeArgs(defaults, userArgs) {
15
+ const userFlags = new Set(userArgs.filter(t => t.startsWith('--')));
16
+ const filtered = [];
17
+ for (let i = 0; i < defaults.length; i++) {
18
+ const token = defaults[i];
19
+ if (token.startsWith('--') && userFlags.has(token)) {
20
+ if (i + 1 < defaults.length && !defaults[i + 1].startsWith('--'))
21
+ i++;
22
+ continue;
23
+ }
24
+ filtered.push(token);
25
+ }
26
+ return [...filtered, ...userArgs];
27
+ }
@@ -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.1",
3
+ "version": "0.2.3",
4
4
  "description": "IM gateway for multiple local Claude Code instances",
5
5
  "author": "roxorlt",
6
6
  "license": "MIT",