@wu529778790/open-im 1.6.8-beta.2 → 1.6.9-beta.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/README.md CHANGED
@@ -59,10 +59,18 @@ The config file is stored at `~/.open-im/config.json` by default.
59
59
 
60
60
  ## Graphical Config Page
61
61
 
62
- - Default URL: `http://127.0.0.1:39282`
63
- - `open-im start` also serves the local configuration page
64
- - `open-im dev` only opens the page automatically when setup is incomplete
65
- - If configuration already exists and you want to open the page manually, use `open-im start`
62
+ Open the config page at **http://127.0.0.1:39282** (or the URL shown after `open-im start`). The page includes:
63
+
64
+ - **Dashboard** Configured / Enabled platform count and service status (Idle or Running)
65
+ - **Platforms** Enable and configure Telegram, Feishu, QQ, WeCom, and DingTalk (credentials, proxy, per-platform AI tool, allowed user IDs). Each platform has a “Test Configuration” button.
66
+ - **AI Tooling** – **General**: default AI tool (Claude / Codex / CodeBuddy), work directory, hook port, log level. **Per-tool tabs**: Claude (CLI path, timeout, proxy, config path, ANTHROPIC_* fields), Codex (CLI path, timeout, proxy), CodeBuddy (CLI path, timeout).
67
+ - **Service control** – Validate config, Save, Start bridge, Stop bridge.
68
+
69
+ WeChat is not in the web UI; configure it in `~/.open-im/config.json` or via `open-im init` if needed.
70
+
71
+ - `open-im start` serves the config page and the bridge.
72
+ - `open-im dev` opens the page automatically only when setup is incomplete.
73
+ - To open the page when config already exists, run `open-im start` and visit the URL above.
66
74
 
67
75
  ## Session Behavior
68
76
 
@@ -267,7 +275,7 @@ Notes on DingTalk: the current implementation uses a hybrid model of "Stream Mod
267
275
 
268
276
  - Plain text replies in a session are sent through `sessionWebhook`
269
277
  - If `cardTemplateId` is configured, the app will try AI assistant `prepare/update/finish` streaming cards; if that fails, it falls back to plain text. In custom bot or regular group scenarios, the interactive card API may return `param.error`, so single-message streaming updates are not available there yet
270
- - Startup and shutdown notifications are sent to the most recently active DingTalk conversation. After a cold start, if no DingTalk conversation has interacted with the service yet, there is no target to notify
278
+ - Startup and shutdown notifications are not sent to DingTalk (the OpenAPI robot API does not support proactive messages in the same way). Other platforms (e.g. Telegram, Feishu, WeCom) still receive lifecycle notifications when configured
271
279
 
272
280
  DingTalk AI card templates are already compatible with the official "Search Result Card" template and use the variables `lastMessage`, `content`, `resources`, `users`, and `flowStatus`. If you use that template, no template changes are required for streaming updates.
273
281
 
package/README.zh-CN.md CHANGED
@@ -59,10 +59,18 @@ open-im start
59
59
 
60
60
  ## 图形化配置页面
61
61
 
62
- - 默认地址:`http://127.0.0.1:39282`
63
- - `open-im start` 会同时提供本地配置页面
62
+ 在浏览器中打开 **http://127.0.0.1:39282**(或执行 `open-im start` 后提示的地址),页面结构如下:
63
+
64
+ - **概览** – 已配置/已启用平台数量、服务状态(未启动或运行中)
65
+ - **平台配置** – 启用并填写 Telegram、飞书、QQ、企业微信、钉钉的凭证(Bot Token/App ID/Secret、代理、该平台使用的 AI 工具、白名单用户 ID)。每个平台提供「校验配置」按钮
66
+ - **AI 工具配置** – **公共**:默认 AI 工具(Claude / Codex / CodeBuddy)、工作目录、Hook 端口、日志级别。**分工具**:Claude(CLI 路径、超时、代理、配置路径、ANTHROPIC_* 等)、Codex(CLI 路径、超时、代理)、CodeBuddy(CLI 路径、超时)
67
+ - **服务控制** – 校验配置、保存、启动桥接、停止桥接
68
+
69
+ 微信暂不在网页中配置,如需使用请在 `~/.open-im/config.json` 中手动配置或通过 `open-im init` 引导。
70
+
71
+ - `open-im start` 会同时启动桥接服务并提供该配置页
64
72
  - `open-im dev` 仅在未完成配置时自动打开页面
65
- - 已有配置但想手动打开时,使用 `open-im start`
73
+ - 已有配置但想手动打开时,执行 `open-im start` 后访问上述地址即可
66
74
 
67
75
  ## 会话说明
68
76
 
@@ -267,7 +275,7 @@ codebuddy login
267
275
 
268
276
  - 会话内普通文本回复默认走 `sessionWebhook`
269
277
  - 若配置了 `cardTemplateId`,会尝试 AI 助理 `prepare/update/finish` 流式卡片;失败则 fallback 为普通文本(自定义机器人/普通群场景下互动卡片 API 报 `param.error`,暂不支持单条流式更新)
270
- - 启动/关闭通知会发给最近一次已互动的钉钉会话;如果服务冷启动后还没有任何钉钉会话互动过,则没有可用目标可发
278
+ - 启动/关闭通知不会发给钉钉(OpenAPI 机器人接口不支持主动发消息);其他已配置平台(如 Telegram、飞书、企业微信)仍会收到生命周期通知
271
279
 
