@raolin2025/claude-code-node 2.6.1 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.1",
3
+ "version": "2.6.3",
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
- resolve({ type: 'reply', text: '⏰ 执行超时(3 分钟)' })
195
+ try { if (child && child.kill) child.kill() } catch {}
196
+ finish({ type: 'reply', text: '⏰ 执行超时(3 分钟)' })
187
197
  }, timeout)
188
198
  try {
189
- const child = spawn(ccNodePath, [text], {
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
- resolve({ type: 'reply', text: stdout.trim().slice(0, 4000) })
225
+ finish({ type: 'reply', text: stdout.trim().slice(0, 4000) })
203
226
  } else if (stderr.trim()) {
204
- resolve({ type: 'reply', text: `❌ Error: ${stderr.trim().slice(0, 1000)}` })
227
+ finish({ type: 'reply', text: `❌ Error: ${stderr.trim().slice(0, 1000)}` })
205
228
  } else {
206
- resolve({ type: 'reply', text: `(completed with code ${code})` })
229
+ finish({ type: 'reply', text: `(completed with code ${code})` })
207
230
  }
208
231
  })
209
232
  child.on('error', (e) => {
210
- clearTimeout(timer)
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
- clearTimeout(timer)
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
- main().catch((err) => {
825
- console.error('Fatal error:', err.message)
826
- process.exit(1)
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
- const token = await this._t()
273
- const res = await fetch(`${API_BASE}/websocket`, {
274
- headers: { Authorization: `QQBot ${token}` },
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)
@@ -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 readBytes(socket, 2)
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 readBytes(socket, 2)
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 readBytes(socket, 4)
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 readBytes(socket, 6) // IPv4 + port
86
+ if (addrType === 0x01) await reader.read(6) // IPv4 + port
84
87
  else if (addrType === 0x03) {
85
- const len = (await readBytes(socket, 1))[0]
86
- await readBytes(socket, len + 2) // hostname + port
87
- } else if (addrType === 0x04) await readBytes(socket, 18) // IPv6 + port
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
- const req = `${options.method || 'GET'} ${path} HTTP/1.1\r\nHost: ${host}\r\n${headers ? headers + '\r\n' : ''}Content-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`
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(req)
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
@@ -96,11 +98,19 @@ function startSocketServer(engine, session, sessionManager, channelManager, verb
96
98
 
97
99
  server.listen(SOCK_PATH, () => {
98
100
  // v1.1 修复: socket 文件权限 0600(仅所有者可读写),阻止其他用户连接
99
- try { chmodSync(SOCK_PATH, 0o600) } catch {}
101
+ // Windows named pipe 不支持 chmod,跳过
102
+ try { if (!process.platform.startsWith('win')) chmodSync(SOCK_PATH, 0o600) } catch {}
100
103
  // 写 PID 文件(权限 0644)
101
104
  writeFileSync(CC_NODE_PID, String(process.pid), { mode: 0o644 })
102
105
  })
103
106
 
107
+ // 关键修复: 监听 socket 失败时绝不能崩溃(如 Windows 权限 / 端口占用)。
108
+ // 否则触发 Unhandled 'error' event 导致整个 cc-node 进程退出。
109
+ server.on('error', (err) => {
110
+ console.error(`⚠️ Socket 监听失败(${err.code || err.message})— cc-notify 远程转发将不可用,但 REPL 仍可正常使用。`)
111
+ console.error(` Path: ${SOCK_PATH}`)
112
+ })
113
+
104
114
  // 退出时清理
105
115
  const cleanup = () => {
106
116
  try { unlinkSync(SOCK_PATH) } catch {}
@@ -315,7 +325,7 @@ const DETAILED_HELP = {
315
325
 
316
326
  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
327
 
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/raolin/projects\n Example: /cd ..",
328
+ 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
329
 
320
330
  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
331
 
@@ -352,6 +362,8 @@ function parseArgs(argv) {
352
362
  case '--resume': case '-r': args.resume = argv[++i]; break
353
363
  case '--verbose': case '-v': args.verbose = true; break
354
364
  case '--no-stream': args.noStream = true; break
365
+ case '--stdio': args.stdio = true; break
366
+ case '--with-notify': args.withNotify = true; break
355
367
  case '--version':
356
368
  const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
357
369
  console.log(pkg.version)
@@ -370,6 +382,8 @@ Options:
370
382
  --version Show version
371
383
  -v, --verbose Verbose mode
372
384
  --no-stream Disable streaming
385
+ --with-notify Start built-in channel listener (Telegram)
386
+ (replaces cc-notify daemon — no external script needed)
373
387
  -h, --help Show this help
374
388
 
375
389
  Environment variables:
@@ -405,6 +419,14 @@ Unix Socket (for cc-notify):
405
419
  export async function main() {
406
420
  const cliArgs = parseArgs(process.argv)
407
421
 
422
+ // P1: stdio 服务器模式(JSON-RPC 2.0 over NDJSON,供桥接层/外部客户端接入)
423
+ if (cliArgs.stdio) {
424
+ const { StdioServer } = await import('../stdio/server.js')
425
+ const server = new StdioServer({ cliArgs })
426
+ server.start()
427
+ return
428
+ }
429
+
408
430
  const config = new Config()
409
431
  await config.load(process.cwd())
410
432
 
@@ -566,8 +588,14 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
566
588
  rl.prompt()
567
589
  }
568
590
 
591
+ // Telegram 双向通道(可选):tgListener 监听 Telegram 消息,tgChatId 记录回复目标
592
+ let tgListener = null
593
+ let tgChatId = null
594
+ let tgReplyTarget = null
595
+
569
596
  // 处理输入行
570
- async function processInputLine(input) {
597
+ // source: 'cli' 来自终端输入, 'telegram' 来自 Telegram
598
+ async function processInputLine(input, source = 'cli', tgChatId = null) {
571
599
  const trimmed = input.trim()
572
600
  if (!trimmed) { showPrompt(); return }
573
601
 
@@ -800,8 +828,15 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
800
828
  if (engine.costTracker && engine.costTracker.totalApiCalls > 0) {
801
829
  console.log(engine.costTracker.formatShort())
802
830
  }
831
+ // 将 AI 回复同步发送到 Telegram(镜像 CLI 显示)
832
+ if (tgListener?.bot && result?.response) {
833
+ await sendTelegram(result.response, tgChatId || null)
834
+ }
803
835
  } catch (err) {
804
836
  console.error(`\nError: ${err.message}\n`)
837
+ if (tgListener?.bot) {
838
+ await sendTelegram(`❌ Error: ${err.message}`, tgChatId || null)
839
+ }
805
840
  if (channelManager.list().length > 0) {
806
841
  await channelManager.sendTemplate('error', {
807
842
  task: input.slice(0, 80), error: err.message.slice(0, 200),
@@ -811,6 +846,33 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
811
846
  showPrompt()
812
847
  }
813
848
 
849
+ // 发送文本到 Telegram(支持分片,>4000 字符自动拆分)
850
+ async function sendTelegram(text, chatId = null) {
851
+ try {
852
+ const target = chatId || tgChatId
853
+ if (!target) return
854
+ const MAX_LEN = 4000
855
+ if (text.length <= MAX_LEN) {
856
+ await tgListener.bot.sendMessage(target, text, { parseMode: 'HTML' })
857
+ } else {
858
+ const parts = []
859
+ let cur = ''
860
+ for (const line of text.split('\n')) {
861
+ if (cur.length + line.length > 3800) { parts.push(cur); cur = line }
862
+ else { cur += (cur ? '\n' : '') + line }
863
+ }
864
+ if (cur) parts.push(cur)
865
+ for (let i = 0; i < parts.length; i++) {
866
+ const header = i > 0 ? `📎 (${i + 1}/${parts.length})\n` : ''
867
+ await tgListener.bot.sendMessage(target, header + parts[i], { parseMode: 'HTML' })
868
+ await new Promise(r => setTimeout(r, 300))
869
+ }
870
+ }
871
+ } catch (e) {
872
+ console.error(`[TG] send failed: ${e.message}`)
873
+ }
874
+ }
875
+
814
876
  // REPL 主循环 — 由 readline 原生处理回显、退格、行回绕与 Enter 提交
815
877
  rl.on('line', (line) => {
816
878
  processInputLine(line)
@@ -838,6 +900,63 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
838
900
  }
839
901
  engine.config.readline = rl
840
902
 
903
+ // ============================================================
904
+ // Telegram 双向通道启动(可选)
905
+ // 配置了 CC_NODE_CHANNEL_TELEGRAM_TOKEN 或 config 中 telegram 时启用:
906
+ // - CLI 的 AI 回复会同步镜像发送到 Telegram
907
+ // - Telegram 消息会作为 REPL 输入,与 CLI 共享同一个引擎和对话记忆
908
+ // ============================================================
909
+ const tgToken = process.env.CC_NODE_CHANNEL_TELEGRAM_TOKEN || config.get('channels')?.telegram?.token || ''
910
+ if (tgToken) {
911
+ try {
912
+ tgListener = new TelegramListener({
913
+ channels: {
914
+ telegram: {
915
+ token: tgToken,
916
+ proxy: process.env.CC_NODE_CHANNEL_TELEGRAM_PROXY || config.get('channels')?.telegram?.proxy || '',
917
+ apiBase: process.env.CC_NODE_CHANNEL_TELEGRAM_API_BASE || config.get('channels')?.telegram?.apiBase || '',
918
+ },
919
+ },
920
+ })
921
+ tgListener.start(async (msg) => {
922
+ // Telegram 消息 → 复用 REPL 引擎处理(共享同一份对话记忆)
923
+ tgChatId = msg.chatId || tgChatId
924
+ tgReplyTarget = msg.replyTo || null
925
+ const text = msg.text || msg.callbackData || ''
926
+ if (!text) return
927
+ // 命令由 listener 内部处理,普通消息转给引擎
928
+ if (!text.startsWith('/')) {
929
+ await processInputLine(text, 'telegram', msg.chatId)
930
+ }
931
+ }).catch(e => console.error(`[TG] listener error: ${e.message}`))
932
+ console.log(`✅ Telegram channel ready (bot ${tgToken.slice(0, 12)}...)`)
933
+ } catch (e) {
934
+ console.error(`[TG] init failed: ${e.message}`)
935
+ }
936
+ }
937
+
938
+ // ============================================================
939
+ // --with-notify: 内置频道监听器(替代 cc-notify 守护进程)
940
+ // 当 cc-node 启动时同时启动 Telegram 监听器,
941
+ // 无需外部 bash 脚本,跨平台(Windows/Linux/macOS)都能用。
942
+ // ============================================================
943
+ if (cliArgs.withNotify) {
944
+ const { startBuiltinListeners } = await import('../channel/notify-daemon.js')
945
+ // 启动内部的 notify 监听器(不 fork 新进程,直接在当前进程运行)
946
+ const builtinNotify = await startBuiltinListeners({
947
+ channels: config.get('channels') || {},
948
+ defaultChannel: config.get('defaultChannel') || process.env.CC_NODE_CHANNEL_DEFAULT || null,
949
+ onMessage: async (msg) => {
950
+ // 来自外部的消息 → 转发到 REPL 引擎
951
+ const text = msg.text || ''
952
+ if (text && !text.startsWith('/')) {
953
+ await processInputLine(text, msg.channel || 'external', msg.chatId)
954
+ }
955
+ },
956
+ })
957
+ console.log('📡 Built-in channel listeners started (--with-notify)')
958
+ }
959
+
841
960
  console.log(buildBanner({ model, permissionMode, session, maxTokens: tokenBudget.maxTokens }))
842
961
  console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
843
962
  console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
package/src/core/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ export { UserMessage, AssistantMessage, ToolCall } from "../types/index.js"
2
+
1
3
  /**
2
4
  * 核心模块统一导出
3
5
  */
package/src/core/paths.js CHANGED
@@ -3,9 +3,15 @@
3
3
  */
4
4
  import { join } from 'path'
5
5
  import { homedir } from 'os'
6
+ import { platform } from 'process'
7
+
8
+ const isWindows = platform === 'win32'
6
9
 
7
10
  export const SOCK_DIR = join(homedir(), '.cc-node')
8
- export const SOCK_PATH = join(SOCK_DIR, 'repl.sock')
11
+ // Windows 不支持传统 Unix domain socket(会抛 EACCES),改用 named pipe。
12
+ // Unix socket: C:\Users\xxx\.cc-node\repl.sock → 报错
13
+ // named pipe : \\.\pipe\cc-node → 正常
14
+ export const SOCK_PATH = isWindows ? '\\\\.\\pipe\\cc-node' : join(SOCK_DIR, 'repl.sock')
9
15
  export const CC_NODE_PID = join(SOCK_DIR, 'cc-node.pid')
10
16
  export const CC_NOTIFY_PID = join(SOCK_DIR, 'cc-notify.pid')
11
17
  export const CC_NOTIFY_LOG = join(SOCK_DIR, 'cc-notify.log')