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,115 @@
1
+ /**
2
+ * cc2im Spoke — MCP channel server,桥接 CC ↔ Hub
3
+ *
4
+ * 启动流程:
5
+ * 1. 从参数读取 agentId
6
+ * 2. 连接 hub Unix socket,注册
7
+ * 3. 启动 MCP channel server(stdio transport,给 CC 用)
8
+ * 4. hub 消息 → MCP channel notification → CC
9
+ * 5. CC weixin_reply → spoke → hub → 微信
10
+ * 6. CC permission_request → spoke → hub → 微信
11
+ * 7. 微信 verdict → hub → spoke → CC
12
+ */
13
+ import { appendFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { SOCKET_DIR } from '../shared/socket.js';
16
+ import { SpokeSocketClient } from './socket-client.js';
17
+ import { createChannelServer, setupTools, connectTransport, handleManagementResult } from './channel-server.js';
18
+ import { PermissionRelay } from './permission.js';
19
+ // --- Parse args ---
20
+ const args = process.argv.slice(2);
21
+ const agentIdIdx = args.indexOf('--agent-id');
22
+ const agentId = agentIdIdx >= 0 ? args[agentIdIdx + 1] : 'default';
23
+ if (!agentId) {
24
+ console.error('Usage: cc2im-spoke --agent-id <name>');
25
+ process.exit(1);
26
+ }
27
+ // --- Redirect stdout/stderr to log file (stdio is reserved for MCP) ---
28
+ const LOG_FILE = join(SOCKET_DIR, 'agents', agentId, 'spoke.log');
29
+ function log(...msgArgs) {
30
+ const line = `[${new Date().toISOString()}] ${msgArgs.join(' ')}\n`;
31
+ process.stderr.write(line);
32
+ try {
33
+ appendFileSync(LOG_FILE, line);
34
+ }
35
+ catch { }
36
+ }
37
+ console.log = log;
38
+ console.error = (...msgArgs) => log('[ERROR]', ...msgArgs);
39
+ // --- Setup ---
40
+ const server = createChannelServer(agentId);
41
+ const socketClient = new SpokeSocketClient(agentId, (msg) => {
42
+ switch (msg.type) {
43
+ case 'message': {
44
+ // Forward to CC as channel notification
45
+ tools.setLastUserId(msg.userId);
46
+ server.notification({
47
+ method: 'notifications/claude/channel',
48
+ params: {
49
+ content: msg.text,
50
+ meta: {
51
+ userId: msg.userId,
52
+ type: msg.msgType,
53
+ source: 'weixin',
54
+ timestamp: msg.timestamp,
55
+ },
56
+ },
57
+ }).catch((err) => {
58
+ console.error(`[spoke:${agentId}] Failed to push channel notification: ${err.message}`);
59
+ });
60
+ break;
61
+ }
62
+ case 'permission_verdict': {
63
+ permissionRelay.handleVerdict(msg.requestId, msg.behavior, msg.toolName);
64
+ break;
65
+ }
66
+ case 'management_result': {
67
+ handleManagementResult(msg);
68
+ break;
69
+ }
70
+ }
71
+ });
72
+ const tools = setupTools(server, agentId, socketClient);
73
+ const permissionRelay = new PermissionRelay(agentId, server, socketClient, tools.getCurrentUserId);
74
+ permissionRelay.setup();
75
+ // --- Exit when CC disconnects ---
76
+ let exiting = false;
77
+ function gracefulExit(reason) {
78
+ if (exiting)
79
+ return;
80
+ exiting = true;
81
+ console.log(`[spoke:${agentId}] ${reason}, exiting`);
82
+ socketClient.disconnect();
83
+ process.exit(0);
84
+ }
85
+ // Method 1: stdin EOF / close
86
+ process.stdin.on('end', () => gracefulExit('CC disconnected (stdin EOF)'));
87
+ process.stdin.on('close', () => gracefulExit('CC disconnected (stdin close)'));
88
+ process.stdin.resume();
89
+ // Method 2: MCP transport close
90
+ server.onclose = () => gracefulExit('MCP transport closed');
91
+ // Method 3: Poll parent PID — when CC dies, PPID becomes 1 (launchd/init)
92
+ const ppidCheck = setInterval(() => {
93
+ if (process.ppid === 1) {
94
+ clearInterval(ppidCheck);
95
+ gracefulExit('Parent process gone (ppid became 1)');
96
+ }
97
+ }, 3000);
98
+ // --- Start ---
99
+ async function main() {
100
+ // Connect MCP stdio first (CC is waiting for it)
101
+ await connectTransport(server);
102
+ // Then connect to hub
103
+ await socketClient.connect();
104
+ // Report ready
105
+ socketClient.send({ type: 'status', agentId, status: 'ready' });
106
+ // Heartbeat every 15s
107
+ setInterval(() => {
108
+ socketClient.send({ type: 'heartbeat', agentId });
109
+ }, 15_000);
110
+ console.log(`[spoke:${agentId}] Ready`);
111
+ }
112
+ main().catch((err) => {
113
+ console.error(`[spoke:${agentId}] Fatal: ${err.message}`);
114
+ process.exit(1);
115
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Permission relay — spoke 侧
3
+ * 从 cc2wx.ts:257-425 搬迁,改为走 hub socket
4
+ *
5
+ * 流程:
6
+ * CC permission_request → spoke 转发到 hub → hub 转发到微信
7
+ * 微信 verdict → hub → spoke → CC
8
+ */
9
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
+ import type { SpokeSocketClient } from './socket-client.js';
11
+ export declare class PermissionRelay {
12
+ private agentId;
13
+ private server;
14
+ private socketClient;
15
+ private getCurrentUserId?;
16
+ private alwaysAllow;
17
+ private allowListPath;
18
+ private pendingVerdicts;
19
+ constructor(agentId: string, server: Server, socketClient: SpokeSocketClient, getCurrentUserId?: (() => string | null) | undefined);
20
+ private loadAllowList;
21
+ private saveAllowList;
22
+ /** Register the notification handler on the MCP server */
23
+ setup(): void;
24
+ /** Called when hub sends a verdict back */
25
+ handleVerdict(requestId: string, behavior: 'allow' | 'deny' | 'always', toolName?: string): void;
26
+ /** Add a tool to always-allow list */
27
+ addAlwaysAllow(toolName: string): void;
28
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Permission relay — spoke 侧
3
+ * 从 cc2wx.ts:257-425 搬迁,改为走 hub socket
4
+ *
5
+ * 流程:
6
+ * CC permission_request → spoke 转发到 hub → hub 转发到微信
7
+ * 微信 verdict → hub → spoke → CC
8
+ */
9
+ import { z } from 'zod';
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { SOCKET_DIR } from '../shared/socket.js';
13
+ const PermissionRequestSchema = z.object({
14
+ method: z.literal('notifications/claude/channel/permission_request'),
15
+ params: z.object({
16
+ request_id: z.string(),
17
+ tool_name: z.string(),
18
+ description: z.string(),
19
+ input_preview: z.string(),
20
+ }),
21
+ });
22
+ export class PermissionRelay {
23
+ agentId;
24
+ server;
25
+ socketClient;
26
+ getCurrentUserId;
27
+ alwaysAllow;
28
+ allowListPath;
29
+ pendingVerdicts = new Map();
30
+ constructor(agentId, server, socketClient, getCurrentUserId) {
31
+ this.agentId = agentId;
32
+ this.server = server;
33
+ this.socketClient = socketClient;
34
+ this.getCurrentUserId = getCurrentUserId;
35
+ this.allowListPath = join(SOCKET_DIR, 'agents', agentId, 'always-allow.json');
36
+ this.alwaysAllow = this.loadAllowList();
37
+ }
38
+ loadAllowList() {
39
+ try {
40
+ if (existsSync(this.allowListPath)) {
41
+ const data = JSON.parse(readFileSync(this.allowListPath, 'utf8'));
42
+ console.log(`[spoke:${this.agentId}] Loaded ${data.length} always-allow patterns`);
43
+ return new Set(data);
44
+ }
45
+ }
46
+ catch (err) {
47
+ console.error(`[spoke:${this.agentId}] Failed to load allow list: ${err instanceof Error ? err.message : String(err)}`);
48
+ }
49
+ return new Set();
50
+ }
51
+ saveAllowList() {
52
+ try {
53
+ const dir = join(SOCKET_DIR, 'agents', this.agentId);
54
+ mkdirSync(dir, { recursive: true });
55
+ writeFileSync(this.allowListPath, JSON.stringify([...this.alwaysAllow], null, 2) + '\n');
56
+ console.log(`[spoke:${this.agentId}] Saved ${this.alwaysAllow.size} always-allow patterns`);
57
+ }
58
+ catch (err) {
59
+ console.error(`[spoke:${this.agentId}] Failed to save allow list: ${err instanceof Error ? err.message : String(err)}`);
60
+ }
61
+ }
62
+ /** Register the notification handler on the MCP server */
63
+ setup() {
64
+ this.server.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
65
+ console.log(`[spoke:${this.agentId}] Permission request: id=${params.request_id} tool=${params.tool_name}`);
66
+ // Auto-approve if in always-allow list
67
+ if (this.alwaysAllow.has(params.tool_name)) {
68
+ console.log(`[spoke:${this.agentId}] Auto-approved ${params.tool_name} (always allow)`);
69
+ await this.server.notification({
70
+ method: 'notifications/claude/channel/permission',
71
+ params: { request_id: params.request_id, behavior: 'allow' },
72
+ });
73
+ return;
74
+ }
75
+ // Forward to hub via socket, including originating userId for routing
76
+ const sent = this.socketClient.send({
77
+ type: 'permission_request',
78
+ agentId: this.agentId,
79
+ requestId: params.request_id,
80
+ toolName: params.tool_name,
81
+ description: params.description,
82
+ inputPreview: params.input_preview,
83
+ userId: this.getCurrentUserId?.() || undefined,
84
+ });
85
+ // If hub is disconnected, deny immediately instead of waiting 5 min
86
+ if (!sent) {
87
+ console.log(`[spoke:${this.agentId}] Hub disconnected, auto-denying permission: ${params.tool_name}`);
88
+ await this.server.notification({
89
+ method: 'notifications/claude/channel/permission',
90
+ params: { request_id: params.request_id, behavior: 'deny' },
91
+ });
92
+ return;
93
+ }
94
+ // Wait for verdict from hub (will be resolved by handleVerdict)
95
+ const behavior = await new Promise((resolve) => {
96
+ this.pendingVerdicts.set(params.request_id, { resolve, toolName: params.tool_name });
97
+ // Timeout: auto-deny after 5 minutes
98
+ setTimeout(() => {
99
+ if (this.pendingVerdicts.has(params.request_id)) {
100
+ this.pendingVerdicts.delete(params.request_id);
101
+ resolve('deny');
102
+ this.socketClient.send({
103
+ type: 'permission_timeout',
104
+ agentId: this.agentId,
105
+ requestId: params.request_id,
106
+ });
107
+ console.log(`[spoke:${this.agentId}] Permission timeout: ${params.request_id}`);
108
+ }
109
+ }, 5 * 60 * 1000);
110
+ });
111
+ await this.server.notification({
112
+ method: 'notifications/claude/channel/permission',
113
+ params: { request_id: params.request_id, behavior },
114
+ });
115
+ console.log(`[spoke:${this.agentId}] Permission verdict forwarded to CC: ${params.request_id} → ${behavior}`);
116
+ });
117
+ }
118
+ /** Called when hub sends a verdict back */
119
+ handleVerdict(requestId, behavior, toolName) {
120
+ const pending = this.pendingVerdicts.get(requestId);
121
+ if (pending) {
122
+ this.pendingVerdicts.delete(requestId);
123
+ if (behavior === 'always') {
124
+ const tool = toolName || pending.toolName;
125
+ this.addAlwaysAllow(tool);
126
+ console.log(`[spoke:${this.agentId}] Always-allow added: ${tool}`);
127
+ pending.resolve('allow');
128
+ }
129
+ else {
130
+ pending.resolve(behavior);
131
+ }
132
+ }
133
+ else {
134
+ console.log(`[spoke:${this.agentId}] Verdict for unknown request: ${requestId}`);
135
+ }
136
+ }
137
+ /** Add a tool to always-allow list */
138
+ addAlwaysAllow(toolName) {
139
+ this.alwaysAllow.add(toolName);
140
+ this.saveAllowList();
141
+ }
142
+ }
@@ -0,0 +1,22 @@
1
+ import type { HubToSpoke, SpokeToHub } from '../shared/types.js';
2
+ export declare class SpokeSocketClient {
3
+ private socket;
4
+ private agentId;
5
+ private onMessage;
6
+ private reconnectTimer;
7
+ private reconnectDelay;
8
+ private connected;
9
+ constructor(agentId: string, onMessage: (msg: HubToSpoke) => void);
10
+ /**
11
+ * Start connecting to hub. Resolves immediately — connection happens
12
+ * in the background with auto-reconnect. This ensures the spoke
13
+ * process doesn't hang if the hub is unavailable at startup.
14
+ */
15
+ connect(): Promise<void>;
16
+ private doConnect;
17
+ private scheduleReconnect;
18
+ /** Send a message. Returns false if not connected (message dropped). */
19
+ send(msg: SpokeToHub): boolean;
20
+ isConnected(): boolean;
21
+ disconnect(): void;
22
+ }
@@ -0,0 +1,83 @@
1
+ import { createConnection } from 'node:net';
2
+ import { HUB_SOCKET_PATH, encodeFrame, createFrameParser } from '../shared/socket.js';
3
+ const RECONNECT_INTERVAL = 3000;
4
+ const MAX_RECONNECT_INTERVAL = 30000;
5
+ export class SpokeSocketClient {
6
+ socket = null;
7
+ agentId;
8
+ onMessage;
9
+ reconnectTimer = null;
10
+ reconnectDelay = RECONNECT_INTERVAL;
11
+ connected = false;
12
+ constructor(agentId, onMessage) {
13
+ this.agentId = agentId;
14
+ this.onMessage = onMessage;
15
+ }
16
+ /**
17
+ * Start connecting to hub. Resolves immediately — connection happens
18
+ * in the background with auto-reconnect. This ensures the spoke
19
+ * process doesn't hang if the hub is unavailable at startup.
20
+ */
21
+ connect() {
22
+ this.doConnect();
23
+ return Promise.resolve();
24
+ }
25
+ doConnect() {
26
+ const socket = createConnection(HUB_SOCKET_PATH, () => {
27
+ this.socket = socket;
28
+ this.connected = true;
29
+ this.reconnectDelay = RECONNECT_INTERVAL;
30
+ socket.write(encodeFrame({ type: 'register', agentId: this.agentId, pid: process.pid }));
31
+ console.log(`[spoke:${this.agentId}] Connected to hub`);
32
+ });
33
+ const parser = createFrameParser((frame) => {
34
+ this.onMessage(frame);
35
+ });
36
+ socket.on('data', parser);
37
+ socket.on('error', (err) => {
38
+ // Suppress ECONNREFUSED noise — reconnect handles it
39
+ if (err.code !== 'ECONNREFUSED') {
40
+ console.error(`[spoke:${this.agentId}] Socket error: ${err.message}`);
41
+ }
42
+ });
43
+ socket.on('close', () => {
44
+ const wasConnected = this.connected;
45
+ this.connected = false;
46
+ this.socket = null;
47
+ if (wasConnected) {
48
+ console.log(`[spoke:${this.agentId}] Disconnected from hub`);
49
+ }
50
+ this.scheduleReconnect();
51
+ });
52
+ }
53
+ scheduleReconnect() {
54
+ if (this.reconnectTimer)
55
+ return;
56
+ console.log(`[spoke:${this.agentId}] Reconnecting in ${this.reconnectDelay / 1000}s...`);
57
+ this.reconnectTimer = setTimeout(() => {
58
+ this.reconnectTimer = null;
59
+ this.doConnect();
60
+ }, this.reconnectDelay);
61
+ // 指数退避,上限 30s
62
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_INTERVAL);
63
+ }
64
+ /** Send a message. Returns false if not connected (message dropped). */
65
+ send(msg) {
66
+ if (this.socket && this.connected) {
67
+ this.socket.write(encodeFrame(msg));
68
+ return true;
69
+ }
70
+ console.log(`[spoke:${this.agentId}] Not connected, dropping message`);
71
+ return false;
72
+ }
73
+ isConnected() {
74
+ return this.connected;
75
+ }
76
+ disconnect() {
77
+ if (this.reconnectTimer) {
78
+ clearTimeout(this.reconnectTimer);
79
+ this.reconnectTimer = null;
80
+ }
81
+ this.socket?.end();
82
+ }
83
+ }