272
280
  钉钉 AI 卡片模板:已适配官方「搜索结果卡片」模板,使用变量 `lastMessage`、`content`、`resources`、`users`、`flowStatus`。若使用该模板,无需修改模板即可实现流式更新。
273
281
 
@@ -1,5 +1,4 @@
1
1
  import { getConfiguredAiCommands } from '../config.js';
2
- import { ClaudeAdapter } from './claude-adapter.js';
3
2
  import { ClaudeSDKAdapter } from './claude-sdk-adapter.js';
4
3
  import { CodexAdapter } from './codex-adapter.js';
5
4
  import { CodeBuddyAdapter } from './codebuddy-adapter.js';
@@ -10,17 +9,8 @@ export function initAdapters(config) {
10
9
  adapters.clear();
11
10
  for (const aiCommand of getConfiguredAiCommands(config)) {
12
11
  if (aiCommand === 'claude') {
13
- if (config.useSdkMode) {
14
- log.info('Claude Agent SDK adapter enabled');
15
- adapters.set('claude', new ClaudeSDKAdapter());
16
- }
17
- else {
18
- log.info('Claude CLI adapter enabled');
19
- adapters.set('claude', new ClaudeAdapter(config.claudeCliPath, {
20
- useProcessPool: true,
21
- idleTimeoutMs: 2 * 60 * 1000,
22
- }));
23
- }
12
+ log.info('Claude Agent SDK adapter enabled');
13
+ adapters.set('claude', new ClaudeSDKAdapter());
24
14
  continue;
25
15
  }
26
16
  if (aiCommand === 'codex') {
@@ -38,7 +28,6 @@ export function getAdapter(aiCommand) {
38
28
  return adapters.get(aiCommand);
39
29
  }
40
30
  export function cleanupAdapters() {
41
- ClaudeAdapter.destroy();
42
31
  ClaudeSDKAdapter.destroy();
43
32
  adapters.clear();
44
33
  }
@@ -667,7 +667,6 @@ export const PAGE_SCRIPT = String.raw ` const platformDefinitions = [
667
667
  codebuddyCliPath: getValue("ai-codebuddyCliPath"),
668
668
  hookPort: getNumber("ai-hookPort"),
669
669
  logLevel: getValue("ai-logLevel"),
670
- useSdkMode: true,
671
670
  },
672
671
  });
673
672
 
@@ -153,7 +153,6 @@ function buildInitialPayload(file) {
153
153
  hookPort: file.hookPort ?? 35801,
154
154
  logDir: file.logDir ?? "",
155
155
  logLevel: file.logLevel ?? "default",
156
- useSdkMode: file.useSdkMode ?? true,
157
156
  },
158
157
  };
159
158
  }
@@ -277,7 +276,6 @@ function createProbeConfig(values) {
277
276
  hookPort: 35801,
278
277
  logDir: "",
279
278
  logLevel: "INFO",
280
- useSdkMode: true,
281
279
  codebuddyCliPath: "codebuddy",
282
280
  platforms: {},
283
281
  ...values,
@@ -405,7 +403,6 @@ function toFileConfig(payload, existing) {
405
403
  hookPort: payload.ai.hookPort,
406
404
  logDir: payload.ai.logDir === undefined ? existing.logDir : clean(payload.ai.logDir),
407
405
  logLevel: payload.ai.logLevel === "default" ? undefined : payload.ai.logLevel,
408
- useSdkMode: payload.ai.useSdkMode,
409
406
  tools: {
410
407
  claude: {
411
408
  ...existing.tools?.claude,
package/dist/config.d.ts CHANGED
@@ -45,8 +45,6 @@ export interface Config {
45
45
  hookPort: number;
46
46
  logDir: string;
47
47
  logLevel: LogLevel;
48
- /** 是否使用 Agent SDK(进程内执行,无 spawn 开销,响应更快) */
49
- useSdkMode: boolean;
50
48
  platforms: {
51
49
  telegram?: {
52
50
  enabled: boolean;
@@ -185,7 +183,6 @@ export interface FileConfig {
185
183
  hookPort?: number;
186
184
  logDir?: string;
187
185
  logLevel?: LogLevel;
188
- useSdkMode?: boolean;
189
186
  }
190
187
  export declare const CONFIG_PATH: string;
191
188
  export declare function loadFileConfig(): FileConfig;
package/dist/config.js CHANGED
@@ -428,14 +428,9 @@ export function loadConfig() {
428
428
  const hookPort = process.env.HOOK_PORT !== undefined
429
429
  ? parseInt(process.env.HOOK_PORT, 10) || 35801
430
430
  : file.hookPort ?? 35801;
431
- // 当使用 Claude 时,强制使用 SDK 模式(更快,无需安装 CLI
432
- // 使用其他工具(codex/codebuddy)时,才根据配置决定
433
- const useSdkMode = aiCommand === 'claude' || (process.env.USE_SDK_MODE !== undefined
434
- ? process.env.USE_SDK_MODE === 'true'
435
- : file.useSdkMode ?? true);
436
- // 6. 校验 Claude API 凭证(SDK 模式需要)
431
+ // 6. 校验 Claude API 凭证(Claude 固定使用 Agent SDK
437
432
  // 支持:官方 API Key、Auth Token、或自定义 API(第三方模型等,BASE_URL + token)
438
- if (aiCommand === 'claude' && useSdkMode) {
433
+ if (aiCommand === 'claude') {
439
434
  const hasCreds = !!(process.env.ANTHROPIC_API_KEY ||
440
435
  process.env.ANTHROPIC_AUTH_TOKEN ||
441
436
  process.env.CLAUDE_CODE_OAUTH_TOKEN ||
@@ -542,49 +537,6 @@ export function loadConfig() {
542
537
  }
543
538
  }
544
539
  }
545
- // 9. 校验 Claude CLI(SDK 模式不需要 CLI)
546
- if (aiCommand === 'claude' && !useSdkMode) {
547
- if (isAbsolute(claudeCliPath) || claudeCliPath.includes('/') || claudeCliPath.includes('\\')) {
548
- try {
549
- accessSync(claudeCliPath, constants.F_OK | constants.X_OK);
550
- }
551
- catch {
552
- throw new Error(`Claude CLI 不可执行: ${claudeCliPath}`);
553
- }
554
- }
555
- else {
556
- // 检查命令是否存在(Windows 用 where,Unix 用 which)
557
- const checkCommand = process.platform === 'win32' ? 'where' : 'which';
558
- try {
559
- execFileSync(checkCommand, [claudeCliPath], {
560
- stdio: 'pipe',
561
- windowsHide: process.platform === 'win32',
562
- });
563
- }
564
- catch {
565
- const installGuide = [
566
- '',
567
- '━━━ Claude CLI 未安装 ━━━',
568
- '',
569
- 'open-im 需要 Claude Code CLI 才能运行。',
570
- '',
571
- '安装方法:',
572
- '',
573
- ' npm install -g @anthropic-ai/claude-code',
574
- '',
575
- '或者:',
576
- ' 1. 访问 https://claude.ai/download',
577
- ' 2. 下载并安装 Claude Code',
578
- '',
579
- '安装后重新运行,例如:',
580
- ' open-im dev',
581
- ' 或 open-im start',
582
- '',
583
- ].join('\n');
584
- throw new Error(installGuide);
585
- }
586
- }
587
- }
588
540
  // 7. 日志与平台配置
589
541
  const logDir = process.env.LOG_DIR ?? file.logDir ?? join(APP_HOME, 'logs');
590
542
  const logLevel = (process.env.LOG_LEVEL?.toUpperCase() ?? file.logLevel ?? 'INFO');
@@ -715,7 +667,6 @@ export function loadConfig() {
715
667
  hookPort,
716
668
  logDir,
717
669
  logLevel,
718
- useSdkMode,
719
670
  platforms,
720
671
  };
721
672
  }
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  const { execFileSyncMock, existsSyncMock, readFileSyncMock, unlinkSyncMock, writeFileSyncMock, spawnMock } = vi.hoisted(() => ({
3
3
  execFileSyncMock: vi.fn(),
4
4
  existsSyncMock: vi.fn(),
@@ -19,9 +19,20 @@ vi.mock("node:fs", () => ({
19
19
  }));
20
20
  import { waitForBackgroundServiceReady } from "./service-control.js";
21
21
  describe("waitForBackgroundServiceReady", () => {
22
+ let killSpy;
22
23
  beforeEach(() => {
23
24
  vi.clearAllMocks();
25
+ // Windows: isRunning() uses execFileSync('tasklist', ...)
24
26
  execFileSyncMock.mockReturnValue(Buffer.from("node.exe 123 Console 1 10,000 K"));
27
+ // Non-Windows (e.g. CI): isRunning() uses process.kill(pid, 0); mock so it doesn't throw (pid 123 doesn't exist)
28
+ killSpy = vi.spyOn(process, "kill").mockImplementation((_pid, signal) => {
29
+ if (signal === 0)
30
+ return true;
31
+ throw new Error("process.kill mock: only sig=0 supported");
32
+ });
33
+ });
34
+ afterEach(() => {
35
+ killSpy.mockRestore();
25
36
  });
26
37
  it("returns once the worker pid is running and the port file appears", async () => {
27
38
  existsSyncMock.mockImplementation((target) => target.includes("worker.pid") || target.includes("open-im.port"));
@@ -236,7 +236,7 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
236
236
  timeoutMs,
237
237
  model: sessionManager.getModel(ctx.userId, ctx.threadId) ?? config.claudeModel,
238
238
  chatId: ctx.chatId,
239
- ...(toolId === 'claude' && config.useSdkMode ? {} : { hookPort: config.hookPort }),
239
+ ...(toolId === 'claude' ? {} : { hookPort: config.hookPort }),
240
240
  ...(toolId === 'codex' && config.codexProxy ? { proxy: config.codexProxy } : {}),
241
241
  });
242
242
  return activeHandle;
@@ -38,7 +38,6 @@ describe("runAITask", () => {
38
38
  claudeTimeoutMs: 600000,
39
39
  claudeSkipPermissions: false,
40
40
  claudeModel: "",
41
- useSdkMode: true,
42
41
  hookPort: 35801,
43
42
  codexProxy: "",
44
43
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.6.8-beta.2",
3
+ "version": "1.6.9-beta.0",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,26 +0,0 @@
1
- import type { ToolAdapter, RunCallbacks, RunOptions, RunHandle } from './tool-adapter.interface.js';
2
- export declare class ClaudeAdapter implements ToolAdapter {
3
- private cliPath;
4
- readonly toolId = "claude";
5
- constructor(cliPath: string, adapterOptions?: {
6
- useProcessPool?: boolean;
7
- idleTimeoutMs?: number;
8
- });
9
- run(prompt: string, sessionId: string | undefined, workDir: string, callbacks: RunCallbacks, options?: RunOptions): RunHandle;
10
- /**
11
- * Get the number of cached entries in the pool.
12
- */
13
- static getCacheSize(): number;
14
- /**
15
- * Get the number of active processes in the pool.
16
- */
17
- static getActiveProcessCount(): number;
18
- /**
19
- * Terminate all cached entries and processes.
20
- */
21
- static terminateAll(): void;
22
- /**
23
- * Destroy the process pool and cleanup resources.
24
- */
25
- static destroy(): void;
26
- }
@@ -1,76 +0,0 @@
1
- import { runClaude } from '../claude/cli-runner.js';
2
- import { ClaudeProcessPool } from '../claude/process-pool.js';
3
- // Global process pool instance
4
- let processPool = null;
5
- export class ClaudeAdapter {
6
- cliPath;
7
- toolId = 'claude';
8
- constructor(cliPath, adapterOptions) {
9
- this.cliPath = cliPath;
10
- const useProcessPool = adapterOptions?.useProcessPool ?? true;
11
- const idleTimeoutMs = adapterOptions?.idleTimeoutMs ?? 2 * 60 * 1000; // 2 minutes default
12
- if (useProcessPool && !processPool) {
13
- // Initialize process pool with configurable idle timeout
14
- processPool = new ClaudeProcessPool(idleTimeoutMs);
15
- }
16
- }
17
- run(prompt, sessionId, workDir, callbacks, options) {
18
- const opts = {
19
- skipPermissions: options?.skipPermissions,
20
- permissionMode: options?.permissionMode,
21
- timeoutMs: options?.timeoutMs,
22
- model: options?.model,
23
- chatId: options?.chatId,
24
- hookPort: options?.hookPort,
25
- };
26
- // Use process pool if enabled and userId is available
27
- if (processPool && opts.chatId) {
28
- let aborted = false;
29
- // Execute using process pool with userId from chatId
30
- processPool
31
- .execute(opts.chatId, sessionId, this.cliPath, prompt, workDir, callbacks, opts)
32
- .catch((err) => {
33
- if (!aborted && callbacks.onError) {
34
- callbacks.onError(err.message);
35
- }
36
- });
37
- return {
38
- abort: () => {
39
- aborted = true;
40
- processPool.terminate(opts.chatId, sessionId);
41
- },
42
- };
43
- }
44
- // Fall back to original implementation
45
- return runClaude(this.cliPath, prompt, sessionId, workDir, callbacks, opts);
46
- }
47
- /**
48
- * Get the number of cached entries in the pool.
49
- */
50
- static getCacheSize() {
51
- return processPool?.size() ?? 0;
52
- }
53
- /**
54
- * Get the number of active processes in the pool.
55
- */
56
- static getActiveProcessCount() {
57
- return processPool?.activeCount() ?? 0;
58
- }
59
- /**
60
- * Terminate all cached entries and processes.
61
- */
62
- static terminateAll() {
63
- if (processPool) {
64
- processPool.terminateAll();
65
- }
66
- }
67
- /**
68
- * Destroy the process pool and cleanup resources.
69
- */
70
- static destroy() {
71
- if (processPool) {
72
- processPool.destroy();
73
- processPool = null;
74
- }
75
- }
76
- }
@@ -1,29 +0,0 @@
1
- export interface ClaudeRunCallbacks {
2
- onText: (accumulated: string) => void;
3
- onThinking?: (accumulated: string) => void;
4
- onToolUse?: (toolName: string, toolInput?: Record<string, unknown>) => void;
5
- onComplete: (result: {
6
- success: boolean;
7
- result: string;
8
- accumulated: string;
9
- cost: number;
10
- durationMs: number;
11
- model?: string;
12
- numTurns: number;
13
- toolStats: Record<string, number>;
14
- }) => void;
15
- onError: (error: string) => void;
16
- onSessionId?: (sessionId: string) => void;
17
- }
18
- export interface ClaudeRunOptions {
19
- skipPermissions?: boolean;
20
- permissionMode?: 'default' | 'acceptEdits' | 'plan';
21
- timeoutMs?: number;
22
- model?: string;
23
- chatId?: string;
24
- hookPort?: number;
25
- }
26
- export interface ClaudeRunHandle {
27
- abort: () => void;
28
- }
29
- export declare function runClaude(cliPath: string, prompt: string, sessionId: string | undefined, workDir: string, callbacks: ClaudeRunCallbacks, options?: ClaudeRunOptions): ClaudeRunHandle;
@@ -1,231 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { createInterface } from "node:readline";
3
- import { parseStreamLine, extractTextDelta, extractThinkingDelta, extractResult, } from "./stream-parser.js";
4
- import { isStreamInit, isContentBlockStart, isContentBlockDelta, isContentBlockStop, } from "./types.js";
5
- import { createLogger } from "../logger.js";
6
- const log = createLogger("CliRunner");
7
- export function runClaude(cliPath, prompt, sessionId, workDir, callbacks, options) {
8
- const args = [
9
- "-p",
10
- "--output-format",
11
- "stream-json",
12
- "--verbose",
13
- "--include-partial-messages",
14
- ];
15
- if (options?.skipPermissions) {
16
- args.push("--dangerously-skip-permissions");
17
- }
18
- else if (options?.permissionMode) {
19
- args.push("--permission-mode", options.permissionMode);
20
- }
21
- if (options?.model)
22
- args.push("--model", options.model);
23
- if (sessionId)
24
- args.push("--resume", sessionId);
25
- args.push("--", prompt);
26
- const env = {};
27
- for (const [k, v] of Object.entries(process.env)) {
28
- // Skip CLAUDECODE to prevent nested session detection
29
- if (k === "CLAUDECODE")
30
- continue;
31
- if (v !== undefined)
32
- env[k] = v;
33
- }
34
- if (options?.chatId)
35
- env.CC_IM_CHAT_ID = options.chatId;
36
- if (options?.hookPort)
37
- env.CC_IM_HOOK_PORT = String(options.hookPort);
38
- // 使用 shell: false 直接 spawn,避免 shell 对参数按空格拆分
39
- // (用户 prompt 如 "npm 你好" 在 shell: true 下会被拆成 "npm" 和 "你好",CLI 只收到第一个)
40
- log.info(`Spawning CLI: path=${cliPath}, platform=${process.platform}`);
41
- const child = spawn(cliPath, args, {
42
- cwd: workDir,
43
- stdio: ["ignore", "pipe", "pipe"],
44
- env,
45
- windowsHide: process.platform === "win32",
46
- });
47
- log.info(`Claude CLI: pid=${child.pid}, cwd=${workDir}, session=${sessionId ?? "new"}`);
48
- let accumulated = "";
49
- let accumulatedThinking = "";
50
- let completed = false;
51
- let model = "";
52
- const toolStats = {};
53
- const pendingToolInputs = new Map();
54
- const MAX_TIMEOUT = 2_147_483_647;
55
- const timeoutMs = options?.timeoutMs && options.timeoutMs > 0
56
- ? Math.min(options.timeoutMs, MAX_TIMEOUT)
57
- : 0;
58
- let timeoutHandle = null;
59
- if (timeoutMs > 0) {
60
- timeoutHandle = setTimeout(() => {
61
- if (!completed && !child.killed) {
62
- completed = true;
63
- log.warn(`Claude CLI timeout after ${timeoutMs}ms, killing pid=${child.pid}`);
64
- child.kill("SIGTERM");
65
- callbacks.onError(`执行超时(${timeoutMs}ms),已终止进程`);
66
- }
67
- }, timeoutMs);
68
- }
69
- // stderr 截断:只保留首 4KB + 尾 6KB,减少 I/O 和内存
70
- const MAX_STDERR_HEAD = 4 * 1024;
71
- const MAX_STDERR_TAIL = 6 * 1024;
72
- let stderrHead = "";
73
- let stderrTail = "";
74
- let stderrTotal = 0;
75
- let stderrHeadFull = false;
76
- child.stderr?.on("data", (chunk) => {
77
- const text = chunk.toString();
78
- stderrTotal += text.length;
79
- if (!stderrHeadFull) {
80
- const room = MAX_STDERR_HEAD - stderrHead.length;
81
- if (room > 0) {
82
- stderrHead += text.slice(0, room);
83
- if (stderrHead.length >= MAX_STDERR_HEAD)
84
- stderrHeadFull = true;
85
- }
86
- }
87
- stderrTail += text;
88
- if (stderrTail.length > MAX_STDERR_TAIL) {
89
- stderrTail = stderrTail.slice(-MAX_STDERR_TAIL);
90
- }
91
- });
92
- const rl = createInterface({ input: child.stdout });
93
- rl.on("line", (line) => {
94
- const event = parseStreamLine(line);
95
- if (!event)
96
- return;
97
- if (isStreamInit(event)) {
98
- model = event.model;
99
- callbacks.onSessionId?.(event.session_id);
100
- }
101
- const delta = extractTextDelta(event);
102
- if (delta) {
103
- accumulated += delta.text;
104
- callbacks.onText(accumulated);
105
- return;
106
- }
107
- const thinking = extractThinkingDelta(event);
108
- if (thinking) {
109
- accumulatedThinking += thinking.text;
110
- callbacks.onThinking?.(accumulatedThinking);
111
- return;
112
- }
113
- if (isContentBlockStart(event) &&
114
- event.event.content_block?.type === "tool_use") {
115
- const name = event.event.content_block.name;
116
- if (name)
117
- pendingToolInputs.set(event.event.index, { name, json: "" });
118
- return;
119
- }
120
- if (isContentBlockDelta(event) &&
121
- event.event.delta?.type === "input_json_delta") {
122
- const pending = pendingToolInputs.get(event.event.index);
123
- if (pending)
124
- pending.json += event.event.delta.partial_json ?? "";
125
- return;
126
- }
127
- if (isContentBlockStop(event)) {
128
- const pending = pendingToolInputs.get(event.event.index);
129
- if (pending) {
130
- toolStats[pending.name] = (toolStats[pending.name] || 0) + 1;
131
- let input;
132
- try {
133
- input = JSON.parse(pending.json);
134
- }
135
- catch {
136
- /* empty */
137
- }
138
- callbacks.onToolUse?.(pending.name, input);
139
- pendingToolInputs.delete(event.event.index);
140
- }
141
- return;
142
- }
143
- const result = extractResult(event);
144
- if (result) {
145
- completed = true;
146
- if (timeoutHandle)
147
- clearTimeout(timeoutHandle);
148
- const fullResult = {
149
- ...result,
150
- accumulated,
151
- model,
152
- toolStats,
153
- };
154
- if (!accumulated && fullResult.result)
155
- accumulated = fullResult.result;
156
- callbacks.onComplete(fullResult);
157
- }
158
- });
159
- let exitCode = null;
160
- let rlClosed = false;
161
- let childClosed = false;
162
- const finalize = () => {
163
- if (!rlClosed || !childClosed)
164
- return;
165
- if (timeoutHandle)
166
- clearTimeout(timeoutHandle);
167
- if (!completed) {
168
- if (exitCode !== null && exitCode !== 0) {
169
- let errMsg = "";
170
- if (stderrTotal > 0) {
171
- if (!stderrHeadFull) {
172
- errMsg = stderrHead;
173
- }
174
- else if (stderrTotal <= MAX_STDERR_HEAD + MAX_STDERR_TAIL) {
175
- errMsg = stderrHead + stderrTail.slice(stderrTail.length - (stderrTotal - MAX_STDERR_HEAD));
176
- }
177
- else {
178
- errMsg =
179
- stderrHead +
180
- `\n\n... (省略 ${stderrTotal - MAX_STDERR_HEAD - MAX_STDERR_TAIL} 字节) ...\n\n` +
181
- stderrTail;
182
- }
183
- }
184
- callbacks.onError(errMsg || `Claude CLI exited with code ${exitCode}`);
185
- }
186
- else {
187
- callbacks.onComplete({
188
- success: true,
189
- result: accumulated,
190
- accumulated,
191
- cost: 0,
192
- durationMs: 0,
193
- model,
194
- numTurns: 0,
195
- toolStats,
196
- });
197
- }
198
- }
199
- };
200
- child.on("close", (code) => {
201
- log.info(`Claude CLI closed: exitCode=${code}, pid=${child.pid}`);
202
- exitCode = code;
203
- childClosed = true;
204
- finalize();
205
- });
206
- rl.on("close", () => {
207
- rlClosed = true;
208
- finalize();
209
- });
210
- child.on("error", (err) => {
211
- const errorCode = err.code;
212
- log.error(`Claude CLI spawn error: ${err.message}, code=${errorCode}, path=${cliPath}`);
213
- if (timeoutHandle)
214
- clearTimeout(timeoutHandle);
215
- if (!completed) {
216
- completed = true;
217
- callbacks.onError(`Failed to start Claude CLI: ${err.message}`);
218
- }
219
- childClosed = true;
220
- finalize();
221
- });
222
- return {
223
- abort: () => {
224
- if (timeoutHandle)
225
- clearTimeout(timeoutHandle);
226
- rl.close();
227
- if (!child.killed)
228
- child.kill("SIGTERM");
229
- },
230
- };
231
- }
@@ -1,84 +0,0 @@
1
- export interface ClaudeRunCallbacks {
2
- onText: (accumulated: string) => void;
3
- onThinking?: (accumulated: string) => void;
4
- onToolUse?: (toolName: string, toolInput?: Record<string, unknown>) => void;
5
- onComplete: (result: {
6
- success: boolean;
7
- result: string;
8
- accumulated: string;
9
- cost: number;
10
- durationMs: number;
11
- model?: string;
12
- numTurns: number;
13
- toolStats: Record<string, number>;
14
- }) => void;
15
- onError: (error: string) => void;
16
- onSessionId?: (sessionId: string) => void;
17
- }
18
- export interface ClaudeResult {
19
- success: boolean;
20
- result: string;
21
- accumulated: string;
22
- cost: number;
23
- durationMs: number;
24
- model?: string;
25
- numTurns: number;
26
- toolStats: Record<string, number>;
27
- }
28
- export interface ClaudeRunOptions {
29
- skipPermissions?: boolean;
30
- permissionMode?: 'default' | 'acceptEdits' | 'plan';
31
- timeoutMs?: number;
32
- model?: string;
33
- chatId?: string;
34
- hookPort?: number;
35
- }
36
- /**
37
- * Process pool that manages cached session configurations.
38
- *
39
- * Since Claude CLI doesn't support persistent mode, we use this pool to:
40
- * 1. Cache active sessions for faster resume using --resume
41
- * 2. Track which sessions are actively being used
42
- * 3. Clean up stale entries
43
- *
44
- * The main benefit is that resumed sessions don't need to reload conversation history.
45
- */
46
- export declare class ClaudeProcessPool {
47
- private entries;
48
- private activeProcesses;
49
- private cleanupTimer;
50
- private readonly ttl;
51
- constructor(ttlMs?: number);
52
- /**
53
- * Execute a prompt, reusing cached session if available.
54
- */
55
- execute(userId: string, sessionId: string | undefined, cliPath: string, prompt: string, workDir: string, callbacks: ClaudeRunCallbacks, options?: ClaudeRunOptions): Promise<ClaudeResult>;
56
- /**
57
- * Run a Claude CLI process for a single request.
58
- */
59
- private runProcess;
60
- /**
61
- * Clean up expired entries.
62
- */
63
- private cleanup;
64
- /**
65
- * Terminate the active process for a session.
66
- */
67
- terminate(userId: string, sessionId: string | undefined): void;
68
- /**
69
- * Terminate all active processes and clear cache.
70
- */
71
- terminateAll(): void;
72
- /**
73
- * Get the number of cached entries.
74
- */
75
- size(): number;
76
- /**
77
- * Get the number of active processes.
78
- */
79
- activeCount(): number;
80
- /**
81
- * Destroy the process pool and cleanup resources.
82
- */
83
- destroy(): void;
84
- }
@@ -1,312 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { createInterface } from "node:readline";
3
- import { createLogger } from "../logger.js";
4
- import { parseStreamLine, extractTextDelta, extractThinkingDelta, extractResult, } from "./stream-parser.js";
5
- import { isStreamInit, isContentBlockStart, isContentBlockDelta, isContentBlockStop, } from "./types.js";
6
- const log = createLogger("ProcessPool");
7
- /**
8
- * Process pool that manages cached session configurations.
9
- *
10
- * Since Claude CLI doesn't support persistent mode, we use this pool to:
11
- * 1. Cache active sessions for faster resume using --resume
12
- * 2. Track which sessions are actively being used
13
- * 3. Clean up stale entries
14
- *
15
- * The main benefit is that resumed sessions don't need to reload conversation history.
16
- */
17
- export class ClaudeProcessPool {
18
- entries = new Map();
19
- activeProcesses = new Map();
20
- cleanupTimer = null;
21
- ttl;
22
- constructor(ttlMs = 2 * 60 * 1000) {
23
- this.ttl = ttlMs;
24
- log.info(`Process pool created with TTL: ${ttlMs}ms`);
25
- // Periodic cleanup of expired entries
26
- this.cleanupTimer = setInterval(() => {
27
- this.cleanup();
28
- }, 60 * 1000); // Every minute
29
- }
30
- /**
31
- * Execute a prompt, reusing cached session if available.
32
- */
33
- async execute(userId, sessionId, cliPath, prompt, workDir, callbacks, options) {
34
- const key = `${userId}:${sessionId || "default"}`;
35
- // Update cache entry (tracks active sessions)
36
- const entry = this.entries.get(key);
37
- if (entry) {
38
- entry.lastUsed = Date.now();
39
- }
40
- else {
41
- this.entries.set(key, { lastUsed: Date.now() });
42
- }
43
- // Check if there's an active process for this session
44
- const activePid = this.activeProcesses.get(key);
45
- if (activePid && !activePid.killed) {
46
- log.info(`Session has active process: key=${key}, pid=${activePid.pid}`);
47
- // Wait a bit for the previous process to complete
48
- await new Promise(resolve => setTimeout(resolve, 100));
49
- }
50
- // Run the Claude CLI process
51
- return this.runProcess(key, cliPath, prompt, sessionId, workDir, callbacks, options || {});
52
- }
53
- /**
54
- * Run a Claude CLI process for a single request.
55
- */
56
- runProcess(key, cliPath, prompt, sessionId, workDir, callbacks, options) {
57
- return new Promise((resolve, reject) => {
58
- const args = [
59
- "-p",
60
- "--output-format",
61
- "stream-json",
62
- "--verbose",
63
- "--include-partial-messages",
64
- ];
65
- if (options.skipPermissions) {
66
- args.push("--dangerously-skip-permissions");
67
- }
68
- else if (options.permissionMode) {
69
- args.push("--permission-mode", options.permissionMode);
70
- }
71
- if (options.model)
72
- args.push("--model", options.model);
73
- if (sessionId)
74
- args.push("--resume", sessionId);
75
- args.push("--", prompt);
76
- // Environment setup
77
- const env = {};
78
- for (const [k, v] of Object.entries(process.env)) {
79
- if (k === "CLAUDECODE")
80
- continue;
81
- if (v !== undefined)
82
- env[k] = v;
83
- }
84
- if (options.chatId)
85
- env.CC_IM_CHAT_ID = options.chatId;
86
- if (options.hookPort)
87
- env.CC_IM_HOOK_PORT = String(options.hookPort);
88
- // 使用 shell: false 直接 spawn,避免 shell 对参数按空格拆分
89
- // (用户 prompt 如 "npm 你好" 在 shell: true 下会被拆成 "npm" 和 "你好",CLI 只收到第一个)
90
- const child = spawn(cliPath, args, {
91
- cwd: workDir,
92
- stdio: ["ignore", "pipe", "pipe"],
93
- env,
94
- windowsHide: process.platform === "win32",
95
- });
96
- log.info(`Started process: pid=${child.pid}, key=${key}`);
97
- // Track active process
98
- this.activeProcesses.set(key, child);
99
- // State tracking
100
- let accumulated = "";
101
- let accumulatedThinking = "";
102
- let model = "";
103
- const toolStats = {};
104
- const pendingToolInputs = new Map();
105
- const startTime = Date.now();
106
- // stderr 截断:只保留首 4KB + 尾 6KB
107
- const MAX_STDERR_HEAD = 4 * 1024;
108
- const MAX_STDERR_TAIL = 6 * 1024;
109
- let stderrHead = "";
110
- let stderrTail = "";
111
- let stderrTotal = 0;
112
- let stderrHeadFull = false;
113
- child.stderr?.on("data", (chunk) => {
114
- const text = chunk.toString();
115
- stderrTotal += text.length;
116
- if (!stderrHeadFull) {
117
- const room = MAX_STDERR_HEAD - stderrHead.length;
118
- if (room > 0) {
119
- stderrHead += text.slice(0, room);
120
- if (stderrHead.length >= MAX_STDERR_HEAD)
121
- stderrHeadFull = true;
122
- }
123
- }
124
- stderrTail += text;
125
- if (stderrTail.length > MAX_STDERR_TAIL) {
126
- stderrTail = stderrTail.slice(-MAX_STDERR_TAIL);
127
- }
128
- });
129
- const rl = createInterface({ input: child.stdout });
130
- rl.on("line", (line) => {
131
- const event = parseStreamLine(line);
132
- if (!event)
133
- return;
134
- if (isStreamInit(event)) {
135
- model = event.model;
136
- callbacks.onSessionId?.(event.session_id);
137
- return;
138
- }
139
- const delta = extractTextDelta(event);
140
- if (delta) {
141
- accumulated += delta.text;
142
- callbacks.onText(accumulated);
143
- return;
144
- }
145
- const thinking = extractThinkingDelta(event);
146
- if (thinking) {
147
- accumulatedThinking += thinking.text;
148
- callbacks.onThinking?.(accumulatedThinking);
149
- return;
150
- }
151
- if (isContentBlockStart(event) &&
152
- event.event.content_block?.type === "tool_use") {
153
- const name = event.event.content_block.name;
154
- if (name)
155
- pendingToolInputs.set(event.event.index, { name, json: "" });
156
- return;
157
- }
158
- if (isContentBlockDelta(event) &&
159
- event.event.delta?.type === "input_json_delta") {
160
- const pending = pendingToolInputs.get(event.event.index);
161
- if (pending)
162
- pending.json += event.event.delta.partial_json ?? "";
163
- return;
164
- }
165
- if (isContentBlockStop(event)) {
166
- const pending = pendingToolInputs.get(event.event.index);
167
- if (pending) {
168
- toolStats[pending.name] = (toolStats[pending.name] || 0) + 1;
169
- let input;
170
- try {
171
- input = JSON.parse(pending.json);
172
- }
173
- catch {
174
- /* empty */
175
- }
176
- callbacks.onToolUse?.(pending.name, input);
177
- pendingToolInputs.delete(event.event.index);
178
- }
179
- return;
180
- }
181
- const result = extractResult(event);
182
- if (result) {
183
- const fullResult = {
184
- ...result,
185
- accumulated,
186
- model,
187
- toolStats,
188
- };
189
- if (!accumulated && fullResult.result) {
190
- accumulated = fullResult.result;
191
- }
192
- callbacks.onComplete(fullResult);
193
- resolve(fullResult);
194
- }
195
- });
196
- let exitCode = null;
197
- let rlClosed = false;
198
- let childClosed = false;
199
- let resolved = false;
200
- const finalize = () => {
201
- if (!rlClosed || !childClosed || resolved)
202
- return;
203
- this.activeProcesses.delete(key);
204
- resolved = true;
205
- if (exitCode !== null && exitCode !== 0) {
206
- let errorMsg = "";
207
- if (stderrTotal > 0) {
208
- if (!stderrHeadFull) {
209
- errorMsg = stderrHead;
210
- }
211
- else if (stderrTotal <= MAX_STDERR_HEAD + MAX_STDERR_TAIL) {
212
- errorMsg =
213
- stderrHead +
214
- stderrTail.slice(stderrTail.length - (stderrTotal - MAX_STDERR_HEAD));
215
- }
216
- else {
217
- errorMsg =
218
- stderrHead +
219
- `\n\n... (省略 ${stderrTotal - MAX_STDERR_HEAD - MAX_STDERR_TAIL} 字节) ...\n\n` +
220
- stderrTail;
221
- }
222
- }
223
- const msg = errorMsg || `Claude CLI exited with code ${exitCode}`;
224
- callbacks.onError(msg);
225
- reject(new Error(msg));
226
- }
227
- // If exitCode is 0 and we haven't resolved yet, the result was already sent
228
- // via the extractResult handler. This is just cleanup.
229
- };
230
- child.on("close", (code) => {
231
- log.info(`Process closed: code=${code}, pid=${child.pid}, key=${key}`);
232
- exitCode = code;
233
- childClosed = true;
234
- finalize();
235
- });
236
- rl.on("close", () => {
237
- rlClosed = true;
238
- finalize();
239
- });
240
- child.on("error", (err) => {
241
- log.error(`Process error: ${err.message}, pid=${child.pid}, key=${key}`);
242
- this.activeProcesses.delete(key);
243
- const errorMsg = `Failed to start Claude CLI: ${err.message}`;
244
- callbacks.onError(errorMsg);
245
- reject(new Error(errorMsg));
246
- });
247
- });
248
- }
249
- /**
250
- * Clean up expired entries.
251
- */
252
- cleanup() {
253
- const now = Date.now();
254
- let cleaned = 0;
255
- for (const [key, entry] of this.entries.entries()) {
256
- if (now - entry.lastUsed > this.ttl) {
257
- this.entries.delete(key);
258
- cleaned++;
259
- }
260
- }
261
- if (cleaned > 0) {
262
- log.info(`Cleaned up ${cleaned} expired entries, ${this.entries.size} remaining`);
263
- }
264
- }
265
- /**
266
- * Terminate the active process for a session.
267
- */
268
- terminate(userId, sessionId) {
269
- const key = `${userId}:${sessionId || "default"}`;
270
- const child = this.activeProcesses.get(key);
271
- if (child && !child.killed) {
272
- child.kill("SIGTERM");
273
- this.activeProcesses.delete(key);
274
- }
275
- // Also remove from cache
276
- this.entries.delete(key);
277
- }
278
- /**
279
- * Terminate all active processes and clear cache.
280
- */
281
- terminateAll() {
282
- for (const child of this.activeProcesses.values()) {
283
- if (!child.killed) {
284
- child.kill("SIGTERM");
285
- }
286
- }
287
- this.activeProcesses.clear();
288
- this.entries.clear();
289
- }
290
- /**
291
- * Get the number of cached entries.
292
- */
293
- size() {
294
- return this.entries.size;
295
- }
296
- /**
297
- * Get the number of active processes.
298
- */
299
- activeCount() {
300
- return this.activeProcesses.size;
301
- }
302
- /**
303
- * Destroy the process pool and cleanup resources.
304
- */
305
- destroy() {
306
- if (this.cleanupTimer) {
307
- clearInterval(this.cleanupTimer);
308
- this.cleanupTimer = null;
309
- }
310
- this.terminateAll();
311
- }
312
- }