@raolin2025/claude-code-node 1.2.0 → 2.1.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.
package/src/core/cli.js CHANGED
@@ -1,15 +1,120 @@
1
1
  /**
2
2
  * CLI 入口 — 命令行解析和 REPL 循环
3
3
  * 对应原版: src/cli/ + src/entrypoints/
4
+ *
5
+ * v1.2: 增加 Unix socket 服务,让 cc-notify 能发现并转发消息
4
6
  */
5
7
  import { createInterface } from 'readline'
8
+ import { createServer as createNetServer } from 'net'
9
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync, chmodSync } from 'fs'
10
+ import { join } from 'path'
6
11
  import { QueryEngine, QueryEngineConfig } from './query-engine.js'
7
12
  import { createDefaultRegistry } from '../tools/index.js'
8
13
  import { SessionManager } from './session.js'
9
14
  import { Config } from './config.js'
10
15
  import { TokenBudget } from './token-budget.js'
11
- import { PermissionChecker } from '../permission/permission.js'
12
16
  import { ChannelManager } from '../channel/index.js'
17
+ import { CostTracker } from './cost-tracker.js'
18
+ import { autoCompact } from './compact.js'
19
+ import { SOCK_DIR, SOCK_PATH, CC_NODE_PID } from './paths.js'
20
+
21
+ // ============================================================
22
+ // Unix Socket — 让 cc-notify 能发现 cc-node
23
+ // ============================================================
24
+
25
+
26
+
27
+ /**
28
+ * 启动 Unix socket 服务器
29
+ * cc-notify 通过此 socket 转发消息给已运行的 cc-node
30
+ */
31
+ function startSocketServer(engine, session, sessionManager, channelManager, verbose) {
32
+ mkdirSync(SOCK_DIR, { recursive: true })
33
+
34
+ // v1.1 修复: 安全清理残留 socket — 检查 PID 文件确认进程已死
35
+ if (existsSync(SOCK_PATH)) {
36
+ let shouldClean = true
37
+ if (existsSync(CC_NODE_PID)) {
38
+ try {
39
+ const oldPid = parseInt(readFileSync(CC_NODE_PID, 'utf8').trim(), 10)
40
+ // 检查旧进程是否还活着
41
+ process.kill(oldPid, 0) // 如果进程存在且活着,这不会抛出
42
+ shouldClean = false // 旧进程还活着,不要清理
43
+ console.error(`cc-node already running (PID ${oldPid}). Use /exit first or kill ${oldPid}`)
44
+ process.exit(1)
45
+ } catch {
46
+ // 旧进程已死,安全清理
47
+ }
48
+ }
49
+ if (shouldClean) {
50
+ try { unlinkSync(SOCK_PATH) } catch {}
51
+ }
52
+ }
53
+
54
+ const server = createNetServer((client) => {
55
+ // v1.1: socket 连接来源验证 — 只允许同用户连接
56
+ // Unix socket 本身通过文件系统权限保护
57
+ let buffer = ''
58
+
59
+ client.on('data', async (data) => {
60
+ buffer += data.toString()
61
+
62
+ // 按行解析 JSON 消息
63
+ const lines = buffer.split('\n')
64
+ buffer = lines.pop() // 保留不完整的行
65
+
66
+ for (const line of lines) {
67
+ if (!line.trim()) continue
68
+ try {
69
+ const msg = JSON.parse(line)
70
+ if (msg.type === 'user_input' && msg.text) {
71
+ // 转发到引擎处理
72
+ const result = await engine.processMessage(msg.text)
73
+ const reply = JSON.stringify({ type: 'reply', text: result.response }) + '\n'
74
+ client.write(reply)
75
+
76
+ // 保存到会话
77
+ await sessionManager.appendMessage({ role: 'user', content: msg.text })
78
+ await sessionManager.appendMessage({ role: 'assistant', content: result.response })
79
+ // M5: 保存 engine state 到 session
80
+ session.state = session.state || {}
81
+ session.state.turnCount = engine.state.turnCount
82
+ session.state.costHistory = engine.costTracker.history.slice(-50) // 只保留最近50条
83
+ await sessionManager.save(session)
84
+ } else if (msg.type === 'ping') {
85
+ client.write(JSON.stringify({ type: 'pong', pid: process.pid }) + '\n')
86
+ }
87
+ } catch (e) {
88
+ client.write(JSON.stringify({ type: 'error', text: e.message }) + '\n')
89
+ }
90
+ }
91
+ })
92
+
93
+ client.on('error', () => {}) // 忽略连接断开
94
+ })
95
+
96
+ server.listen(SOCK_PATH, () => {
97
+ // v1.1 修复: socket 文件权限 0600(仅所有者可读写),阻止其他用户连接
98
+ try { chmodSync(SOCK_PATH, 0o600) } catch {}
99
+ // 写 PID 文件(权限 0644)
100
+ writeFileSync(CC_NODE_PID, String(process.pid), { mode: 0o644 })
101
+ })
102
+
103
+ // 退出时清理
104
+ const cleanup = () => {
105
+ try { unlinkSync(SOCK_PATH) } catch {}
106
+ try { unlinkSync(CC_NODE_PID) } catch {}
107
+ }
108
+ process.on('SIGTERM', cleanup)
109
+ process.on('SIGINT', cleanup)
110
+ process.on('exit', cleanup)
111
+
112
+ return server
113
+ }
114
+
115
+ // ============================================================
116
+ // Banner & Help
117
+ // ============================================================
13
118
 
