cc2im 0.2.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +120 -0
  3. package/README.md +120 -0
  4. package/dist/cli.d.ts +16 -0
  5. package/dist/cli.js +314 -0
  6. package/dist/hub/agent-manager.d.ts +63 -0
  7. package/dist/hub/agent-manager.js +311 -0
  8. package/dist/hub/hub-context.d.ts +27 -0
  9. package/dist/hub/hub-context.js +57 -0
  10. package/dist/hub/index.d.ts +6 -0
  11. package/dist/hub/index.js +234 -0
  12. package/dist/hub/launchd.d.ts +7 -0
  13. package/dist/hub/launchd.js +151 -0
  14. package/dist/hub/plugin-manager.d.ts +7 -0
  15. package/dist/hub/plugin-manager.js +29 -0
  16. package/dist/hub/router.d.ts +21 -0
  17. package/dist/hub/router.js +35 -0
  18. package/dist/hub/socket-server.d.ts +23 -0
  19. package/dist/hub/socket-server.js +191 -0
  20. package/dist/plugins/channel-manager/index.d.ts +10 -0
  21. package/dist/plugins/channel-manager/index.js +387 -0
  22. package/dist/plugins/cron-scheduler/db.d.ts +12 -0
  23. package/dist/plugins/cron-scheduler/db.js +160 -0
  24. package/dist/plugins/cron-scheduler/index.d.ts +4 -0
  25. package/dist/plugins/cron-scheduler/index.js +22 -0
  26. package/dist/plugins/cron-scheduler/scheduler.d.ts +20 -0
  27. package/dist/plugins/cron-scheduler/scheduler.js +129 -0
  28. package/dist/plugins/persistence/db.d.ts +24 -0
  29. package/dist/plugins/persistence/db.js +121 -0
  30. package/dist/plugins/persistence/index.d.ts +2 -0
  31. package/dist/plugins/persistence/index.js +93 -0
  32. package/dist/plugins/web-monitor/api-routes.d.ts +33 -0
  33. package/dist/plugins/web-monitor/api-routes.js +474 -0
  34. package/dist/plugins/web-monitor/index.d.ts +2 -0
  35. package/dist/plugins/web-monitor/index.js +21 -0
  36. package/dist/plugins/web-monitor/log-tailer.d.ts +13 -0
  37. package/dist/plugins/web-monitor/log-tailer.js +74 -0
  38. package/dist/plugins/web-monitor/monitor-client.d.ts +17 -0
  39. package/dist/plugins/web-monitor/monitor-client.js +68 -0
  40. package/dist/plugins/web-monitor/server.d.ts +14 -0
  41. package/dist/plugins/web-monitor/server.js +205 -0
  42. package/dist/plugins/web-monitor/stats-reader.d.ts +22 -0
  43. package/dist/plugins/web-monitor/stats-reader.js +17 -0
  44. package/dist/plugins/web-monitor/token-stats.d.ts +19 -0
  45. package/dist/plugins/web-monitor/token-stats.js +86 -0
  46. package/dist/plugins/web-monitor/usage-stats.d.ts +13 -0
  47. package/dist/plugins/web-monitor/usage-stats.js +56 -0
  48. package/dist/plugins/weixin/chunker.d.ts +16 -0
  49. package/dist/plugins/weixin/chunker.js +142 -0
  50. package/dist/plugins/weixin/connection.d.ts +46 -0
  51. package/dist/plugins/weixin/connection.js +270 -0
  52. package/dist/plugins/weixin/index.d.ts +10 -0
  53. package/dist/plugins/weixin/index.js +198 -0
  54. package/dist/plugins/weixin/media-upload.d.ts +22 -0
  55. package/dist/plugins/weixin/media-upload.js +134 -0
  56. package/dist/plugins/weixin/media.d.ts +6 -0
  57. package/dist/plugins/weixin/media.js +83 -0
  58. package/dist/plugins/weixin/permission.d.ts +35 -0
  59. package/dist/plugins/weixin/permission.js +96 -0
  60. package/dist/plugins/weixin/qr-login.d.ts +23 -0
  61. package/dist/plugins/weixin/qr-login.js +77 -0
  62. package/dist/plugins/weixin/weixin-channel.d.ts +33 -0
  63. package/dist/plugins/weixin/weixin-channel.js +123 -0
  64. package/dist/shared/channel-config.d.ts +8 -0
  65. package/dist/shared/channel-config.js +14 -0
  66. package/dist/shared/channel.d.ts +37 -0
  67. package/dist/shared/channel.js +8 -0
  68. package/dist/shared/mcp-config.d.ts +5 -0
  69. package/dist/shared/mcp-config.js +44 -0
  70. package/dist/shared/plugin.d.ts +32 -0
  71. package/dist/shared/plugin.js +1 -0
  72. package/dist/shared/socket.d.ts +5 -0
  73. package/dist/shared/socket.js +31 -0
  74. package/dist/shared/types.d.ts +136 -0
  75. package/dist/shared/types.js +1 -0
  76. package/dist/spoke/channel-server.d.ts +48 -0
  77. package/dist/spoke/channel-server.js +383 -0
  78. package/dist/spoke/index.d.ts +13 -0
  79. package/dist/spoke/index.js +115 -0
  80. package/dist/spoke/permission.d.ts +28 -0
  81. package/dist/spoke/permission.js +142 -0
  82. package/dist/spoke/socket-client.d.ts +22 -0
  83. package/dist/spoke/socket-client.js +83 -0
  84. package/dist/web-frontend/assets/index-CU9vxw8F.js +9 -0
  85. package/dist/web-frontend/index.html +82 -0
  86. package/package.json +54 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Agent Lifecycle Manager — hub 侧
