@raolin2025/claude-code-node 2.6.1 → 2.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +2 -2
- package/src/channel/notify-daemon.js +105 -18
- package/src/channel/qqbot-listener.js +3 -7
- package/src/channel/tg-listener.js +1 -1
- package/src/channel/tg-proxy.js +105 -32
- package/src/core/cli.js +113 -2
- package/src/core/index.js +2 -0
- package/src/core/query-engine.js +35 -7
- package/src/stdio/server.js +426 -0
- package/src/tools/index.js +3 -3
- package/src/tools/telegram-tools.js +319 -0
- package/src/types/index.js +6 -1
- package/src/utils/llm-server.js +1 -1
- package/src/tools/qqbot-channel-api.js +0 -147
- package/src/tools/qqbot-media.js +0 -177
- package/src/tools/qqbot-remind.js +0 -42
- package/src/tools/qqbot-tools-wrapper.js +0 -185
package/README.md
CHANGED
|
@@ -67,8 +67,25 @@ cc-node --resume session-1747000000000-abc123
|
|
|
67
67
|
| `--resume` | `-r` | 恢复会话 ID | |
|
|
68
68
|
| `--verbose` | `-v` | 详细输出 | `false` |
|
|
69
69
|
| `--no-stream` | | 禁用流式响应 | `false` |
|
|
70
|
+
| `--stdio` | | **JSON-RPC 服务器模式**(供桥接层/外部客户端接入,见下) | |
|
|
70
71
|
| `--help` | `-h` | 显示帮助 | |
|
|
71
72
|
|
|
73
|
+
### 🖥️ stdio 服务器模式(--stdio)
|
|
74
|
+
|
|
75
|
+
以独立子进程形态提供 **JSON-RPC 2.0 over NDJSON** 服务,供外部客户端(VS Code 扩展 / Web / Telegram 等)接入。
|
|
76
|
+
协议完整定义见 cc-node-bridge 项目 `docs/stdio-protocol.md`。
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# 启动(stdin/stdout 为协议通道,stderr 为日志)
|
|
80
|
+
cc-node --stdio --api-base http://127.0.0.1:11434/v1 --model qwen2.5:0.5b
|
|
81
|
+
|
|
82
|
+
# 最小请求:initialize
|
|
83
|
+
printf '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n' | cc-node --stdio
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
能力:流式输出(event/delta)、多模态(images)、remote 工具执行(toolCall 回传)、
|
|
87
|
+
会话管理、abort 中断、config 运行时配置。每个接入客户端建议独立子进程(由桥接层管理)。
|
|
88
|
+
|
|
72
89
|
### 权限模式
|
|
73
90
|
|
|
74
91
|
| 模式 | 说明 |
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.2",
|
|
4
4
|
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "src/index.js",
|
|
6
|
+
"main": "src/core/index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"cc-node": "src/index.js",
|
|
9
9
|
"cc-notify": "src/channel/notify-daemon.js"
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
mkdirSync, openSync, closeSync,
|
|
27
27
|
} from 'node:fs'
|
|
28
28
|
import { join } from 'node:path'
|
|
29
|
+
import { pathToFileURL } from 'node:url'
|
|
29
30
|
import { homedir } from 'node:os'
|
|
30
31
|
import { spawn, execSync } from 'node:child_process'
|
|
31
32
|
import { createConnection } from 'node:net'
|
|
@@ -181,12 +182,36 @@ function sendToExistingNode(socketPath, text) {
|
|
|
181
182
|
function spawnNewNode(ccNodePath, text) {
|
|
182
183
|
return new Promise((resolve) => {
|
|
183
184
|
const timeout = 180000 // 3分钟超时
|
|
185
|
+
// 用 let 在 setTimeout 之前声明 child,避免 TDZ / 时序问题导致 "child is not defined"
|
|
186
|
+
let child = null
|
|
187
|
+
let finished = false
|
|
188
|
+
const finish = (val) => {
|
|
189
|
+
if (finished) return
|
|
190
|
+
finished = true
|
|
191
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
192
|
+
resolve(val)
|
|
193
|
+
}
|
|
184
194
|
let timer = setTimeout(() => {
|
|
185
|
-
child.kill()
|
|
186
|
-
|
|
195
|
+
try { if (child && child.kill) child.kill() } catch {}
|
|
196
|
+
finish({ type: 'reply', text: '⏰ 执行超时(3 分钟)' })
|
|
187
197
|
}, timeout)
|
|
188
198
|
try {
|
|
189
|
-
|
|
199
|
+
// 一次性模式必须显式传 --api-key / --api-base / --model,
|
|
200
|
+
// 因为 cc-node 的 cli.js 只从命令行参数或 config 读取 apiKey,不读 LLM_API_KEY 环境变量。
|
|
201
|
+
// 注意:cc-node 的 parseArgs 在遇到第一个非 '-' 参数(prompt)后会跳过后续选项,
|
|
202
|
+
// 所以选项必须放在 prompt 之前。
|
|
203
|
+
const args = []
|
|
204
|
+
const apiKey = process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || process.env.QWEN_API_KEY || process.env.GLM_API_KEY || process.env.KIMI_API_KEY || ''
|
|
205
|
+
const apiBase = process.env.LLM_API_BASE || ''
|
|
206
|
+
// 仅在显式指定时传 --model,否则让 cli.js 使用默认模型,避免硬编码不存在的模型名导致一次性节点失败
|
|
207
|
+
const model = process.env.CC_NODE_ONESHOT_MODEL || ''
|
|
208
|
+
if (apiKey) args.push('--api-key', apiKey)
|
|
209
|
+
if (apiBase) args.push('--api-base', apiBase)
|
|
210
|
+
if (model) args.push('--model', model)
|
|
211
|
+
args.push('--no-stream')
|
|
212
|
+
args.push(text)
|
|
213
|
+
|
|
214
|
+
child = spawn(ccNodePath, args, {
|
|
190
215
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
191
216
|
env: { ...process.env, CC_NODE_ONESHOT: '1' },
|
|
192
217
|
timeout,
|
|
@@ -196,25 +221,19 @@ function spawnNewNode(ccNodePath, text) {
|
|
|
196
221
|
child.stdout.on('data', (d) => (stdout += d.toString()))
|
|
197
222
|
child.stderr.on('data', (d) => (stderr += d.toString()))
|
|
198
223
|
child.on('close', (code) => {
|
|
199
|
-
clearTimeout(timer)
|
|
200
|
-
timer = null
|
|
201
224
|
if (stdout.trim()) {
|
|
202
|
-
|
|
225
|
+
finish({ type: 'reply', text: stdout.trim().slice(0, 4000) })
|
|
203
226
|
} else if (stderr.trim()) {
|
|
204
|
-
|
|
227
|
+
finish({ type: 'reply', text: `❌ Error: ${stderr.trim().slice(0, 1000)}` })
|
|
205
228
|
} else {
|
|
206
|
-
|
|
229
|
+
finish({ type: 'reply', text: `(completed with code ${code})` })
|
|
207
230
|
}
|
|
208
231
|
})
|
|
209
232
|
child.on('error', (e) => {
|
|
210
|
-
|
|
211
|
-
timer = null
|
|
212
|
-
resolve({ type: 'reply', text: `❌ Failed: ${e.message}` })
|
|
233
|
+
finish({ type: 'reply', text: `❌ Failed: ${e.message}` })
|
|
213
234
|
})
|
|
214
235
|
} catch (e) {
|
|
215
|
-
|
|
216
|
-
timer = null
|
|
217
|
-
resolve({ type: 'reply', text: `❌ Failed: ${e.message}` })
|
|
236
|
+
finish({ type: 'reply', text: `❌ Failed: ${e.message}` })
|
|
218
237
|
}
|
|
219
238
|
})
|
|
220
239
|
}
|
|
@@ -225,6 +244,11 @@ async function routeMessage(text, config) {
|
|
|
225
244
|
log(`[route] cc-node running → forwarding via socket`)
|
|
226
245
|
try {
|
|
227
246
|
const reply = await sendToExistingNode(nodeInfo.socketPath, text)
|
|
247
|
+
// 如果 socket 返回的是错误类型(如"引擎正在运行中"),回退到一次性模式
|
|
248
|
+
if (reply && reply.type === 'error') {
|
|
249
|
+
log(`[route] socket returned error (${reply.text}) → spawning new one-shot node`)
|
|
250
|
+
return (await spawnNewNode(config.ccNodePath, text)).text
|
|
251
|
+
}
|
|
228
252
|
return reply.text || JSON.stringify(reply)
|
|
229
253
|
} catch (e) {
|
|
230
254
|
log(`[route] socket forward failed: ${e.message} → spawning new`)
|
|
@@ -821,7 +845,70 @@ async function main() {
|
|
|
821
845
|
setInterval(() => {}, 60000)
|
|
822
846
|
}
|
|
823
847
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
848
|
+
// 仅当 notify-daemon.js 作为主入口直接运行时才启动守护进程。
|
|
849
|
+
// 被 cli.js 通过 import() 引入(--with-notify 模式)时,只导出 startBuiltinListeners,
|
|
850
|
+
// 避免 import 副作用重复启动 HTTP 服务 / QQ 监听器 / PID 文件。
|
|
851
|
+
const isMainEntry = (() => {
|
|
852
|
+
try {
|
|
853
|
+
return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
854
|
+
} catch {
|
|
855
|
+
return false
|
|
856
|
+
}
|
|
857
|
+
})()
|
|
858
|
+
|
|
859
|
+
if (isMainEntry) {
|
|
860
|
+
main().catch((err) => {
|
|
861
|
+
console.error('Fatal error:', err.message)
|
|
862
|
+
process.exit(1)
|
|
863
|
+
})
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ============================================================
|
|
867
|
+
// --with-notify: 内置监听器(由 cli.js 调用,无需 fork 新进程)
|
|
868
|
+
// ============================================================
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* 在 cc-node 主进程中启动频道监听器(Telegram),
|
|
872
|
+
* 替代外部 bash 脚本启动 cc-notify 的方式。
|
|
873
|
+
* 跨平台兼容 — 不依赖 bash / nohup / PID 文件。
|
|
874
|
+
*
|
|
875
|
+
* @param {object} opts
|
|
876
|
+
* @param {object} opts.channels - 频道配置对象
|
|
877
|
+
* @param {string|null} opts.defaultChannel - 默认频道
|
|
878
|
+
* @param {function} opts.onMessage - 消息回调 (msg) => void
|
|
879
|
+
* @returns {Promise<{tgListener: object|null, stop: function}>}
|
|
880
|
+
*/
|
|
881
|
+
export async function startBuiltinListeners(opts = {}) {
|
|
882
|
+
const { channels = {}, defaultChannel = null, onMessage } = opts
|
|
883
|
+
|
|
884
|
+
const config = {
|
|
885
|
+
channels,
|
|
886
|
+
defaultChannel: defaultChannel || process.env.CC_NODE_CHANNEL_DEFAULT || null,
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
let tgListener = null
|
|
890
|
+
|
|
891
|
+
// 启动 Telegram 监听器
|
|
892
|
+
if (config.channels.telegram?.token) {
|
|
893
|
+
try {
|
|
894
|
+
const { TelegramListener } = await import('./tg-listener.js')
|
|
895
|
+
tgListener = new TelegramListener(config)
|
|
896
|
+
if (tgListener.bot) {
|
|
897
|
+
tgListener.start(async (msg) => {
|
|
898
|
+
msg.channel = 'telegram'
|
|
899
|
+
await onMessage(msg)
|
|
900
|
+
}).catch(e => console.error(`[builtin-notify] TG listener error: ${e.message}`))
|
|
901
|
+
console.log(`[builtin-notify] ✅ Telegram listener started`)
|
|
902
|
+
}
|
|
903
|
+
} catch (e) {
|
|
904
|
+
console.error(`[builtin-notify] TG init failed: ${e.message}`)
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return {
|
|
909
|
+
tgListener,
|
|
910
|
+
stop() {
|
|
911
|
+
tgListener?.stop()
|
|
912
|
+
},
|
|
913
|
+
}
|
|
914
|
+
}
|
|
@@ -269,13 +269,9 @@ export class QQBot {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
async _getWSURL() {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
})
|
|
276
|
-
if (!res.ok) throw new Error(`WS URL ${res.status}`)
|
|
277
|
-
const data = await res.json()
|
|
278
|
-
return data.url
|
|
272
|
+
// QQ Bot API v2 的 /websocket 端点需要 WebSocket 升级请求,不能用 HTTP fetch
|
|
273
|
+
// 直接使用固定的 WebSocket URL
|
|
274
|
+
return 'wss://api.sgroup.qq.com/websocket'
|
|
279
275
|
}
|
|
280
276
|
|
|
281
277
|
_handleWS(msg) {
|
|
@@ -91,7 +91,7 @@ class RateLimiter {
|
|
|
91
91
|
// Telegram Bot 客户端
|
|
92
92
|
// ============================================================
|
|
93
93
|
|
|
94
|
-
class TelegramBotClient {
|
|
94
|
+
export class TelegramBotClient {
|
|
95
95
|
constructor(token, opts = {}) {
|
|
96
96
|
this.token = token
|
|
97
97
|
this.apiBase = opts.apiBase || API_BASE(token)
|
package/src/channel/tg-proxy.js
CHANGED
|
@@ -35,11 +35,14 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
|
|
|
35
35
|
|
|
36
36
|
socket.once('connect', async () => {
|
|
37
37
|
try {
|
|
38
|
+
// 创建持久的字节缓冲读取器,避免多次 readBytes 丢失多余字节
|
|
39
|
+
const reader = createByteReader(socket)
|
|
40
|
+
|
|
38
41
|
// Step 1: 握手 — 协商认证方式
|
|
39
42
|
const authMethods = opts.username ? [0x00, 0x02] : [0x00] // 无认证 + 用户名密码
|
|
40
43
|
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]))
|
|
41
44
|
|
|
42
|
-
const handshake = await
|
|
45
|
+
const handshake = await reader.read(2)
|
|
43
46
|
if (handshake[0] !== 0x05) {
|
|
44
47
|
throw new Error('SOCKS5: 版本不匹配')
|
|
45
48
|
}
|
|
@@ -51,7 +54,7 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
|
|
|
51
54
|
const p = Buffer.from(opts.password, 'utf8')
|
|
52
55
|
const authReq = Buffer.from([0x01, u.length, ...u, p.length, ...p])
|
|
53
56
|
socket.write(authReq)
|
|
54
|
-
const authResp = await
|
|
57
|
+
const authResp = await reader.read(2)
|
|
55
58
|
if (authResp[1] !== 0x00) throw new Error('SOCKS5: 认证失败')
|
|
56
59
|
} else if (handshake[1] !== 0x00) {
|
|
57
60
|
throw new Error('SOCKS5: 代理不支持不需要的认证方式')
|
|
@@ -72,7 +75,7 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
|
|
|
72
75
|
const connectReq = Buffer.from([0x05, 0x01, 0x00, hostType, ...addr, ...portBuf])
|
|
73
76
|
socket.write(connectReq)
|
|
74
77
|
|
|
75
|
-
const connectResp = await
|
|
78
|
+
const connectResp = await reader.read(4)
|
|
76
79
|
if (connectResp[0] !== 0x05 || connectResp[1] !== 0x00) {
|
|
77
80
|
const errors = { 0x01: '通用错误', 0x02: '不允许', 0x03: '网络不可达', 0x04: '主机不可达', 0x05: '连接被拒', 0x06: 'TTL超时', 0x07: '命令不支持', 0x08: '地址类型不支持' }
|
|
78
81
|
throw new Error(`SOCKS5: 连接失败 — ${errors[connectResp[1]] || `错误码 ${connectResp[1]}`}`)
|
|
@@ -80,11 +83,14 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
|
|
|
80
83
|
|
|
81
84
|
// 读取剩余响应包头(根据地址类型)
|
|
82
85
|
const addrType = connectResp[3]
|
|
83
|
-
if (addrType === 0x01) await
|
|
86
|
+
if (addrType === 0x01) await reader.read(6) // IPv4 + port
|
|
84
87
|
else if (addrType === 0x03) {
|
|
85
|
-
const len = (await
|
|
86
|
-
await
|
|
87
|
-
} else if (addrType === 0x04) await
|
|
88
|
+
const len = (await reader.read(1))[0]
|
|
89
|
+
await reader.read(len + 2) // hostname + port
|
|
90
|
+
} else if (addrType === 0x04) await reader.read(18) // IPv6 + port
|
|
91
|
+
|
|
92
|
+
// 清理 reader 的监听器,确保隧道建立后数据完整交给调用方
|
|
93
|
+
reader.detach()
|
|
88
94
|
|
|
89
95
|
clearTimeout(timeout)
|
|
90
96
|
resolve(socket)
|
|
@@ -102,6 +108,85 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
|
|
|
102
108
|
})
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 创建持久的字节缓冲读取器。
|
|
113
|
+
*
|
|
114
|
+
* 原实现每次 readBytes 都重新注册 'data' 监听器,当 SOCKS5 代理
|
|
115
|
+
* 一次性返回多段响应时,首个 readBytes 会消费掉多余字节并丢弃,
|
|
116
|
+
* 导致后续 readBytes 永远等待,最终触发超时。此读取器用统一的
|
|
117
|
+
* 内部缓冲队列累积所有到达的字节,保证多次读取之间字节不丢失。
|
|
118
|
+
*
|
|
119
|
+
* @param {import('node:net').Socket} socket
|
|
120
|
+
*/
|
|
121
|
+
function createByteReader(socket) {
|
|
122
|
+
let buffer = Buffer.alloc(0)
|
|
123
|
+
let closed = false
|
|
124
|
+
const waiters = []
|
|
125
|
+
|
|
126
|
+
const onData = (chunk) => {
|
|
127
|
+
buffer = Buffer.concat([buffer, chunk])
|
|
128
|
+
flush()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const onError = () => {
|
|
132
|
+
closed = true
|
|
133
|
+
flush()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const onEnd = () => {
|
|
137
|
+
closed = true
|
|
138
|
+
flush()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
socket.on('data', onData)
|
|
142
|
+
socket.once('error', onError)
|
|
143
|
+
socket.once('end', onEnd)
|
|
144
|
+
|
|
145
|
+
function flush() {
|
|
146
|
+
while (waiters.length > 0) {
|
|
147
|
+
const waiter = waiters[0]
|
|
148
|
+
if (buffer.length >= waiter.n) {
|
|
149
|
+
waiters.shift()
|
|
150
|
+
const out = buffer.slice(0, waiter.n)
|
|
151
|
+
buffer = buffer.slice(waiter.n)
|
|
152
|
+
waiter.resolve(out)
|
|
153
|
+
} else {
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// 所有等待者都已满足,但连接已关闭且字节不足 -> 报错
|
|
158
|
+
if (closed && waiters.length > 0 && buffer.length === 0) {
|
|
159
|
+
const waiter = waiters.shift()
|
|
160
|
+
waiter.reject(new Error('SOCKS5: 连接意外关闭'))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
read(n) {
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
if (n === 0) return resolve(Buffer.alloc(0))
|
|
168
|
+
// 先检查已有缓冲
|
|
169
|
+
if (buffer.length >= n) {
|
|
170
|
+
const out = buffer.slice(0, n)
|
|
171
|
+
buffer = buffer.slice(n)
|
|
172
|
+
return resolve(out)
|
|
173
|
+
}
|
|
174
|
+
// 连接已关闭且缓冲不足
|
|
175
|
+
if (closed) {
|
|
176
|
+
return reject(new Error('SOCKS5: 连接意外关闭'))
|
|
177
|
+
}
|
|
178
|
+
waiters.push({ n, resolve, reject })
|
|
179
|
+
flush()
|
|
180
|
+
})
|
|
181
|
+
},
|
|
182
|
+
detach() {
|
|
183
|
+
socket.removeListener('data', onData)
|
|
184
|
+
socket.removeListener('error', onError)
|
|
185
|
+
socket.removeListener('end', onEnd)
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
105
190
|
/**
|
|
106
191
|
* 创建通过 SOCKS5 代理的 TLS 连接
|
|
107
192
|
*
|
|
@@ -171,8 +256,18 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
|
|
|
171
256
|
// 构建 HTTP 请求
|
|
172
257
|
const path = parsedUrl.pathname + parsedUrl.search
|
|
173
258
|
const headers = Object.entries(options.headers || {}).map(([k, v]) => `${k}: ${v}`).join('\r\n')
|
|
259
|
+
// body 必须是字符串/Buffer;FormData/Blob 等 multipart 正文当前不支持
|
|
260
|
+
// (仅用于 Telegram Bot API 的 JSON 请求),遇到非字符串 body 给出清晰错误而非崩溃。
|
|
174
261
|
const body = options.body || ''
|
|
175
|
-
|
|
262
|
+
if (typeof body !== 'string' && !Buffer.isBuffer(body)) {
|
|
263
|
+
socket.destroy()
|
|
264
|
+
return Promise.reject(new Error('fetchViaSocks5: 仅支持 string/Buffer 请求体(不支持 FormData 等 multipart)'))
|
|
265
|
+
}
|
|
266
|
+
// 注意:Content-Length 必须用 UTF-8 字节数,不能用字符串字符数,
|
|
267
|
+
// 否则包含中文/emoji 时会导致服务器读不完整请求体。
|
|
268
|
+
const bodyLen = Buffer.byteLength(body, 'utf8')
|
|
269
|
+
const head = `${options.method || 'GET'} ${path} HTTP/1.1\r\nHost: ${host}\r\n${headers ? headers + '\r\n' : ''}Content-Length: ${bodyLen}\r\nConnection: close\r\n\r\n`
|
|
270
|
+
const reqBuf = Buffer.concat([Buffer.from(head, 'utf8'), Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8')])
|
|
176
271
|
|
|
177
272
|
return new Promise((resolve, reject) => {
|
|
178
273
|
let responseData = ''
|
|
@@ -181,7 +276,7 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
|
|
|
181
276
|
reject(new Error('HTTP request timeout'))
|
|
182
277
|
}, 30000)
|
|
183
278
|
|
|
184
|
-
socket.write(
|
|
279
|
+
socket.write(reqBuf)
|
|
185
280
|
socket.on('data', (chunk) => {
|
|
186
281
|
responseData += chunk.toString()
|
|
187
282
|
})
|
|
@@ -211,26 +306,4 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
|
|
|
211
306
|
reject(err)
|
|
212
307
|
})
|
|
213
308
|
})
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** 从 socket 读取指定字节数 */
|
|
217
|
-
function readBytes(socket, n) {
|
|
218
|
-
return new Promise((resolve, reject) => {
|
|
219
|
-
if (n === 0) return resolve(Buffer.alloc(0))
|
|
220
|
-
let buf = Buffer.alloc(0)
|
|
221
|
-
const onData = (chunk) => {
|
|
222
|
-
buf = Buffer.concat([buf, chunk])
|
|
223
|
-
if (buf.length >= n) {
|
|
224
|
-
socket.removeListener('data', onData)
|
|
225
|
-
resolve(buf.slice(0, n))
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
socket.on('data', onData)
|
|
229
|
-
socket.once('error', reject)
|
|
230
|
-
// 处理已经缓冲的数据
|
|
231
|
-
if (buf.length >= n) {
|
|
232
|
-
socket.removeListener('data', onData)
|
|
233
|
-
resolve(buf.slice(0, n))
|
|
234
|
-
}
|
|
235
|
-
})
|
|
236
|
-
}
|
|
309
|
+
}
|
package/src/core/cli.js
CHANGED
|
@@ -18,6 +18,8 @@ import { CostTracker } from './cost-tracker.js'
|
|
|
18
18
|
import { autoCompact } from './compact.js'
|
|
19
19
|
import { isLocalLlmServer } from '../utils/index.js'
|
|
20
20
|
import { SOCK_DIR, SOCK_PATH, CC_NODE_PID } from './paths.js'
|
|
21
|
+
import { TelegramListener } from '../channel/tg-listener.js'
|
|
22
|
+
import { fetchViaSocks5 } from '../channel/tg-proxy.js'
|
|
21
23
|
|
|
22
24
|
// ============================================================
|
|
23
25
|
// Unix Socket — 让 cc-notify 能发现 cc-node
|
|
@@ -315,7 +317,7 @@ const DETAILED_HELP = {
|
|
|
315
317
|
|
|
316
318
|
compact: "/compact\n Manually trigger context compression.\n Compresses the conversation history to fit within the token budget.\n Keeps recent turns intact, compresses older ones.\n\n Typically triggered automatically at 80% budget usage.",
|
|
317
319
|
|
|
318
|
-
cd: "/cd <path>\n Change the working directory of cc-node.\n Affects all subsequent tool executions (Bash, Read, Write, etc.).\n\n Without path: show the current working directory.\n\n Example: /cd /home/
|
|
320
|
+
cd: "/cd <path>\n Change the working directory of cc-node.\n Affects all subsequent tool executions (Bash, Read, Write, etc.).\n\n Without path: show the current working directory.\n\n Example: /cd /home/yourname/projects\n Example: /cd ..",
|
|
319
321
|
|
|
320
322
|
allow: "/allow [tool|all|reset]\n Manage tool permissions for this session.\n\n Options:\n <tool> — allow a specific tool (e.g. Bash, Write, Read)\n all — automatically allow ALL remaining tools for this session\n reset — reset to ask mode (ask for each tool)\n (no arg) — same as /allow all\n\n When asked to confirm a tool, you can also type:\n y — allow this once\n a — allow all for the rest of the session\n\n Example: /allow Bash\n Example: /allow all\n Example: /allow reset",
|
|
321
323
|
|
|
@@ -352,6 +354,8 @@ function parseArgs(argv) {
|
|
|
352
354
|
case '--resume': case '-r': args.resume = argv[++i]; break
|
|
353
355
|
case '--verbose': case '-v': args.verbose = true; break
|
|
354
356
|
case '--no-stream': args.noStream = true; break
|
|
357
|
+
case '--stdio': args.stdio = true; break
|
|
358
|
+
case '--with-notify': args.withNotify = true; break
|
|
355
359
|
case '--version':
|
|
356
360
|
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
357
361
|
console.log(pkg.version)
|
|
@@ -370,6 +374,8 @@ Options:
|
|
|
370
374
|
--version Show version
|
|
371
375
|
-v, --verbose Verbose mode
|
|
372
376
|
--no-stream Disable streaming
|
|
377
|
+
--with-notify Start built-in channel listener (Telegram)
|
|
378
|
+
(replaces cc-notify daemon — no external script needed)
|
|
373
379
|
-h, --help Show this help
|
|
374
380
|
|
|
375
381
|
Environment variables:
|
|
@@ -405,6 +411,14 @@ Unix Socket (for cc-notify):
|
|
|
405
411
|
export async function main() {
|
|
406
412
|
const cliArgs = parseArgs(process.argv)
|
|
407
413
|
|
|
414
|
+
// P1: stdio 服务器模式(JSON-RPC 2.0 over NDJSON,供桥接层/外部客户端接入)
|
|
415
|
+
if (cliArgs.stdio) {
|
|
416
|
+
const { StdioServer } = await import('../stdio/server.js')
|
|
417
|
+
const server = new StdioServer({ cliArgs })
|
|
418
|
+
server.start()
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
408
422
|
const config = new Config()
|
|
409
423
|
await config.load(process.cwd())
|
|
410
424
|
|
|
@@ -566,8 +580,14 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
566
580
|
rl.prompt()
|
|
567
581
|
}
|
|
568
582
|
|
|
583
|
+
// Telegram 双向通道(可选):tgListener 监听 Telegram 消息,tgChatId 记录回复目标
|
|
584
|
+
let tgListener = null
|
|
585
|
+
let tgChatId = null
|
|
586
|
+
let tgReplyTarget = null
|
|
587
|
+
|
|
569
588
|
// 处理输入行
|
|
570
|
-
|
|
589
|
+
// source: 'cli' 来自终端输入, 'telegram' 来自 Telegram
|
|
590
|
+
async function processInputLine(input, source = 'cli', tgChatId = null) {
|
|
571
591
|
const trimmed = input.trim()
|
|
572
592
|
if (!trimmed) { showPrompt(); return }
|
|
573
593
|
|
|
@@ -800,8 +820,15 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
800
820
|
if (engine.costTracker && engine.costTracker.totalApiCalls > 0) {
|
|
801
821
|
console.log(engine.costTracker.formatShort())
|
|
802
822
|
}
|
|
823
|
+
// 将 AI 回复同步发送到 Telegram(镜像 CLI 显示)
|
|
824
|
+
if (tgListener?.bot && result?.response) {
|
|
825
|
+
await sendTelegram(result.response, tgChatId || null)
|
|
826
|
+
}
|
|
803
827
|
} catch (err) {
|
|
804
828
|
console.error(`\nError: ${err.message}\n`)
|
|
829
|
+
if (tgListener?.bot) {
|
|
830
|
+
await sendTelegram(`❌ Error: ${err.message}`, tgChatId || null)
|
|
831
|
+
}
|
|
805
832
|
if (channelManager.list().length > 0) {
|
|
806
833
|
await channelManager.sendTemplate('error', {
|
|
807
834
|
task: input.slice(0, 80), error: err.message.slice(0, 200),
|
|
@@ -811,6 +838,33 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
811
838
|
showPrompt()
|
|
812
839
|
}
|
|
813
840
|
|
|
841
|
+
// 发送文本到 Telegram(支持分片,>4000 字符自动拆分)
|
|
842
|
+
async function sendTelegram(text, chatId = null) {
|
|
843
|
+
try {
|
|
844
|
+
const target = chatId || tgChatId
|
|
845
|
+
if (!target) return
|
|
846
|
+
const MAX_LEN = 4000
|
|
847
|
+
if (text.length <= MAX_LEN) {
|
|
848
|
+
await tgListener.bot.sendMessage(target, text, { parseMode: 'HTML' })
|
|
849
|
+
} else {
|
|
850
|
+
const parts = []
|
|
851
|
+
let cur = ''
|
|
852
|
+
for (const line of text.split('\n')) {
|
|
853
|
+
if (cur.length + line.length > 3800) { parts.push(cur); cur = line }
|
|
854
|
+
else { cur += (cur ? '\n' : '') + line }
|
|
855
|
+
}
|
|
856
|
+
if (cur) parts.push(cur)
|
|
857
|
+
for (let i = 0; i < parts.length; i++) {
|
|
858
|
+
const header = i > 0 ? `📎 (${i + 1}/${parts.length})\n` : ''
|
|
859
|
+
await tgListener.bot.sendMessage(target, header + parts[i], { parseMode: 'HTML' })
|
|
860
|
+
await new Promise(r => setTimeout(r, 300))
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
} catch (e) {
|
|
864
|
+
console.error(`[TG] send failed: ${e.message}`)
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
814
868
|
// REPL 主循环 — 由 readline 原生处理回显、退格、行回绕与 Enter 提交
|
|
815
869
|
rl.on('line', (line) => {
|
|
816
870
|
processInputLine(line)
|
|
@@ -838,6 +892,63 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
838
892
|
}
|
|
839
893
|
engine.config.readline = rl
|
|
840
894
|
|
|
895
|
+
// ============================================================
|
|
896
|
+
// Telegram 双向通道启动(可选)
|
|
897
|
+
// 配置了 CC_NODE_CHANNEL_TELEGRAM_TOKEN 或 config 中 telegram 时启用:
|
|
898
|
+
// - CLI 的 AI 回复会同步镜像发送到 Telegram
|
|
899
|
+
// - Telegram 消息会作为 REPL 输入,与 CLI 共享同一个引擎和对话记忆
|
|
900
|
+
// ============================================================
|
|
901
|
+
const tgToken = process.env.CC_NODE_CHANNEL_TELEGRAM_TOKEN || config.get('channels')?.telegram?.token || ''
|
|
902
|
+
if (tgToken) {
|
|
903
|
+
try {
|
|
904
|
+
tgListener = new TelegramListener({
|
|
905
|
+
channels: {
|
|
906
|
+
telegram: {
|
|
907
|
+
token: tgToken,
|
|
908
|
+
proxy: process.env.CC_NODE_CHANNEL_TELEGRAM_PROXY || config.get('channels')?.telegram?.proxy || '',
|
|
909
|
+
apiBase: process.env.CC_NODE_CHANNEL_TELEGRAM_API_BASE || config.get('channels')?.telegram?.apiBase || '',
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
})
|
|
913
|
+
tgListener.start(async (msg) => {
|
|
914
|
+
// Telegram 消息 → 复用 REPL 引擎处理(共享同一份对话记忆)
|
|
915
|
+
tgChatId = msg.chatId || tgChatId
|
|
916
|
+
tgReplyTarget = msg.replyTo || null
|
|
917
|
+
const text = msg.text || msg.callbackData || ''
|
|
918
|
+
if (!text) return
|
|
919
|
+
// 命令由 listener 内部处理,普通消息转给引擎
|
|
920
|
+
if (!text.startsWith('/')) {
|
|
921
|
+
await processInputLine(text, 'telegram', msg.chatId)
|
|
922
|
+
}
|
|
923
|
+
}).catch(e => console.error(`[TG] listener error: ${e.message}`))
|
|
924
|
+
console.log(`✅ Telegram channel ready (bot ${tgToken.slice(0, 12)}...)`)
|
|
925
|
+
} catch (e) {
|
|
926
|
+
console.error(`[TG] init failed: ${e.message}`)
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ============================================================
|
|
931
|
+
// --with-notify: 内置频道监听器(替代 cc-notify 守护进程)
|
|
932
|
+
// 当 cc-node 启动时同时启动 Telegram 监听器,
|
|
933
|
+
// 无需外部 bash 脚本,跨平台(Windows/Linux/macOS)都能用。
|
|
934
|
+
// ============================================================
|
|
935
|
+
if (cliArgs.withNotify) {
|
|
936
|
+
const { startBuiltinListeners } = await import('../channel/notify-daemon.js')
|
|
937
|
+
// 启动内部的 notify 监听器(不 fork 新进程,直接在当前进程运行)
|
|
938
|
+
const builtinNotify = await startBuiltinListeners({
|
|
939
|
+
channels: config.get('channels') || {},
|
|
940
|
+
defaultChannel: config.get('defaultChannel') || process.env.CC_NODE_CHANNEL_DEFAULT || null,
|
|
941
|
+
onMessage: async (msg) => {
|
|
942
|
+
// 来自外部的消息 → 转发到 REPL 引擎
|
|
943
|
+
const text = msg.text || ''
|
|
944
|
+
if (text && !text.startsWith('/')) {
|
|
945
|
+
await processInputLine(text, msg.channel || 'external', msg.chatId)
|
|
946
|
+
}
|
|
947
|
+
},
|
|
948
|
+
})
|
|
949
|
+
console.log('📡 Built-in channel listeners started (--with-notify)')
|
|
950
|
+
}
|
|
951
|
+
|
|
841
952
|
console.log(buildBanner({ model, permissionMode, session, maxTokens: tokenBudget.maxTokens }))
|
|
842
953
|
console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
|
|
843
954
|
console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
|