@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 +13 -5
- package/README.zh-CN.md +12 -4
- package/dist/adapters/registry.js +2 -13
- package/dist/config-web-page-script.js +0 -1
- package/dist/config-web.js +0 -3
- package/dist/config.d.ts +0 -3
- package/dist/config.js +2 -51
- package/dist/service-control.test.js +12 -1
- package/dist/shared/ai-task.js +1 -1
- package/dist/shared/ai-task.test.js +0 -1
- package/package.json +1 -1
- package/dist/adapters/claude-adapter.d.ts +0 -26
- package/dist/adapters/claude-adapter.js +0 -76
- package/dist/claude/cli-runner.d.ts +0 -29
- package/dist/claude/cli-runner.js +0 -231
- package/dist/claude/process-pool.d.ts +0 -84
- package/dist/claude/process-pool.js +0 -312
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
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
}
|
package/dist/config-web.js
CHANGED
|
@@ -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
|
-
//
|
|
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'
|
|
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"));
|
package/dist/shared/ai-task.js
CHANGED
|
@@ -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'
|
|
239
|
+
...(toolId === 'claude' ? {} : { hookPort: config.hookPort }),
|
|
240
240
|
...(toolId === 'codex' && config.codexProxy ? { proxy: config.codexProxy } : {}),
|
|
241
241
|
});
|
|
242
242
|
return activeHandle;
|
package/package.json
CHANGED
|
@@ -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
|
-
}
|