3
+ * 管理 agent 的注册/注销、启动/停止、健康检查
4
+ */
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { SOCKET_DIR } from '../shared/socket.js';
9
+ import { ensureMcpJson } from '../shared/mcp-config.js';
10
+ const AGENTS_JSON_PATH = join(SOCKET_DIR, 'agents.json');
11
+ const STOP_TIMEOUT_MS = 5000;
12
+ const RESTART_DELAY_MS = 5000;
13
+ const MAX_RESTART_ATTEMPTS = 5;
14
+ const RESTART_WINDOW_MS = 5 * 60_000; // 5 min — reset counter if stable for this long
15
+ export class AgentManager {
16
+ processes = new Map();
17
+ config;
18
+ getConnectedAgents;
19
+ onEvent;
20
+ stoppedManually = new Set(); // agents stopped by user intent
21
+ shuttingDown = false; // suppress auto-restart during hub shutdown
22
+ restartAttempts = new Map(); // backoff tracking
23
+ constructor(getConnectedAgents, onEvent) {
24
+ this.config = this.loadConfig();
25
+ this.getConnectedAgents = getConnectedAgents;
26
+ this.onEvent = onEvent;
27
+ }
28
+ loadConfig() {
29
+ if (existsSync(AGENTS_JSON_PATH)) {
30
+ return JSON.parse(readFileSync(AGENTS_JSON_PATH, 'utf8'));
31
+ }
32
+ return { defaultAgent: 'brain', agents: {} };
33
+ }
34
+ saveConfig() {
35
+ writeFileSync(AGENTS_JSON_PATH, JSON.stringify(this.config, null, 2) + '\n');
36
+ }
37
+ getConfig() {
38
+ return this.config;
39
+ }
40
+ reloadConfig() {
41
+ this.config = this.loadConfig();
42
+ }
43
+ register(name, cwd, claudeArgs) {
44
+ if (this.config.agents[name]) {
45
+ return { success: false, error: `Agent "${name}" already exists` };
46
+ }
47
+ if (!existsSync(cwd)) {
48
+ return { success: false, error: `Directory "${cwd}" does not exist` };
49
+ }
50
+ this.config.agents[name] = {
51
+ name,
52
+ cwd,
53
+ claudeArgs,
54
+ createdAt: new Date().toISOString().split('T')[0],
55
+ autoStart: true,
56
+ };
57
+ this.saveConfig();
58
+ console.log(`[agent-manager] Registered "${name}" → ${cwd}`);
59
+ return { success: true };
60
+ }
61
+ async deregister(name) {
62
+ if (!this.config.agents[name]) {
63
+ return { success: false, error: `Agent "${name}" not found` };
64
+ }
65
+ await this.stop(name);
66
+ delete this.config.agents[name];
67
+ if (this.config.defaultAgent === name) {
68
+ const remaining = Object.keys(this.config.agents);
69
+ this.config.defaultAgent = remaining[0] || '';
70
+ }
71
+ this.saveConfig();
72
+ console.log(`[agent-manager] Deregistered "${name}"`);
73
+ return { success: true };
74
+ }
75
+ start(name) {
76
+ if (!this.config.agents[name]) {
77
+ return { success: false, error: `Agent "${name}" not found in config` };
78
+ }
79
+ // Guard: refuse to spawn if spoke is already connected (externally started)
80
+ const connected = this.getConnectedAgents();
81
+ if (!this.processes.has(name) && connected.includes(name)) {
82
+ return { success: false, error: `Agent "${name}" is already running externally (spoke connected). Stop it manually first.` };
83
+ }
84
+ // If process exists but spoke not connected, it's stale — kill and restart
85
+ if (this.processes.has(name)) {
86
+ if (connected.includes(name)) {
87
+ return { success: false, error: `Agent "${name}" is already running and connected` };
88
+ }
89
+ console.log(`[agent-manager] Agent "${name}" has stale process (not connected), restarting`);
90
+ const stale = this.processes.get(name);
91
+ this.killProcessTree(stale);
92
+ this.processes.delete(name);
93
+ }
94
+ const agent = this.config.agents[name];
95
+ // Resolve spoke script path (works for both tsx/src and compiled/dist)
96
+ const dir = import.meta.dirname;
97
+ const spokeTs = join(dir, '..', 'spoke', 'index.ts');
98
+ const spokeJs = join(dir, '..', 'spoke', 'index.js');
99
+ const spokeScript = existsSync(spokeTs) ? spokeTs : spokeJs;
100
+ // Write .mcp.json in agent's cwd
101
+ ensureMcpJson(agent.cwd, spokeScript, name);
102
+ // Ensure agent log directory
103
+ const agentDir = join(SOCKET_DIR, 'agents', name);
104
+ mkdirSync(agentDir, { recursive: true });
105
+ // autoMode defaults to true — auto-approve safe operations, deny risky ones
106
+ const useAutoMode = agent.autoMode !== false;
107
+ const claudeArgs = [
108
+ '--dangerously-load-development-channels', 'server:cc2im',
109
+ ...(useAutoMode ? ['--enable-auto-mode'] : []),
110
+ ...(agent.claudeArgs || []),
111
+ ];
112
+ // Use `expect` to allocate a pseudo-tty so CC enters interactive mode.
113
+ // Unlike `script`, `expect` creates its own pty without needing a tty stdin.
114
+ // The expect script auto-approves the workspace trust prompt and then waits.
115
+ const logPath = join(agentDir, 'claude.log');
116
+ const expectScriptPath = join(agentDir, 'start.exp');
117
+ const expectScript = [
118
+ `log_file -a {${logPath}}`,
119
+ `spawn claude ${claudeArgs.map(a => `{${a}}`).join(' ')}`,
120
+ '',
121
+ '# Auto-approve workspace trust prompt if it appears',
122
+ 'set timeout 30',
123
+ 'expect {',
124
+ ' "confirm" {',
125
+ ' after 500',
126
+ ' send "\\r"',
127
+ ' }',
128
+ ' timeout {}',
129
+ '}',
130
+ '',
131
+ '# Keep CC running until it exits',
132
+ 'set timeout -1',
133
+ 'expect eof',
134
+ ].join('\n');
135
+ writeFileSync(expectScriptPath, expectScript + '\n');
136
+ // macOS: caffeinate -i prevents idle sleep
137
+ const cmd = process.platform === 'darwin' ? 'caffeinate' : 'expect';
138
+ const args = process.platform === 'darwin'
139
+ ? ['-i', 'expect', expectScriptPath]
140
+ : [expectScriptPath];
141
+ console.log(`[agent-manager] Starting "${name}" in ${agent.cwd}`);
142
+ const child = spawn(cmd, args, {
143
+ cwd: agent.cwd,
144
+ stdio: ['pipe', 'ignore', 'ignore'],
145
+ detached: true, // new process group — allows killing entire tree with -pid
146
+ });
147
+ child.on('exit', (code) => {
148
+ console.log(`[agent-manager] Agent "${name}" exited (code ${code})`);
149
+ this.processes.delete(name);
150
+ this.onEvent?.('agent_stopped', name, { code });
151
+ // Don't restart if: shutting down, or user explicitly stopped
152
+ if (this.shuttingDown)
153
+ return;
154
+ if (this.stoppedManually.has(name)) {
155
+ this.stoppedManually.delete(name);
156
+ this.restartAttempts.delete(name);
157
+ return;
158
+ }
159
+ const agentConfig = this.config.agents[name];
160
+ if (!agentConfig?.autoStart)
161
+ return;
162
+ // Backoff: track consecutive restarts within time window
163
+ const now = Date.now();
164
+ const attempts = this.restartAttempts.get(name);
165
+ if (attempts && now - attempts.firstAt < RESTART_WINDOW_MS) {
166
+ attempts.count++;
167
+ if (attempts.count > MAX_RESTART_ATTEMPTS) {
168
+ console.error(`[agent-manager] "${name}" crashed ${attempts.count} times in ${Math.round((now - attempts.firstAt) / 1000)}s — giving up auto-restart`);
169
+ this.restartAttempts.delete(name);
170
+ this.onEvent?.('agent_dead', name);
171
+ return;
172
+ }
173
+ }
174
+ else {
175
+ this.restartAttempts.set(name, { count: 1, firstAt: now });
176
+ }
177
+ const attempt = this.restartAttempts.get(name);
178
+ const delay = RESTART_DELAY_MS * attempt.count; // 5s, 10s, 15s, 20s, 25s
179
+ console.log(`[agent-manager] Auto-restarting "${name}" in ${delay / 1000}s (attempt ${attempt.count}/${MAX_RESTART_ATTEMPTS})`);
180
+ setTimeout(() => {
181
+ if (this.shuttingDown || this.stoppedManually.has(name))
182
+ return;
183
+ const result = this.start(name);
184
+ if (!result.success) {
185
+ console.log(`[agent-manager] Failed to restart "${name}": ${result.error}`);
186
+ }
187
+ }, delay);
188
+ });
189
+ this.processes.set(name, child);
190
+ this.onEvent?.('agent_started', name);
191
+ return { success: true };
192
+ }
193
+ /** Stop an agent and wait for the process to exit (with timeout).
194
+ * Marks as manually stopped — will NOT auto-restart. */
195
+ stop(name) {
196
+ const child = this.processes.get(name);
197
+ if (!child) {
198
+ return Promise.resolve({ success: false, error: `Agent "${name}" is not running` });
199
+ }
200
+ this.stoppedManually.add(name);
201
+ return new Promise((resolve) => {
202
+ const timeout = setTimeout(() => {
203
+ // Force kill entire process tree if SIGTERM didn't work
204
+ this.killProcessTree(child);
205
+ this.processes.delete(name);
206
+ console.log(`[agent-manager] Force-killed "${name}" (SIGTERM timeout)`);
207
+ resolve({ success: true });
208
+ }, STOP_TIMEOUT_MS);
209
+ child.once('exit', () => {
210
+ clearTimeout(timeout);
211
+ this.processes.delete(name);
212
+ console.log(`[agent-manager] Stopped "${name}"`);
213
+ resolve({ success: true });
214
+ });
215
+ // SIGTERM the entire process group
216
+ this.killProcessTree(child, 'SIGTERM');
217
+ });
218
+ }
219
+ list() {
220
+ const connected = this.getConnectedAgents();
221
+ return Object.entries(this.config.agents).map(([name, agent]) => ({
222
+ name,
223
+ cwd: agent.cwd,
224
+ // 'connected' = spoke online, 'starting' = process spawned but not yet connected, 'stopped' = no process
225
+ status: connected.includes(name) ? 'connected'
226
+ : this.processes.has(name) ? 'starting'
227
+ : 'stopped',
228
+ autoStart: agent.autoStart ?? false,
229
+ claudeArgs: agent.claudeArgs || [],
230
+ isDefault: this.config.defaultAgent === name,
231
+ }));
232
+ }
233
+ /** Kill an agent's entire process tree (caffeinate → expect → claude).
234
+ * Uses negative PID to kill the process group created by detached: true. */
235
+ killProcessTree(child, signal = 'SIGKILL') {
236
+ try {
237
+ // Kill entire process group (negative PID)
238
+ process.kill(-child.pid, signal);
239
+ }
240
+ catch {
241
+ // Fallback: kill just the child
242
+ try {
243
+ child.kill(signal);
244
+ }
245
+ catch { /* already dead */ }
246
+ }
247
+ }
248
+ /** Kill an agent's process for restart (e.g., after heartbeat eviction).
249
+ * Does NOT mark as manually stopped — child.on('exit') will auto-restart. */
250
+ killForRestart(name) {
251
+ const child = this.processes.get(name);
252
+ if (child) {
253
+ console.log(`[agent-manager] Killing "${name}" for restart`);
254
+ this.killProcessTree(child);
255
+ }
256
+ }
257
+ async restart(name) {
258
+ // Only restart hub-managed agents. Externally started agents (foreground CLI)
259
+ // cannot be stopped by the hub — refuse rather than spawn a duplicate.
260
+ if (!this.processes.has(name)) {
261
+ const connected = this.getConnectedAgents();
262
+ if (connected.includes(name)) {
263
+ return { success: false, error: `Agent "${name}" was started externally. Stop it manually, then use start.` };
264
+ }
265
+ return { success: false, error: `Agent "${name}" is not running` };
266
+ }
267
+ await this.stop(name);
268
+ this.stoppedManually.delete(name); // restart is intentional, allow re-start
269
+ this.restartAttempts.delete(name); // reset backoff
270
+ return this.start(name);
271
+ }
272
+ updateEffort(name, effort) {
273
+ if (!this.config.agents[name]) {
274
+ return { success: false, error: `Agent "${name}" not found` };
275
+ }
276
+ const agent = this.config.agents[name];
277
+ const args = agent.claudeArgs || [];
278
+ const effortIdx = args.indexOf('--effort');
279
+ if (effortIdx >= 0) {
280
+ args[effortIdx + 1] = effort;
281
+ }
282
+ else {
283
+ args.push('--effort', effort);
284
+ }
285
+ agent.claudeArgs = args;
286
+ this.saveConfig();
287
+ return { success: true };
288
+ }
289
+ startAutoAgents() {
290
+ for (const [name, agent] of Object.entries(this.config.agents)) {
291
+ if (agent.autoStart) {
292
+ const result = this.start(name);
293
+ if (result.success) {
294
+ console.log(`[agent-manager] Auto-started "${name}"`);
295
+ }
296
+ else {
297
+ console.log(`[agent-manager] Failed to auto-start "${name}": ${result.error}`);
298
+ }
299
+ }
300
+ }
301
+ }
302
+ /** Check if this agent's process is managed (spawned) by the hub. */
303
+ isManaged(name) {
304
+ return this.processes.has(name);
305
+ }
306
+ async stopAll() {
307
+ this.shuttingDown = true;
308
+ const names = [...this.processes.keys()];
309
+ await Promise.all(names.map(name => this.stop(name)));
310
+ }
311
+ }
@@ -0,0 +1,27 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { HubSocketServer } from './socket-server.js';
3
+ import type { AgentManager } from './agent-manager.js';
4
+ import type { Router } from './router.js';
5
+ import type { HubContext } from '../shared/plugin.js';
6
+ import type { AgentsConfig, HubToSpoke, HubEventData } from '../shared/types.js';
7
+ import type { Cc2imChannel } from '../shared/channel.js';
8
+ export declare class HubContextImpl extends EventEmitter implements HubContext {
9
+ private socketServer;
10
+ private agentManager;
11
+ private router;
12
+ private config;
13
+ private channels;
14
+ constructor(socketServer: HubSocketServer, agentManager: AgentManager, router: Router, config: AgentsConfig);
15
+ deliverToAgent(agentId: string, msg: HubToSpoke): boolean;
16
+ broadcastMonitor(event: HubEventData): void;
17
+ getConnectedAgents(): string[];
18
+ getAgentManager(): AgentManager;
19
+ getRouter(): Router;
20
+ getConfig(): AgentsConfig;
21
+ registerChannel(channel: Cc2imChannel): void;
22
+ getChannel(channelId: string): Cc2imChannel | undefined;
23
+ getChannels(): Cc2imChannel[];
24
+ addChannel(type: string, channelId: string, accountName: string): Promise<void>;
25
+ removeChannel(channelId: string): Promise<void>;
26
+ reconnectChannel(channelId: string): Promise<void>;
27
+ }
@@ -0,0 +1,57 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { randomUUID } from 'node:crypto';
3
+ export class HubContextImpl extends EventEmitter {
4
+ socketServer;
5
+ agentManager;
6
+ router;
7
+ config;
8
+ channels = new Map();
9
+ constructor(socketServer, agentManager, router, config) {
10
+ super();
11
+ this.socketServer = socketServer;
12
+ this.agentManager = agentManager;
13
+ this.router = router;
14
+ this.config = config;
15
+ }
16
+ deliverToAgent(agentId, msg) {
17
+ const messageId = randomUUID();
18
+ this.emit('deliver:before', agentId, msg, messageId);
19
+ const ok = this.socketServer.send(agentId, msg);
20
+ this.emit('deliver:after', messageId, ok);
21
+ return ok;
22
+ }
23
+ broadcastMonitor(event) {
24
+ this.socketServer.broadcast(event);
25
+ }
26
+ getConnectedAgents() {
27
+ return this.socketServer.getConnectedAgents();
28
+ }
29
+ getAgentManager() {
30
+ return this.agentManager;
31
+ }
32
+ getRouter() {
33
+ return this.router;
34
+ }
35
+ getConfig() {
36
+ return this.config;
37
+ }
38
+ registerChannel(channel) {
39
+ this.channels.set(channel.id, channel);
40
+ }
41
+ getChannel(channelId) {
42
+ return this.channels.get(channelId);
43
+ }
44
+ getChannels() {
45
+ return Array.from(this.channels.values());
46
+ }
47
+ async addChannel(type, channelId, accountName) {
48
+ this.emit('channel:add', type, channelId, accountName);
49
+ }
50
+ async removeChannel(channelId) {
51
+ this.channels.delete(channelId);
52
+ this.emit('channel:remove', channelId);
53
+ }
54
+ async reconnectChannel(channelId) {
55
+ this.emit('channel:reconnect', channelId);
56
+ }
57
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * cc2im Hub — 常驻进程,核心路由 + 插件加载
3
+ */
4
+ export declare function startHub(options?: {
5
+ autoStartAgents?: boolean;
6
+ }): Promise<void>;
@@ -0,0 +1,234 @@
1
+ /**
2
+ * cc2im Hub — 常驻进程,核心路由 + 插件加载
3
+ */
4
+ import { existsSync, readFileSync, statSync, truncateSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { HubSocketServer } from './socket-server.js';
7
+ import { Router } from './router.js';
8
+ import { AgentManager } from './agent-manager.js';
9
+ import { HubContextImpl } from './hub-context.js';
10
+ import { PluginManager } from './plugin-manager.js';
11
+ import { createPersistencePlugin } from '../plugins/persistence/index.js';
12
+ import { createCronSchedulerPlugin } from '../plugins/cron-scheduler/index.js';
13
+ import { WeixinChannel } from '../plugins/weixin/weixin-channel.js';
14
+ import { loadChannelConfigs } from '../shared/channel-config.js';
15
+ import { createChannelManagerPlugin } from '../plugins/channel-manager/index.js';
16
+ import { createWebMonitorPlugin } from '../plugins/web-monitor/index.js';
17
+ import { SOCKET_DIR } from '../shared/socket.js';
18
+ // --- Config ---
19
+ const AGENTS_JSON_PATH = join(SOCKET_DIR, 'agents.json');
20
+ function loadAgentsConfig() {
21
+ if (existsSync(AGENTS_JSON_PATH)) {
22
+ return JSON.parse(readFileSync(AGENTS_JSON_PATH, 'utf8'));
23
+ }
24
+ return { defaultAgent: 'brain', agents: {} };
25
+ }
26
+ const MAX_LOG_BYTES = 50 * 1024 * 1024; // 50 MB
27
+ function truncateLogs() {
28
+ const logDir = SOCKET_DIR;
29
+ for (const name of ['hub.log', 'hub.error.log']) {
30
+ const p = join(logDir, name);
31
+ try {
32
+ if (existsSync(p) && statSync(p).size > MAX_LOG_BYTES) {
33
+ truncateSync(p, 0);
34
+ console.log(`[hub] Truncated oversized log: ${name}`);
35
+ }
36
+ }
37
+ catch { }
38
+ }
39
+ }
40
+ // --- Main ---
41
+ export async function startHub(options) {
42
+ truncateLogs();
43
+ const config = loadAgentsConfig();
44
+ const router = new Router(config);
45
+ let socketServer;
46
+ let ctx;
47
+ const agentManager = new AgentManager(() => socketServer.getConnectedAgents(), (kind, agentId, extra) => {
48
+ const event = { kind: kind, agentId, timestamp: new Date().toISOString(), ...extra };
49
+ ctx.broadcastMonitor(event);
50
+ if (kind === 'agent_dead')
51
+ ctx.emit('agent:dead', agentId);
52
+ });
53
+ socketServer = new HubSocketServer(async (agentId, msg) => {
54
+ // Management messages are core — handle before emitting to plugins
55
+ if (msg.type === 'management') {
56
+ await handleManagement(agentId, msg, agentManager, router, socketServer, ctx);
57
+ return;
58
+ }
59
+ ctx.emit('spoke:message', agentId, msg);
60
+ }, {
61
+ onEvict: (agentId) => {
62
+ ctx.emit('agent:evicted', agentId);
63
+ // Kill the process — child.on('exit') in AgentManager will handle restart
64
+ if (agentManager.isManaged(agentId)) {
65
+ agentManager.killForRestart(agentId);
66
+ }
67
+ },
68
+ onAgentOnline: (agentId) => ctx.emit('agent:online', agentId),
69
+ onAgentOffline: (agentId) => ctx.emit('agent:offline', agentId),
70
+ });
71
+ ctx = new HubContextImpl(socketServer, agentManager, router, config);
72
+ const pluginManager = new PluginManager();
73
+ const channelConfigs = loadChannelConfigs();
74
+ const channels = channelConfigs.map(cfg => {
75
+ switch (cfg.type) {
76
+ case 'weixin':
77
+ return new WeixinChannel(cfg.id, cfg.accountName);
78
+ default:
79
+ console.warn(`[hub] Unknown channel type: ${cfg.type}, skipping "${cfg.id}"`);
80
+ return null;
81
+ }
82
+ }).filter((ch) => ch !== null);
83
+ if (channels.length === 0) {
84
+ console.warn('[hub] No channels configured, creating default weixin channel');
85
+ channels.push(new WeixinChannel());
86
+ }
87
+ pluginManager.register(createPersistencePlugin());
88
+ pluginManager.register(createCronSchedulerPlugin());
89
+ pluginManager.register(createChannelManagerPlugin(channels));
90
+ pluginManager.register(createWebMonitorPlugin());
91
+ // --- Start ---
92
+ await socketServer.start();
93
+ await pluginManager.initAll(ctx);
94
+ if (options?.autoStartAgents) {
95
+ agentManager.startAutoAgents();
96
+ }
97
+ ctx.emit('hub:ready');
98
+ // Graceful shutdown
99
+ const shutdown = async () => {
100
+ console.log('[hub] Shutting down...');
101
+ ctx.emit('hub:shutdown');
102
+ await pluginManager.destroyAll();
103
+ await agentManager.stopAll();
104
+ socketServer.stop();
105
+ process.exit(0);
106
+ };
107
+ process.on('SIGTERM', shutdown);
108
+ process.on('SIGINT', shutdown);
109
+ }
110
+ /** Handle management messages (agent register/start/stop/list) — core logic */
111
+ async function handleManagement(agentId, msg, agentManager, router, socketServer, ctx) {
112
+ console.log(`[hub] Management request from ${agentId}: ${msg.action}`);
113
+ const targetName = msg.params?.name;
114
+ const isSelfAction = targetName === agentId &&
115
+ (msg.action === 'stop' || msg.action === 'deregister');
116
+ const sendResult = (result) => {
117
+ socketServer.send(agentId, {
118
+ type: 'management_result',
119
+ requestId: msg.requestId,
120
+ success: result.success,
121
+ data: result.data,
122
+ error: result.error,
123
+ });
124
+ };
125
+ let result;
126
+ switch (msg.action) {
127
+ case 'register': {
128
+ result = agentManager.register(msg.params.name, msg.params.cwd, msg.params.claudeArgs);
129
+ if (result.success) {
130
+ router.updateConfig(agentManager.getConfig());
131
+ ctx.broadcastMonitor({ kind: 'config_changed', agentId: msg.params.name, timestamp: new Date().toISOString() });
132
+ }
133
+ break;
134
+ }
135
+ case 'deregister': {
136
+ if (isSelfAction) {
137
+ sendResult({ success: true });
138
+ await agentManager.deregister(msg.params.name);
139
+ router.updateConfig(agentManager.getConfig());
140
+ ctx.broadcastMonitor({ kind: 'config_changed', agentId: msg.params.name, timestamp: new Date().toISOString() });
141
+ return;
142
+ }
143
+ result = await agentManager.deregister(msg.params.name);
144
+ if (result.success) {
145
+ router.updateConfig(agentManager.getConfig());
146
+ ctx.broadcastMonitor({ kind: 'config_changed', agentId: msg.params.name, timestamp: new Date().toISOString() });
147
+ }
148
+ break;
149
+ }
150
+ case 'start': {
151
+ result = agentManager.start(msg.params.name);
152
+ break;
153
+ }
154
+ case 'stop': {
155
+ if (!agentManager.isManaged(targetName)) {
156
+ result = { success: false, error: `Agent "${targetName}" is not managed by this hub (started externally)` };
157
+ break;
158
+ }
159
+ if (isSelfAction) {
160
+ sendResult({ success: true });
161
+ await agentManager.stop(msg.params.name);
162
+ return;
163
+ }
164
+ result = await agentManager.stop(msg.params.name);
165
+ break;
166
+ }
167
+ case 'list': {
168
+ result = { success: true, data: agentManager.list() };
169
+ break;
170
+ }
171
+ case 'cron_create': {
172
+ const { createJob: dbCreate, CronScheduler: Sched } = await import('../plugins/cron-scheduler/index.js');
173
+ const p = msg.params;
174
+ const tmpSched = new Sched(ctx);
175
+ const nextRun = tmpSched.calcNextRun(p.scheduleType, p.scheduleValue, p.timezone || 'Asia/Shanghai');
176
+ if (!nextRun) {
177
+ const errMsg = p.scheduleType === 'once'
178
+ ? 'Once schedule is in the past'
179
+ : `Invalid schedule: ${p.scheduleType} "${p.scheduleValue}"`;
180
+ result = { success: false, error: errMsg };
181
+ break;
182
+ }
183
+ const job = dbCreate({
184
+ name: p.name,
185
+ agentId: p.agentId || agentId,
186
+ scheduleType: p.scheduleType,
187
+ scheduleValue: p.scheduleValue,
188
+ timezone: p.timezone || 'Asia/Shanghai',
189
+ message: p.message,
190
+ enabled: true,
191
+ nextRun,
192
+ createdBy: agentId,
193
+ });
194
+ result = { success: true, data: job };
195
+ break;
196
+ }
197
+ case 'cron_list': {
198
+ const { listJobs: dbList, getRecentRuns: dbRuns } = await import('../plugins/cron-scheduler/index.js');
199
+ const jobs = dbList(msg.params?.agentId);
200
+ const data = jobs.map(j => ({
201
+ ...j,
202
+ recentRuns: dbRuns(j.id, 5),
203
+ }));
204
+ result = { success: true, data };
205
+ break;
206
+ }
207
+ case 'cron_delete': {
208
+ const { deleteJob: dbDelete } = await import('../plugins/cron-scheduler/index.js');
209
+ const ok = dbDelete(msg.params.jobId);
210
+ result = ok ? { success: true } : { success: false, error: 'Job not found' };
211
+ break;
212
+ }
213
+ case 'cron_update': {
214
+ const { updateJob: dbUpdate } = await import('../plugins/cron-scheduler/index.js');
215
+ const { jobId, ...updates } = msg.params;
216
+ const ok = dbUpdate(jobId, updates);
217
+ result = ok ? { success: true } : { success: false, error: 'Job not found' };
218
+ break;
219
+ }
220
+ default:
221
+ result = { success: false, error: `Unknown action: ${msg.action}` };
222
+ }
223
+ if (!isSelfAction) {
224
+ sendResult(result);
225
+ }
226
+ }
227
+ // Run if executed directly (not imported)
228
+ const isDirectRun = process.argv[1]?.includes('hub/index');
229
+ if (isDirectRun) {
230
+ startHub().catch((err) => {
231
+ console.error(`[hub] Fatal: ${err.message}`);
232
+ process.exit(1);
233
+ });
234
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * launchd 集成 — macOS 后台服务化
3
+ */
4
+ export declare function install(): void;
5
+ export declare function uninstall(): void;
6
+ export declare function status(): void;
7
+ export declare function logs(): void;