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,151 @@
1
+ /**
2
+ * launchd 集成 — macOS 后台服务化
3
+ */
4
+ import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { execSync, spawn } from 'node:child_process';
8
+ import { SOCKET_DIR } from '../shared/socket.js';
9
+ const PLIST_LABEL = 'com.cc2im.hub';
10
+ const PLIST_DIR = join(homedir(), 'Library', 'LaunchAgents');
11
+ const PLIST_PATH = join(PLIST_DIR, `${PLIST_LABEL}.plist`);
12
+ const LOG_PATH = join(SOCKET_DIR, 'hub.log');
13
+ const ERROR_LOG_PATH = join(SOCKET_DIR, 'hub.error.log');
14
+ function findBinary(name) {
15
+ try {
16
+ return execSync(`which ${name}`, { encoding: 'utf8' }).trim();
17
+ }
18
+ catch {
19
+ throw new Error(`"${name}" not found in PATH`);
20
+ }
21
+ }
22
+ function getCliScript() {
23
+ // Resolve cli.ts/cli.js path relative to this file
24
+ const dir = import.meta.dirname;
25
+ const cliTs = join(dir, '..', 'cli.ts');
26
+ const cliJs = join(dir, '..', 'cli.js');
27
+ if (existsSync(cliTs))
28
+ return cliTs;
29
+ if (existsSync(cliJs))
30
+ return cliJs;
31
+ throw new Error('Cannot find cli entry point');
32
+ }
33
+ export function install() {
34
+ if (process.platform !== 'darwin') {
35
+ console.error('[cc2im] launchd is macOS-only');
36
+ process.exit(1);
37
+ }
38
+ const npxPath = findBinary('npx');
39
+ const cliScript = getCliScript();
40
+ const pathEnv = process.env.PATH || '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin';
41
+ mkdirSync(PLIST_DIR, { recursive: true });
42
+ mkdirSync(SOCKET_DIR, { recursive: true });
43
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
44
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
45
+ <plist version="1.0">
46
+ <dict>
47
+ <key>Label</key>
48
+ <string>${PLIST_LABEL}</string>
49
+ <key>ProgramArguments</key>
50
+ <array>
51
+ <string>caffeinate</string>
52
+ <string>-i</string>
53
+ <string>${npxPath}</string>
54
+ <string>tsx</string>
55
+ <string>${cliScript}</string>
56
+ <string>start</string>
57
+ </array>
58
+ <key>RunAtLoad</key>
59
+ <true/>
60
+ <key>KeepAlive</key>
61
+ <true/>
62
+ <key>StandardOutPath</key>
63
+ <string>${LOG_PATH}</string>
64
+ <key>StandardErrorPath</key>
65
+ <string>${ERROR_LOG_PATH}</string>
66
+ <key>EnvironmentVariables</key>
67
+ <dict>
68
+ <key>PATH</key>
69
+ <string>${pathEnv}</string>
70
+ <key>HOME</key>
71
+ <string>${homedir()}</string>
72
+ </dict>
73
+ <key>WorkingDirectory</key>
74
+ <string>${homedir()}</string>
75
+ </dict>
76
+ </plist>
77
+ `;
78
+ writeFileSync(PLIST_PATH, plist);
79
+ console.log(`[cc2im] Plist written to ${PLIST_PATH}`);
80
+ try {
81
+ execSync(`launchctl load ${PLIST_PATH}`, { stdio: 'inherit' });
82
+ console.log(`[cc2im] Service loaded: ${PLIST_LABEL}`);
83
+ }
84
+ catch {
85
+ console.error('[cc2im] Failed to load service. You may need to load manually:');
86
+ console.error(` launchctl load ${PLIST_PATH}`);
87
+ }
88
+ }
89
+ export function uninstall() {
90
+ if (existsSync(PLIST_PATH)) {
91
+ try {
92
+ execSync(`launchctl unload ${PLIST_PATH}`, { stdio: 'inherit' });
93
+ console.log(`[cc2im] Service unloaded`);
94
+ }
95
+ catch {
96
+ console.log('[cc2im] Service was not loaded');
97
+ }
98
+ unlinkSync(PLIST_PATH);
99
+ console.log(`[cc2im] Plist removed: ${PLIST_PATH}`);
100
+ }
101
+ else {
102
+ console.log(`[cc2im] No plist found at ${PLIST_PATH}`);
103
+ }
104
+ }
105
+ export function status() {
106
+ // Check launchd service
107
+ try {
108
+ const output = execSync('launchctl list', { encoding: 'utf8' });
109
+ const line = output.split('\n').find(l => l.includes('cc2im'));
110
+ if (line) {
111
+ console.log(`Hub service: ${line.trim()}`);
112
+ }
113
+ else {
114
+ console.log('Hub service: not loaded');
115
+ }
116
+ }
117
+ catch {
118
+ console.log('Hub service: unable to check');
119
+ }
120
+ // Check socket file
121
+ const hubSockPath = join(SOCKET_DIR, 'hub.sock');
122
+ console.log(`Hub socket: ${existsSync(hubSockPath) ? 'exists' : 'not found'}`);
123
+ // Check agents.json
124
+ const agentsPath = join(SOCKET_DIR, 'agents.json');
125
+ if (existsSync(agentsPath)) {
126
+ try {
127
+ const config = JSON.parse(readFileSync(agentsPath, 'utf8'));
128
+ const names = Object.keys(config.agents);
129
+ console.log(`Agents configured: ${names.join(', ') || 'none'}`);
130
+ console.log(`Default agent: ${config.defaultAgent}`);
131
+ }
132
+ catch {
133
+ console.log('Agents config: unable to parse');
134
+ }
135
+ }
136
+ }
137
+ export function logs() {
138
+ console.log(`[cc2im] Tailing logs from ${LOG_PATH} and ${ERROR_LOG_PATH}`);
139
+ console.log('Press Ctrl+C to stop.\n');
140
+ const files = [];
141
+ if (existsSync(LOG_PATH))
142
+ files.push(LOG_PATH);
143
+ if (existsSync(ERROR_LOG_PATH))
144
+ files.push(ERROR_LOG_PATH);
145
+ if (files.length === 0) {
146
+ console.log('No log files found yet.');
147
+ return;
148
+ }
149
+ const tail = spawn('tail', ['-f', ...files], { stdio: 'inherit' });
150
+ tail.on('exit', () => process.exit(0));
151
+ }
@@ -0,0 +1,7 @@
1
+ import type { Cc2imPlugin, HubContext } from '../shared/plugin.js';
2
+ export declare class PluginManager {
3
+ private plugins;
4
+ register(plugin: Cc2imPlugin): void;
5
+ initAll(ctx: HubContext): Promise<void>;
6
+ destroyAll(): Promise<void>;
7
+ }
@@ -0,0 +1,29 @@
1
+ export class PluginManager {
2
+ plugins = [];
3
+ register(plugin) {
4
+ this.plugins.push(plugin);
5
+ console.log(`[plugin] Registered: ${plugin.name}`);
6
+ }
7
+ async initAll(ctx) {
8
+ for (const plugin of this.plugins) {
9
+ try {
10
+ await plugin.init(ctx);
11
+ console.log(`[plugin] Initialized: ${plugin.name}`);
12
+ }
13
+ catch (err) {
14
+ console.error(`[plugin] Failed to init "${plugin.name}": ${err.message}`);
15
+ }
16
+ }
17
+ }
18
+ async destroyAll() {
19
+ for (const plugin of [...this.plugins].reverse()) {
20
+ try {
21
+ await plugin.destroy();
22
+ console.log(`[plugin] Destroyed: ${plugin.name}`);
23
+ }
24
+ catch (err) {
25
+ console.error(`[plugin] Failed to destroy "${plugin.name}": ${err.message}`);
26
+ }
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,21 @@
1
+ import type { AgentsConfig } from '../shared/types.js';
2
+ export interface RouteResult {
3
+ agentId: string;
4
+ text: string;
5
+ /** true when @mentioned an agent not in config */
6
+ unknownAgent: boolean;
7
+ /** intercepted hub command (not forwarded to spoke) */
8
+ intercepted?: {
9
+ command: 'restart' | 'effort';
10
+ args?: string[];
11
+ };
12
+ /** channel that originated this message */
13
+ channelId?: string;
14
+ }
15
+ export declare class Router {
16
+ private config;
17
+ constructor(config: AgentsConfig);
18
+ route(text: string, channelId?: string): RouteResult;
19
+ getAgentNames(): string[];
20
+ updateConfig(config: AgentsConfig): void;
21
+ }
@@ -0,0 +1,35 @@
1
+ export class Router {
2
+ config;
3
+ constructor(config) {
4
+ this.config = config;
5
+ }
6
+ route(text, channelId) {
7
+ const match = text.match(/^@(\S+)\s+([\s\S]+)$/);
8
+ if (match) {
9
+ const name = match[1];
10
+ const content = match[2].trim();
11
+ if (this.config.agents[name]) {
12
+ // Check for intercepted commands
13
+ if (/^(重启|restart)$/i.test(content)) {
14
+ return { agentId: name, text: content, unknownAgent: false, intercepted: { command: 'restart' }, channelId };
15
+ }
16
+ const effortMatch = content.match(/^\/effort\s+(\S+)$/i);
17
+ if (effortMatch) {
18
+ return { agentId: name, text: content, unknownAgent: false, intercepted: { command: 'effort', args: [effortMatch[1]] }, channelId };
19
+ }
20
+ return { agentId: name, text: content, unknownAgent: false, channelId };
21
+ }
22
+ // @name not found in config
23
+ return { agentId: name, text, unknownAgent: true, channelId };
24
+ }
25
+ // No @mention: use channel default → global default
26
+ const defaultAgent = (channelId && this.config.channelDefaults?.[channelId]) || this.config.defaultAgent;
27
+ return { agentId: defaultAgent, text, unknownAgent: false, channelId };
28
+ }
29
+ getAgentNames() {
30
+ return Object.keys(this.config.agents);
31
+ }
32
+ updateConfig(config) {
33
+ this.config = config;
34
+ }
35
+ }
@@ -0,0 +1,23 @@
1
+ import type { SpokeToHub, HubToSpoke, HubEventData } from '../shared/types.js';
2
+ export declare class HubSocketServer {
3
+ private spokes;
4
+ private lastHeartbeat;
5
+ private monitors;
6
+ private server;
7
+ private onMessage;
8
+ private onEvict?;
9
+ private onAgentOnline?;
10
+ private onAgentOffline?;
11
+ private heartbeatChecker;
12
+ constructor(onMessage: (agentId: string, msg: SpokeToHub) => void, opts?: {
13
+ onEvict?: (agentId: string) => void;
14
+ onAgentOnline?: (agentId: string) => void;
15
+ onAgentOffline?: (agentId: string) => void;
16
+ });
17
+ start(): Promise<void>;
18
+ send(agentId: string, msg: HubToSpoke): boolean;
19
+ /** Broadcast an event to all connected monitors. No-op if none connected. */
20
+ broadcast(event: HubEventData): void;
21
+ getConnectedAgents(): string[];
22
+ stop(): void;
23
+ }
@@ -0,0 +1,191 @@
1
+ import { createServer, createConnection } from 'node:net';
2
+ import { unlinkSync, existsSync } from 'node:fs';
3
+ import { HUB_SOCKET_PATH, ensureSocketDir, encodeFrame, createFrameParser } from '../shared/socket.js';
4
+ /** Check if another hub is already listening on the socket path. */
5
+ function probeSocket(socketPath) {
6
+ return new Promise((resolve) => {
7
+ const timeout = setTimeout(() => {
8
+ conn.destroy();
9
+ resolve(false); // Timed out — treat as stale
10
+ }, 1000);
11
+ const conn = createConnection(socketPath, () => {
12
+ // Connection succeeded — another hub is alive
13
+ clearTimeout(timeout);
14
+ conn.destroy();
15
+ resolve(true);
16
+ });
17
+ conn.on('error', () => {
18
+ clearTimeout(timeout);
19
+ resolve(false); // ECONNREFUSED / ENOENT — no hub listening
20
+ });
21
+ });
22
+ }
23
+ const HEARTBEAT_TIMEOUT_MS = 45_000; // 3 missed heartbeats (15s interval)
24
+ export class HubSocketServer {
25
+ spokes = new Map();
26
+ lastHeartbeat = new Map();
27
+ monitors = new Set();
28
+ server = createServer();
29
+ onMessage;
30
+ onEvict;
31
+ onAgentOnline;
32
+ onAgentOffline;
33
+ heartbeatChecker = null;
34
+ constructor(onMessage, opts) {
35
+ this.onMessage = onMessage;
36
+ this.onEvict = opts?.onEvict;
37
+ this.onAgentOnline = opts?.onAgentOnline;
38
+ this.onAgentOffline = opts?.onAgentOffline;
39
+ }
40
+ async start() {
41
+ ensureSocketDir();
42
+ // Probe existing socket — refuse to start if another hub is alive
43
+ if (existsSync(HUB_SOCKET_PATH)) {
44
+ const alive = await probeSocket(HUB_SOCKET_PATH);
45
+ if (alive) {
46
+ console.error(`[hub] ✗ Another hub is already running on ${HUB_SOCKET_PATH}. Stop it first.`);
47
+ process.exit(1);
48
+ }
49
+ // Stale socket from a crash — safe to clean up
50
+ unlinkSync(HUB_SOCKET_PATH);
51
+ }
52
+ this.server.on('connection', (socket) => {
53
+ let agentId = null;
54
+ let isMonitor = false;
55
+ const parser = createFrameParser((frame) => {
56
+ // First message must be registration (spoke or monitor)
57
+ if (!agentId && !isMonitor) {
58
+ if (frame.type === 'register_monitor') {
59
+ isMonitor = true;
60
+ this.monitors.add(socket);
61
+ console.log(`[hub] Monitor connected (${this.monitors.size} total)`);
62
+ // Send current state: all connected spokes
63
+ const now = new Date().toISOString();
64
+ for (const agId of this.spokes.keys()) {
65
+ socket.write(encodeFrame({ type: 'hub_event', event: { kind: 'agent_online', agentId: agId, timestamp: now } }));
66
+ }
67
+ return;
68
+ }
69
+ if (frame.type === 'register') {
70
+ agentId = frame.agentId;
71
+ const existing = this.spokes.get(agentId);
72
+ if (existing && existing.socket !== socket) {
73
+ console.log(`[hub] Replacing stale connection for ${agentId}`);
74
+ existing.socket.destroy();
75
+ if (existing.pid) {
76
+ try {
77
+ process.kill(existing.pid, 'SIGTERM');
78
+ console.log(`[hub] Killed replaced spoke "${agentId}" (pid ${existing.pid})`);
79
+ }
80
+ catch { /* already dead */ }
81
+ }
82
+ }
83
+ const rawPid = frame.pid;
84
+ const pid = typeof rawPid === 'number' && rawPid > 0 && Number.isInteger(rawPid) ? rawPid : undefined;
85
+ this.spokes.set(agentId, { agentId: agentId, socket, pid });
86
+ this.lastHeartbeat.set(agentId, Date.now());
87
+ console.log(`[hub] Spoke registered: ${agentId}`);
88
+ this.broadcast({ kind: 'agent_online', agentId: agentId, timestamp: new Date().toISOString() });
89
+ this.onAgentOnline?.(agentId);
90
+ return;
91
+ }
92
+ }
93
+ // Monitor is read-only, ignore any further messages
94
+ if (isMonitor)
95
+ return;
96
+ if (agentId) {
97
+ // Track heartbeats silently
98
+ if (frame.type === 'heartbeat') {
99
+ this.lastHeartbeat.set(agentId, Date.now());
100
+ return;
101
+ }
102
+ this.onMessage(agentId, frame);
103
+ }
104
+ });
105
+ socket.on('data', parser);
106
+ socket.on('close', () => {
107
+ if (isMonitor) {
108
+ this.monitors.delete(socket);
109
+ console.log(`[hub] Monitor disconnected (${this.monitors.size} total)`);
110
+ return;
111
+ }
112
+ if (agentId) {
113
+ const current = this.spokes.get(agentId);
114
+ if (current && current.socket === socket) {
115
+ this.spokes.delete(agentId);
116
+ this.lastHeartbeat.delete(agentId);
117
+ console.log(`[hub] Spoke disconnected: ${agentId}`);
118
+ this.broadcast({ kind: 'agent_offline', agentId, timestamp: new Date().toISOString() });
119
+ this.onAgentOffline?.(agentId);
120
+ }
121
+ }
122
+ });
123
+ socket.on('error', (err) => {
124
+ console.error(`[hub] Socket error (${agentId || 'monitor'}):`, err.message);
125
+ });
126
+ });
127
+ this.server.listen(HUB_SOCKET_PATH, () => {
128
+ console.log(`[hub] Listening on ${HUB_SOCKET_PATH}`);
129
+ });
130
+ // Periodically evict spokes that missed heartbeats + clean stale monitors
131
+ this.heartbeatChecker = setInterval(() => {
132
+ for (const s of this.monitors) {
133
+ if (s.destroyed)
134
+ this.monitors.delete(s);
135
+ }
136
+ const now = Date.now();
137
+ for (const [agentId, lastSeen] of this.lastHeartbeat) {
138
+ if (now - lastSeen > HEARTBEAT_TIMEOUT_MS) {
139
+ const spoke = this.spokes.get(agentId);
140
+ if (spoke) {
141
+ console.log(`[hub] Evicting stale spoke "${agentId}" (no heartbeat for ${Math.round((now - lastSeen) / 1000)}s)`);
142
+ spoke.socket.destroy();
143
+ this.spokes.delete(agentId);
144
+ this.lastHeartbeat.delete(agentId);
145
+ this.broadcast({ kind: 'agent_offline', agentId, timestamp: new Date().toISOString() });
146
+ if (spoke.pid) {
147
+ try {
148
+ process.kill(spoke.pid, 'SIGTERM');
149
+ console.log(`[hub] Sent SIGTERM to zombie spoke "${agentId}" (pid ${spoke.pid})`);
150
+ }
151
+ catch {
152
+ // Process already dead — that's fine
153
+ }
154
+ }
155
+ this.onEvict?.(agentId);
156
+ }
157
+ }
158
+ }
159
+ }, 15_000);
160
+ }
161
+ send(agentId, msg) {
162
+ const spoke = this.spokes.get(agentId);
163
+ if (!spoke)
164
+ return false;
165
+ const ok = spoke.socket.write(encodeFrame(msg));
166
+ if (!ok)
167
+ console.log(`[hub] ⚠ Back-pressure on socket to ${agentId}`);
168
+ if (spoke.socket.destroyed)
169
+ console.log(`[hub] ⚠ Socket to ${agentId} is destroyed!`);
170
+ return true;
171
+ }
172
+ /** Broadcast an event to all connected monitors. No-op if none connected. */
173
+ broadcast(event) {
174
+ if (this.monitors.size === 0)
175
+ return;
176
+ const frame = encodeFrame({ type: 'hub_event', event });
177
+ for (const socket of this.monitors) {
178
+ socket.write(frame);
179
+ }
180
+ }
181
+ getConnectedAgents() {
182
+ return [...this.spokes.keys()];
183
+ }
184
+ stop() {
185
+ if (this.heartbeatChecker)
186
+ clearInterval(this.heartbeatChecker);
187
+ this.server.close();
188
+ if (existsSync(HUB_SOCKET_PATH))
189
+ unlinkSync(HUB_SOCKET_PATH);
190
+ }
191
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * ChannelManager plugin — unified channel lifecycle, routing, and permissions.
3
+ *
4
+ * Replaces the weixin plugin's "glue" logic with a channel-agnostic implementation.
5
+ * Owns: channel lifecycle, message routing (channel<->agent), typing indicators,
6
+ * pending-ack timers, permission management, and user-tracking per agent.
7
+ */
8
+ import type { Cc2imPlugin } from '../../shared/plugin.js';
9
+ import type { Cc2imChannel } from '../../shared/channel.js';
10
+ export declare function createChannelManagerPlugin(channels: Cc2imChannel[]): Cc2imPlugin;