14
119
  const BANNER = `
15
120
  ╔═══════════════════════════════════════════════╗
@@ -31,13 +136,17 @@ Commands:
31
136
  /config KEY — Show config value
32
137
  /budget — Show token budget
33
138
  /channel CMD — Manage notification channels (list|send|test)
139
+ /cost — Show API cost report
140
+ /compact — Manually compact conversation context
141
+ /allow [tool] — Allow a tool for the current session (default: all)
34
142
  /exit — Exit (also Ctrl+C)
35
143
  /quit — Same as /exit
36
144
  `
37
145
 
38
- /**
39
- * 解析命令行参数
40
- */
146
+ // ============================================================
147
+ // 参数解析
148
+ // ============================================================
149
+
41
150
  function parseArgs(argv) {
42
151
  const args = {
43
152
  model: 'deepseek-chat',
@@ -50,7 +159,7 @@ function parseArgs(argv) {
50
159
  noStream: false,
51
160
  }
52
161
 
53
- let i = 2 // skip node and script name
162
+ let i = 2
54
163
  while (i < argv.length) {
55
164
  const arg = argv[i]
56
165
  switch (arg) {
@@ -64,14 +173,14 @@ function parseArgs(argv) {
64
173
  case '--verbose': case '-v': args.verbose = true; break
65
174
  case '--no-stream': args.noStream = true; break
66
175
  case '--help': case '-h':
67
- console.log(`Usage: cc-node [options]
176
+ console.log(`Usage: cc-node [options] [prompt]
68
177
 
69
178
  Options:
70
- -m, --model NAME Model to use (required, e.g. deepseek-chat, qwen-plus, glm-4-flash)
179
+ -m, --model NAME Model to use
71
180
  -s, --system-prompt TEXT System prompt
72
- -p, --permission-mode Permission mode: ask|always-allow|deny (default: ask)
181
+ -p, --permission-mode Permission mode: ask|always-allow|deny
73
182
  -t, --max-turns N Max tool loop turns (default: 100)
74
- --api-base URL API base URL (default: https://api.deepseek.com/v1)
183
+ --api-base URL API base URL
75
184
  --api-key *** API key (or set LLM_API_KEY env)
76
185
  -r, --resume ID Resume a session
77
186
  -v, --verbose Verbose mode
@@ -79,22 +188,17 @@ Options:
79
188
  -h, --help Show this help
80
189
 
81
190
  Environment variables:
82
- LLM_API_KEY Universal API key (recommended)
83
- DEEPSEEK_API_KEY DeepSeek API key (default)
84
- OPENAI_API_KEY OpenAI API key
85
- QWEN_API_KEY Qwen (DashScope) API key
86
- GLM_API_KEY Zhipu GLM API key
87
- KIMI_API_KEY Moonshot Kimi API key
88
- LLM_API_BASE API base URL (default: https://api.deepseek.com/v1)
191
+ LLM_API_KEY, DEEPSEEK_API_KEY, OPENAI_API_KEY,
192
+ QWEN_API_KEY, GLM_API_KEY, KIMI_API_KEY, LLM_API_BASE
89
193
 
90
194
  Channel environment variables:
91
- CC_NODE_CHANNEL_DEFAULT Default channel name
92
- CC_NODE_CHANNEL_TELEGRAM_TOKEN Telegram bot token
93
- CC_NODE_CHANNEL_TELEGRAM_CHAT_ID Telegram chat ID
94
- CC_NODE_CHANNEL_WECOM_WEBHOOK_URL WeCom webhook URL
95
- CC_NODE_CHANNEL_FEISHU_WEBHOOK_URL Feishu webhook URL
96
- CC_NODE_CHANNEL_DISCORD_WEBHOOK_URL Discord webhook URL
97
- CC_NODE_CHANNEL_SLACK_WEBHOOK_URL Slack webhook URL
195
+ CC_NODE_CHANNEL_DEFAULT, CC_NODE_CHANNEL_TELEGRAM_TOKEN,
196
+ CC_NODE_CHANNEL_TELEGRAM_CHAT_ID, CC_NODE_CHANNEL_WECOM_WEBHOOK_URL,
197
+ CC_NODE_CHANNEL_FEISHU_WEBHOOK_URL, CC_NODE_CHANNEL_DISCORD_WEBHOOK_URL,
198
+ CC_NODE_CHANNEL_SLACK_WEBHOOK_URL
199
+
200
+ Unix Socket (for cc-notify):
201
+ ${SOCK_PATH} — cc-notify 通过此 socket 转发消息
98
202
  `)
99
203
  process.exit(0)
100
204
  default:
@@ -109,17 +213,16 @@ Channel environment variables:
109
213
  return args
110
214
  }
111
215
 
112
- /**
113
- * 主入口
114
- */
216
+ // ============================================================
217
+ // 主入口
218
+ // ============================================================
219
+
115
220
  export async function main() {
116
221
  const cliArgs = parseArgs(process.argv)
117
222
 
118
- // 加载配置
119
223
  const config = new Config()
120
224
  await config.load(process.cwd())
121
225
 
122
- // 合并 CLI 参数 > 项目配置 > 用户配置 > 默认值
123
226
  const model = cliArgs.model || config.get('model')
124
227
  const systemPrompt = cliArgs.systemPrompt || ''
125
228
  const permissionMode = cliArgs.permissionMode || config.get('permissionMode')
@@ -128,55 +231,53 @@ export async function main() {
128
231
  const apiKey = cliArgs.apiKey || config.get('apiKey') || ''
129
232
  const verbose = cliArgs.verbose || config.get('verbose')
130
233
 
131
- // 创建工具注册表
132
234
  const registry = createDefaultRegistry()
235
+ const sessionManager = new SessionManager({ sessionsDir: config.get('sessionsDir') })
133
236
 
134
- // 创建会话管理器
135
- const sessionManager = new SessionManager({
136
- sessionsDir: config.get('sessionsDir'),
137
- })
138
-
139
- // 恢复或创建会话
140
237
  let session
141
238
  if (cliArgs.resume) {
142
239
  session = await sessionManager.load(cliArgs.resume)
143
- if (!session) {
144
- console.error(`Session not found: ${cliArgs.resume}`)
145
- process.exit(1)
146
- }
240
+ if (!session) { console.error(`Session not found: ${cliArgs.resume}`); process.exit(1) }
147
241
  } else {
148
242
  session = await sessionManager.create()
149
243
  }
150
244
 
151
- // 创建查询引擎
245
+ // M1 fix: tokenBudget 必须在 engineConfig 之前定义,否则 TDZ ReferenceError
246
+ const tokenBudget = new TokenBudget({ maxTokens: config.get('maxBudgetTokens') || 200_000 })
247
+ const costTracker = new CostTracker({ model })
248
+
152
249
  const engineConfig = new QueryEngineConfig({
153
- model,
154
- systemPrompt,
155
- permissionMode,
156
- maxTurns,
157
- apiBase,
158
- apiKey,
159
- verbose,
250
+ model, systemPrompt, permissionMode, maxTurns, apiBase, apiKey, verbose,
160
251
  tools: registry.getAll(),
252
+ noStream: cliArgs.noStream,
253
+ costTracker,
254
+ tokenBudget,
161
255
  })
162
256
  const engine = new QueryEngine(engineConfig)
163
257
 
164
- // 恢复会话历史
258
+ // M5: 恢复会话历史和状态 — 完整恢复所有角色(含 tool_calls、tool 结果)
165
259
  if (session?.messages?.length) {
166
260
  for (const msg of session.messages) {
167
- if (msg.role === 'user') {
168
- engine.state.messages.push({ role: 'user', content: msg.content })
169
- } else if (msg.role === 'assistant') {
170
- engine.state.messages.push({ role: 'assistant', content: msg.content })
261
+ const entry = { role: msg.role, content: msg.content }
262
+ if (msg.role === 'assistant' && msg.toolCalls?.length > 0) {
263
+ entry.toolCalls = msg.toolCalls
264
+ }
265
+ if (msg.role === 'tool' && msg.tool_call_id) {
266
+ entry.tool_call_id = msg.tool_call_id
267
+ }
268
+ engine.state.messages.push(entry)
269
+ }
270
+ // 恢复 turn count
271
+ if (session.state?.turnCount) engine.state.turnCount = session.state.turnCount
272
+ // 恢复费用记录
273
+ if (session.state?.costHistory) {
274
+ for (const record of session.state.costHistory) {
275
+ engine.costTracker.recordUsage(record)
171
276
  }
172
277
  }
173
278
  }
174
279
 
175
- const tokenBudget = new TokenBudget({
176
- maxTokens: config.get('maxBudgetTokens') || 200_000,
177
- })
178
280
 
179
- // 初始化通讯通道
180
281
  const channelManager = new ChannelManager({
181
282
  channels: config.get('channels') || {},
182
283
  defaultChannel: config.get('defaultChannel') || null,
@@ -184,27 +285,46 @@ export async function main() {
184
285
 
185
286
  // 一次性输入模式
186
287
  if (cliArgs.oneShot) {
288
+ // 一次性模式下用户已明确表达了执行意图,自动批准所有工具调用
289
+ if (engine.permissionChecker.mode === 'ask') {
290
+ engine.config.onConfirmTool = async () => true
291
+ }
187
292
  const result = await engine.processMessage(cliArgs.oneShot)
188
293
  console.log(result.response)
189
- // 一次性模式结束后发通知
294
+ // 保存会话
295
+ session = await sessionManager.create(`one-shot: ${cliArgs.oneShot.slice(0, 50)}`)
296
+ await sessionManager.appendMessage({ role: 'user', content: cliArgs.oneShot })
297
+ await sessionManager.appendMessage({ role: 'assistant', content: result.response })
190
298
  if (channelManager.list().length > 0) {
191
299
  await channelManager.sendTemplate('task-done', {
192
300
  task: cliArgs.oneShot.slice(0, 80),
193
301
  result: result.response.slice(0, 200),
194
- })
302
+ }).catch(() => {})
195
303
  }
196
304
  process.exit(0)
197
305
  }
198
306
 
199
- // REPL 模式
200
- const rl = createInterface({
201
- input: process.stdin,
202
- output: process.stdout,
203
- prompt: '> ',
204
- })
307
+ // REPL 模式 — 启动 Unix socket 让 cc-notify 能发现
308
+ startSocketServer(engine, session, sessionManager, channelManager, verbose)
309
+
310
+ const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
311
+
312
+ // 将 readline 注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
313
+ if (permissionMode === 'ask') {
314
+ engine.config.onConfirmTool = async (toolName, input) => {
315
+ return new Promise((resolve) => {
316
+ const snippet = JSON.stringify(input).slice(0, 120) || '(no params)'
317
+ rl.question(`\n⚠️ Allow tool "${toolName}"?\n Input: ${snippet}\n (y/N) `, (answer) => {
318
+ resolve(answer.toLowerCase().startsWith('y'))
319
+ })
320
+ })
321
+ }
322
+ }
323
+ engine.config.readline = rl
205
324
 
206
325
  console.log(BANNER)
207
326
  console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
327
+ console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
208
328
  if (channelManager.list().length > 0) {
209
329
  const chList = channelManager.list().join(', ')
210
330
  const def = channelManager.defaultChannel ? ` (default: ${channelManager.defaultChannel})` : ''
@@ -213,24 +333,22 @@ export async function main() {
213
333
  console.log()
214
334
  rl.prompt()
215
335
 
336
+ // REPL 消息处理包装(留作扩展点)
337
+ async function processInput(input) {
338
+ return engine.processMessage(input)
339
+ }
340
+
216
341
  rl.on('line', async (line) => {
217
342
  const input = line.trim()
218
343
  if (!input) { rl.prompt(); return }
219
344
 
220
- // 命令处理
221
345
  if (input.startsWith('/')) {
222
346
  const [cmd, ...rest] = input.slice(1).split(' ')
223
347
  switch (cmd) {
224
- case 'help':
225
- console.log(HELP_TEXT)
226
- break
348
+ case 'help': console.log(HELP_TEXT); break
227
349
  case 'model':
228
- if (rest[0]) {
229
- engine.config.model = rest.join(' ')
230
- console.log(`Model switched to: ${engine.config.model}`)
231
- } else {
232
- console.log(`Current model: ${engine.config.model}`)
233
- }
350
+ if (rest[0]) { engine.config.model = rest.join(' '); console.log(`Model → ${engine.config.model}`) }
351
+ else console.log(`Model: ${engine.config.model}`)
234
352
  break
235
353
  case 'tools':
236
354
  console.log('Available tools:')
@@ -247,13 +365,8 @@ export async function main() {
247
365
  break
248
366
  case 'sessions': {
249
367
  const sessions = await sessionManager.list()
250
- if (sessions.length === 0) {
251
- console.log('No sessions found')
252
- } else {
253
- for (const s of sessions) {
254
- console.log(` ${s.id} — ${s.title} (${s.messageCount} msgs, ${s.updated})`)
255
- }
256
- }
368
+ if (sessions.length === 0) console.log('No sessions found')
369
+ else for (const s of sessions) console.log(` ${s.id} ${s.title} (${s.messageCount} msgs, ${s.updated})`)
257
370
  break
258
371
  }
259
372
  case 'clear':
@@ -269,18 +382,14 @@ export async function main() {
269
382
  console.log(JSON.stringify(config.toJSON(), null, 2))
270
383
  }
271
384
  break
272
- case 'budget':
273
- console.log(tokenBudget.format())
274
- break
385
+ case 'budget': console.log(tokenBudget.format()); break
275
386
  case 'channel': {
276
387
  const subCmd = rest.join(' ')
277
388
  if (subCmd === 'list' || subCmd === '') {
278
389
  const channels = channelManager.list()
279
390
  if (channels.length === 0) {
280
391
  console.log('No channels configured')
281
- console.log('Setup options:')
282
- console.log(' 1. Environment: CC_NODE_CHANNEL_TELEGRAM_TOKEN=xxx CC_NODE_CHANNEL_TELEGRAM_CHAT_ID=xxx')
283
- console.log(' 2. Config: .claude-code/config.json -> { "channels": { "telegram": { ... } } }')
392
+ console.log('Setup: CC_NODE_CHANNEL_TELEGRAM_TOKEN=xxx CC_NODE_CHANNEL_TELEGRAM_CHAT_ID=xxx')
284
393
  } else {
285
394
  console.log('Channels:')
286
395
  for (const ch of channels) {
@@ -291,24 +400,39 @@ export async function main() {
291
400
  } else if (subCmd.startsWith('send ')) {
292
401
  const text = subCmd.slice(5)
293
402
  const results = await channelManager.send(text)
294
- for (const r of results) {
295
- console.log(r.ok ? `✅ ${r.channel}: sent` : `❌ ${r.channel}: ${r.error}`)
296
- }
403
+ for (const r of results) console.log(r.ok ? `✅ ${r.channel}: sent` : `❌ ${r.channel}: ${r.error}`)
297
404
  } else if (subCmd.startsWith('test')) {
298
405
  const results = await channelManager.send('📡 cc-node channel test')
299
- for (const r of results) {
300
- console.log(r.ok ? `✅ ${r.channel}: test OK` : `❌ ${r.channel}: ${r.error}`)
406
+ for (const r of results) console.log(r.ok ? `✅ ${r.channel}: test OK` : `❌ ${r.channel}: ${r.error}`)
407
+ } else {
408
+ console.log('Usage: /channel list|send <msg>|test')
409
+ }
410
+ break
411
+ }
412
+ case 'allow': {
413
+ const allowTool = rest.join(' ') || '*'
414
+ engine.permissionChecker.allowForSession(allowTool, '*')
415
+ console.log(`✅ Tool "${allowTool}" allowed for this session`)
416
+ break
417
+ }
418
+ case 'cost':
419
+ console.log(engine.costTracker.formatReport())
420
+ break
421
+ case 'compact': {
422
+ if (engine.tokenBudget) {
423
+ const { compacted, messages } = autoCompact(engine.state.messages, engine.tokenBudget, { keepRecentTurns: 4 })
424
+ if (compacted) {
425
+ engine.state.messages = messages
426
+ console.log('✅ Context compressed')
427
+ } else {
428
+ console.log('ℹ️ No compression needed')
301
429
  }
302
430
  } else {
303
- console.log('Usage:')
304
- console.log(' /channel list — List configured channels')
305
- console.log(' /channel send <msg> — Send message to channels')
306
- console.log(' /channel test — Test channel connectivity')
431
+ console.log('Token budget not configured')
307
432
  }
308
433
  break
309
434
  }
310
- case 'exit':
311
- case 'quit':
435
+ case 'exit': case 'quit':
312
436
  console.log('Goodbye!')
313
437
  process.exit(0)
314
438
  default:
@@ -320,35 +444,32 @@ export async function main() {
320
444
 
321
445
  // 发送到引擎
322
446
  try {
323
- const result = await engine.processMessage(input)
324
-
325
- // 输出助手回复
447
+ const result = await processInput(input)
326
448
  console.log()
327
449
  console.log(result.response)
328
450
  console.log()
329
-
330
- // 保存到会话
331
451
  await sessionManager.appendMessage({ role: 'user', content: input })
332
452
  await sessionManager.appendMessage({ role: 'assistant', content: result.response })
333
-
334
- if (verbose) {
335
- console.log(`[Turns: ${result.turns} | Tools: ${result.toolResults.length}]`)
453
+ // 保存引擎状态到会话
454
+ session.state = session.state || {}
455
+ session.state.turnCount = engine.state.turnCount
456
+ session.state.costHistory = engine.costTracker.history.slice(-50)
457
+ await sessionManager.save(session)
458
+ if (verbose) console.log(`[Turns: ${result.turns} | Tools: ${result.toolResults.length}]`)
459
+ // 显示费用(即使非 verbose 也显示)
460
+ if (engine.costTracker && engine.costTracker.totalApiCalls > 0) {
461
+ console.log(engine.costTracker.formatShort())
336
462
  }
337
463
  } catch (err) {
338
464
  console.error(`\nError: ${err.message}\n`)
339
- // 错误也通知
340
465
  if (channelManager.list().length > 0) {
341
466
  await channelManager.sendTemplate('error', {
342
- task: input.slice(0, 80),
343
- error: err.message.slice(0, 200),
344
- }).catch(() => {}) // 通知失败不影响主流程
467
+ task: input.slice(0, 80), error: err.message.slice(0, 200),
468
+ }).catch(() => {})
345
469
  }
346
470
  }
347
471
  rl.prompt()
348
472
  })
349
473
 
350
- rl.on('close', () => {
351
- console.log('\nGoodbye!')
352
- process.exit(0)
353
- })
474
+ rl.on('close', () => { console.log('\nGoodbye!'); process.exit(0) })
354
475
  }