@raolin2025/claude-code-node 2.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -491,28 +491,31 @@ async function main() {
491
491
  // 确保 socket 目录存在
492
492
  mkdirSync(SOCK_DIR, { recursive: true });
493
493
 
494
- // v1.1: PID file lock — atomic create, prevent multiple instances
495
- try {
496
- const fd = openSync(config.pidFile, "wx");
497
- writeFileSync(fd, String(process.pid));
498
- closeSync(fd);
499
- } catch (err) {
500
- if (err.code === "EEXIST") {
494
+ // M9 fix: PID file lock — atomic create with retry, no unlink+write race
495
+ let pidAcquired = false;
496
+ for (let attempt = 0; attempt < 3; attempt++) {
497
+ try {
498
+ const fd = openSync(config.pidFile, "wx");
499
+ writeFileSync(fd, String(process.pid));
500
+ closeSync(fd);
501
+ pidAcquired = true;
502
+ break;
503
+ } catch (err) {
504
+ if (err.code !== "EEXIST") throw err;
501
505
  const oldPid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
502
506
  try {
503
507
  process.kill(oldPid, 0);
504
508
  console.error("cc-notify already running (PID " + oldPid + "). Use --stop first.");
505
509
  process.exit(1);
506
510
  } catch {
507
- try {
508
- unlinkSync(config.pidFile);
509
- } catch {}
510
- writeFileSync(config.pidFile, String(process.pid));
511
+ try { unlinkSync(config.pidFile); } catch {}
512
+ if (attempt < 2) await sleep(100);
511
513
  }
512
- } else {
513
- throw err;
514
514
  }
515
515
  }
516
+ if (!pidAcquired) {
517
+ writeFileSync(config.pidFile, String(process.pid));
518
+ }
516
519
 
517
520
  const cleanup = () => {
518
521
  log("Shutting down...");
package/src/core/cli.js CHANGED
@@ -138,6 +138,7 @@ Commands:
138
138
  /channel CMD — Manage notification channels (list|send|test)
139
139
  /cost — Show API cost report
140
140
  /compact — Manually compact conversation context
141
+ /allow [tool] — Allow a tool for the current session (default: all)
141
142
  /exit — Exit (also Ctrl+C)
142
143
  /quit — Same as /exit
143
144
  `
@@ -241,6 +242,8 @@ export async function main() {
241
242
  session = await sessionManager.create()
242
243
  }
243
244
 
245
+ // M1 fix: tokenBudget 必须在 engineConfig 之前定义,否则 TDZ ReferenceError
246
+ const tokenBudget = new TokenBudget({ maxTokens: config.get('maxBudgetTokens') || 200_000 })
244
247
  const costTracker = new CostTracker({ model })
245
248
 
246
249
  const engineConfig = new QueryEngineConfig({
@@ -252,11 +255,17 @@ export async function main() {
252
255
  })
253
256
  const engine = new QueryEngine(engineConfig)
254
257
 
255
- // M5: 恢复会话历史和状态
258
+ // M5: 恢复会话历史和状态 — 完整恢复所有角色(含 tool_calls、tool 结果)
256
259
  if (session?.messages?.length) {
257
260
  for (const msg of session.messages) {
258
- if (msg.role === 'user') engine.state.messages.push({ role: 'user', content: msg.content })
259
- else if (msg.role === 'assistant') 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)
260
269
  }
261
270
  // 恢复 turn count
262
271
  if (session.state?.turnCount) engine.state.turnCount = session.state.turnCount
@@ -268,7 +277,6 @@ export async function main() {
268
277
  }
269
278
  }
270
279
 
271
- const tokenBudget = new TokenBudget({ maxTokens: config.get('maxBudgetTokens') || 200_000 })
272
280
 
273
281
  const channelManager = new ChannelManager({
274
282
  channels: config.get('channels') || {},
@@ -277,8 +285,16 @@ export async function main() {
277
285
 
278
286
  // 一次性输入模式
279
287
  if (cliArgs.oneShot) {
288
+ // 一次性模式下用户已明确表达了执行意图,自动批准所有工具调用
289
+ if (engine.permissionChecker.mode === 'ask') {
290
+ engine.config.onConfirmTool = async () => true
291
+ }
280
292
  const result = await engine.processMessage(cliArgs.oneShot)
281
293
  console.log(result.response)
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 })
282
298
  if (channelManager.list().length > 0) {
283
299
  await channelManager.sendTemplate('task-done', {
284
300
  task: cliArgs.oneShot.slice(0, 80),
@@ -293,6 +309,19 @@ export async function main() {
293
309
 
294
310
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
295
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
324
+
296
325
  console.log(BANNER)
297
326
  console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
298
327
  console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
@@ -304,7 +333,7 @@ export async function main() {
304
333
  console.log()
305
334
  rl.prompt()
306
335
 
307
- // 共享的消息处理函数(REPL 和 socket 都用)
336
+ // REPL 消息处理包装(留作扩展点)
308
337
  async function processInput(input) {
309
338
  return engine.processMessage(input)
310
339
  }
@@ -380,6 +409,12 @@ export async function main() {
380
409
  }
381
410
  break
382
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
+ }
383
418
  case 'cost':
384
419
  console.log(engine.costTracker.formatReport())
385
420
  break
@@ -415,6 +450,11 @@ export async function main() {
415
450
  console.log()
416
451
  await sessionManager.appendMessage({ role: 'user', content: input })
417
452
  await sessionManager.appendMessage({ role: 'assistant', content: result.response })
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)
418
458
  if (verbose) console.log(`[Turns: ${result.turns} | Tools: ${result.toolResults.length}]`)
419
459
  // 显示费用(即使非 verbose 也显示)
420
460
  if (engine.costTracker && engine.costTracker.totalApiCalls > 0) {
@@ -43,6 +43,8 @@ export class QueryEngineConfig {
43
43
  this.costTracker = options.costTracker || null
44
44
  this.tokenBudget = options.tokenBudget || null
45
45
  this.initialMessages = options.initialMessages || []
46
+ this.onConfirmTool = options.onConfirmTool || null // ask 模式确认回调
47
+ this.readline = options.readline || null // 用于 AskUserQuestion 工具
46
48
  }
47
49
  }
48
50
 
@@ -196,39 +198,61 @@ export class QueryEngine {
196
198
 
197
199
 
198
200
  /**
199
- * 执行工具调用
201
+ * 执行工具调用 — 两阶段策略
202
+ * 阶段1(串行):安全检查 + ask 模式确认(需要用户交互,必须串行)
203
+ * 阶段2(并行):批准后的工具并行执行,互不依赖的工具同时跑
200
204
  */
201
205
  async _executeToolCalls(toolCalls) {
202
- const results = []
206
+ // 阶段1:串行安全检查
207
+ const approved = []
203
208
  for (const tc of toolCalls) {
204
- // 安全检查
205
209
  const permResult = await this.permissionChecker.check(tc.name, tc.input)
206
210
  if (!permResult.allowed) {
207
- results.push(new ToolResult(tc.id, `工具调用被安全策略拒绝: ${tc.name} — ${permResult.reason || ""}`, true))
208
- results[results.length - 1].toolName = tc.name
209
- continue
211
+ if (permResult.requiresConfirmation && this.config.onConfirmTool) {
212
+ const confirmed = await this.config.onConfirmTool(tc.name, tc.input)
213
+ if (!confirmed) {
214
+ approved.push({ tc, error: '用户未确认' })
215
+ continue
216
+ }
217
+ } else {
218
+ approved.push({ tc, error: `安全策略拒绝: ${permResult.reason || ""}` })
219
+ continue
220
+ }
210
221
  }
211
222
 
212
- // 查找工具
213
223
  const tool = this.config.tools.find(t => t.name === tc.name)
214
224
  if (!tool) {
215
- results.push(new ToolResult(tc.id, `未找到工具: ${tc.name}`, true))
216
- results[results.length - 1].toolName = tc.name
225
+ approved.push({ tc, error: `未找到工具: ${tc.name}` })
217
226
  continue
218
227
  }
219
228
 
229
+ approved.push({ tc, tool })
230
+ }
231
+
232
+ // 阶段2:并行执行已批准的工具
233
+ const execPromises = approved.map(async (item) => {
234
+ if (item.error) {
235
+ const r = new ToolResult(item.tc.id, item.error, true)
236
+ r.toolName = item.tc.name
237
+ return r
238
+ }
239
+ const { tc, tool } = item
240
+ tc.status = 'running'
220
241
  try {
221
- tc.status = 'running'
222
- const content = await tool.handler(tc.input, { cwd: this.config.cwd, engine: this })
242
+ const content = await tool.handler(tc.input, { cwd: this.config.cwd, engine: this, readline: this.config.readline })
223
243
  tc.status = 'done'
224
- results.push(new ToolResult(tc.id, typeof content === 'string' ? content : JSON.stringify(content), false))
225
- results[results.length - 1].toolName = tc.name
244
+ const r = new ToolResult(tc.id, typeof content === 'string' ? content : JSON.stringify(content), false)
245
+ r.toolName = tc.name
246
+ return r
226
247
  } catch (err) {
227
248
  tc.status = 'error'
228
- results.push(new ToolResult(tc.id, `工具执行错误: ${err.message}`, true))
229
- results[results.length - 1].toolName = tc.name
249
+ const r = new ToolResult(tc.id, `工具执行错误: ${err.message}`, true)
250
+ r.toolName = tc.name
251
+ return r
230
252
  }
231
- }
253
+ })
254
+
255
+ const results = await Promise.all(execPromises)
232
256
  return results
233
257
  }
234
258
 
@@ -289,6 +313,12 @@ export class QueryEngine {
289
313
 
290
314
  // 带重试的 fetch
291
315
  const maxRetries = 3
316
+ // Jitter 退避 — 指数退避 + 随机 ±50%,防止惊群效应
317
+ const retryDelay = (baseMs, attempt) => {
318
+ const ms = baseMs * Math.pow(2, attempt - 1)
319
+ const jitter = ms * (0.5 + Math.random() * 0.5) // 50%-100% of base
320
+ return Math.round(jitter)
321
+ }
292
322
  let lastError = null
293
323
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
294
324
  try {
@@ -310,7 +340,7 @@ export class QueryEngine {
310
340
  const errText = await response.text()
311
341
  // 429/503 可重试
312
342
  if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
313
- const waitMs = response.status === 429 ? 2000 * attempt : 1000
343
+ const waitMs = retryDelay(response.status === 429 ? 2000 : 1000, attempt)
314
344
  if (this.config.verbose) {
315
345
  console.error(`[retry] API ${response.status}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
316
346
  }
@@ -322,11 +352,17 @@ export class QueryEngine {
322
352
 
323
353
  // 流式或非流式处理
324
354
  if (useStream && response.body) {
325
- return await this._handleStreamResponse(response)
355
+ const result = await this._handleStreamResponse(response)
356
+ if (result.usage && this.costTracker) {
357
+ this.costTracker.recordUsage(result.usage)
358
+ }
359
+ if (this.tokenBudget && result.usage) {
360
+ this.tokenBudget.recordUsage(result.usage)
361
+ }
362
+ return result
326
363
  } else {
327
364
  const data = await response.json()
328
365
  const result = parseNonStreamResponse(data)
329
- // M4: 记录非流式响应费用
330
366
  if (result.usage && this.costTracker) {
331
367
  this.costTracker.recordUsage(result.usage)
332
368
  }
@@ -339,7 +375,7 @@ export class QueryEngine {
339
375
  lastError = err
340
376
  // 网络错误重试
341
377
  if (err.name !== 'AbortError' && attempt < maxRetries && !err.message.startsWith('API 错误')) {
342
- const waitMs = 1000 * attempt
378
+ const waitMs = retryDelay(1000, attempt)
343
379
  if (this.config.verbose) {
344
380
  console.error(`[retry] Network error: ${err.message}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
345
381
  }
@@ -394,42 +430,9 @@ export class QueryEngine {
394
430
  return result
395
431
  }
396
432
 
397
- /**
398
- * 解析 OpenAI 兼容响应
399
- */
400
- _parseResponse(data) {
401
- const result = { content: '', toolCalls: [] }
402
- const choice = data.choices?.[0]
403
- if (!choice) return result
404
-
405
- const message = choice.message
406
- if (message.content) {
407
- result.content = message.content
408
- }
409
-
410
- for (const tc of (message.tool_calls || [])) {
411
- let input = {}
412
- try {
413
- input = JSON.parse(tc.function.arguments || '{}')
414
- } catch {
415
- input = { _raw: tc.function.arguments }
416
- }
417
- result.toolCalls.push(new ToolCall(tc.id, tc.function.name, input))
418
- }
419
-
420
- // M4: 记录 API 调用费用
421
- if (result.usage && this.costTracker) {
422
- this.costTracker.recordUsage(result.usage)
423
- }
424
- if (this.tokenBudget && result.usage) {
425
- this.tokenBudget.recordUsage(result.usage)
426
- }
427
-
428
- return result
429
- }
430
-
431
433
  /** 格式化内容 */
432
434
  _formatContent(content) {
435
+ if (content == null) return ''
433
436
  if (typeof content === 'string') return content
434
437
  if (typeof content === 'object') return JSON.stringify(content)
435
438
  return String(content)
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { readFile, writeFile, mkdir, readdir, rm, chmod } from 'fs/promises'
6
6
  import { resolve, join } from 'path'
7
+ import { randomBytes } from 'crypto'
7
8
 
8
9
  const DEFAULT_SESSIONS_DIR = '.claude-code/sessions'
9
10
 
@@ -21,7 +22,8 @@ export class SessionManager {
21
22
  /** 创建新会话 */
22
23
  async create(title = '') {
23
24
  await this.ensureDir()
24
- const id = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
25
+ // M4 fix: 使用 crypto.randomBytes 生成不可预测的会话 ID
26
+ const id = `session-${Date.now()}-${randomBytes(8).toString('hex')}`
25
27
  const session = {
26
28
  id,
27
29
  title: title || `Session ${new Date().toISOString().slice(0, 19)}`,
package/src/mcp/client.js CHANGED
@@ -212,7 +212,7 @@ export class MCPClient {
212
212
  }
213
213
 
214
214
  /** 发送 JSON-RPC 请求 */
215
- _sendRequest(method, params) {
215
+ _sendRequest(method, params, timeoutMs = 30000) {
216
216
  return new Promise((resolve, reject) => {
217
217
  const id = nextId()
218
218
  const message = JSON.stringify({
@@ -222,11 +222,19 @@ export class MCPClient {
222
222
  params,
223
223
  })
224
224
 
225
- this.pending.set(id, { resolve, reject })
225
+ // M8 fix: 请求超时保护,防止 MCP 服务器无响应时永远挂起
226
+ const timer = setTimeout(() => {
227
+ if (this.pending.has(id)) {
228
+ this.pending.delete(id)
229
+ reject(new Error(`MCP request timeout: ${method} (${timeoutMs}ms)`))
230
+ }
231
+ }, timeoutMs)
232
+ this.pending.set(id, { resolve, reject, timer })
226
233
 
227
234
  // 每条消息以换行符分隔
228
235
  this.process.stdin.write(message + '\n', (err) => {
229
236
  if (err) {
237
+ clearTimeout(timer)
230
238
  this.pending.delete(id)
231
239
  reject(new Error(`Failed to send message: ${err.message}`))
232
240
  }
@@ -255,8 +263,10 @@ export class MCPClient {
255
263
  const message = JSON.parse(line)
256
264
 
257
265
  if (message.id && this.pending.has(message.id)) {
258
- const { resolve, reject } = this.pending.get(message.id)
266
+ const { resolve, reject, timer } = this.pending.get(message.id)
259
267
  this.pending.delete(message.id)
268
+ // M8 fix: 清除超时定时器
269
+ if (timer) clearTimeout(timer)
260
270
 
261
271
  if (message.error) {
262
272
  reject(new Error(message.error.message || 'MCP error'))
@@ -8,6 +8,7 @@
8
8
  * - 双重编码绕过检测(%252e 等)
9
9
  */
10
10
  import { resolve, normalize, isAbsolute, relative, sep } from 'path'
11
+ import { realpathSync } from 'fs'
11
12
 
12
13
  /**
13
14
  * 敏感路径列表 — 禁止读写
@@ -53,7 +54,16 @@ export function sanitizePath(filePath, cwd = process.cwd()) {
53
54
  // 如果是相对路径,基于 cwd 解析
54
55
  const absPath = isAbsolute(decoded) ? decoded : resolve(cwd, decoded)
55
56
  // 规范化:消除 .. 和 .
56
- return normalize(absPath)
57
+ const normalized = normalize(absPath)
58
+
59
+ // M6 fix: 解析符号链接,防止通过 symlink 绕过路径安全检查
60
+ // 例如: /tmp/link → /etc/shadow
61
+ try {
62
+ return realpathSync(normalized)
63
+ } catch {
64
+ // 文件不存在时 realpathSync 会抛错,返回规范化路径即可
65
+ return normalized
66
+ }
57
67
  }
58
68
 
59
69
  /**
@@ -35,6 +35,8 @@ function htmlToText(html) {
35
35
  return text
36
36
  }
37
37
 
38
+ const VERSION = '2.0.0'
39
+
38
40
  export const webFetchTool = new ToolDef(
39
41
  'WebFetch',
40
42
  `Fetch and extract content from a URL.
@@ -69,7 +71,7 @@ Usage:
69
71
  try {
70
72
  const response = await fetch(url, {
71
73
  headers: {
72
- 'User-Agent': 'ClaudeCode-Node/1.0',
74
+ 'User-Agent': `ClaudeCode-Node/${VERSION}`,
73
75
  'Accept': 'text/html,application/json,text/plain,*/*',
74
76
  },
75
77
  signal: AbortSignal.timeout(30000),