@wu529778790/open-im 1.6.8-beta.2 → 1.6.9-alpha.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/claude-cli-adapter.d.ts +13 -0
- package/dist/adapters/claude-cli-adapter.js +56 -0
- package/dist/adapters/codebuddy-adapter.d.ts +2 -1
- package/dist/adapters/codebuddy-adapter.js +29 -4
- package/dist/adapters/codex-adapter.d.ts +3 -1
- package/dist/adapters/codex-adapter.js +8 -3
- package/dist/adapters/registry.js +10 -14
- package/dist/adapters/tool-adapter.interface.d.ts +3 -1
- package/dist/claude-cli/cli-runner.d.ts +44 -0
- package/dist/claude-cli/cli-runner.js +172 -0
- package/dist/cli-persistent/claude-cli-persistent-runner.d.ts +6 -0
- package/dist/cli-persistent/claude-cli-persistent-runner.js +108 -0
- package/dist/cli-persistent/cli-wrapper.d.ts +9 -0
- package/dist/cli-persistent/cli-wrapper.js +84 -0
- package/dist/cli-persistent/codebuddy-persistent-runner.d.ts +6 -0
- package/dist/cli-persistent/codebuddy-persistent-runner.js +90 -0
- package/dist/cli-persistent/codex-persistent-runner.d.ts +5 -0
- package/dist/cli-persistent/codex-persistent-runner.js +97 -0
- package/dist/cli-persistent/session.d.ts +40 -0
- package/dist/cli-persistent/session.js +109 -0
- package/dist/codebuddy/cli-runner.d.ts +11 -0
- package/dist/codebuddy/cli-runner.js +106 -8
- package/dist/codex/cli-runner.d.ts +24 -0
- package/dist/codex/cli-runner.js +113 -6
- package/dist/commands/handler.d.ts +3 -4
- package/dist/commands/handler.js +17 -58
- package/dist/config-web-page-script.js +0 -1
- package/dist/config-web.js +6 -6
- package/dist/config.d.ts +11 -5
- package/dist/config.js +8 -52
- package/dist/feishu/event-handler.js +1 -1
- package/dist/feishu/message-sender.js +4 -4
- package/dist/hook/permission-server.d.ts +2 -2
- package/dist/hook/permission-server.js +41 -24
- package/dist/index.js +2 -6
- package/dist/permission-mode/session-mode.js +18 -3
- package/dist/permission-mode/types.d.ts +2 -1
- package/dist/permission-mode/types.js +26 -12
- package/dist/qq/event-handler.test.js +1 -1
- package/dist/service-control.test.js +12 -1
- package/dist/shared/ai-task.js +25 -19
- package/dist/shared/ai-task.test.js +2 -3
- package/dist/shared/system-messages.js +3 -3
- package/dist/shared/utils.js +2 -0
- package/dist/telegram/event-handler.js +1 -1
- package/dist/telegram/message-sender.js +1 -1
- package/dist/wechat/event-handler.js +1 -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
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code CLI Adapter - run tasks through Claude Code CLI (`claude -p`)
|
|
3
|
+
* Uses same permission hook as Codex; supports IM permission when hook is configured.
|
|
4
|
+
* usePersistentCLI 时采用 Happy 式:直连 spawn 真实 CLI(不用 wrapper),一会话用 -r 延续,stderr 推到 IM。
|
|
5
|
+
*/
|
|
6
|
+
import type { RunCallbacks, RunHandle, RunOptions, ToolAdapter } from './tool-adapter.interface.js';
|
|
7
|
+
export declare class ClaudeCLIAdapter implements ToolAdapter {
|
|
8
|
+
private cliPath;
|
|
9
|
+
private usePersistentCLI;
|
|
10
|
+
readonly toolId = "claude-cli";
|
|
11
|
+
constructor(cliPath: string, usePersistentCLI?: boolean);
|
|
12
|
+
run(prompt: string, sessionId: string | undefined, workDir: string, callbacks: RunCallbacks, options?: RunOptions): RunHandle;
|
|
13
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code CLI Adapter - run tasks through Claude Code CLI (`claude -p`)
|
|
3
|
+
* Uses same permission hook as Codex; supports IM permission when hook is configured.
|
|
4
|
+
* usePersistentCLI 时采用 Happy 式:直连 spawn 真实 CLI(不用 wrapper),一会话用 -r 延续,stderr 推到 IM。
|
|
5
|
+
*/
|
|
6
|
+
import { runClaudeCLI } from '../claude-cli/cli-runner.js';
|
|
7
|
+
export class ClaudeCLIAdapter {
|
|
8
|
+
cliPath;
|
|
9
|
+
usePersistentCLI;
|
|
10
|
+
toolId = 'claude-cli';
|
|
11
|
+
constructor(cliPath, usePersistentCLI = false) {
|
|
12
|
+
this.cliPath = cliPath;
|
|
13
|
+
this.usePersistentCLI = usePersistentCLI;
|
|
14
|
+
}
|
|
15
|
+
run(prompt, sessionId, workDir, callbacks, options) {
|
|
16
|
+
const opts = {
|
|
17
|
+
skipPermissions: options?.skipPermissions,
|
|
18
|
+
permissionMode: options?.permissionMode,
|
|
19
|
+
timeoutMs: options?.timeoutMs,
|
|
20
|
+
model: options?.model,
|
|
21
|
+
chatId: options?.chatId,
|
|
22
|
+
hookPort: options?.hookPort,
|
|
23
|
+
};
|
|
24
|
+
const claudeCallbacks = {
|
|
25
|
+
onText: callbacks.onText,
|
|
26
|
+
onThinking: callbacks.onThinking,
|
|
27
|
+
onToolUse: callbacks.onToolUse,
|
|
28
|
+
onComplete: (raw) => {
|
|
29
|
+
const result = {
|
|
30
|
+
success: raw.success,
|
|
31
|
+
result: raw.result,
|
|
32
|
+
accumulated: raw.accumulated,
|
|
33
|
+
cost: raw.cost,
|
|
34
|
+
durationMs: raw.durationMs,
|
|
35
|
+
model: raw.model,
|
|
36
|
+
numTurns: raw.numTurns,
|
|
37
|
+
toolStats: raw.toolStats,
|
|
38
|
+
};
|
|
39
|
+
callbacks.onComplete(result);
|
|
40
|
+
},
|
|
41
|
+
onError: (err) => {
|
|
42
|
+
const msg = typeof err === 'string' ? err : String(err);
|
|
43
|
+
const friendly = msg.includes('Authentication') || msg.includes('login') || msg.includes('auth')
|
|
44
|
+
? 'Claude CLI 需要先登录。请在终端运行 claude auth login。'
|
|
45
|
+
: msg.includes('not found') || msg.includes('ENOENT')
|
|
46
|
+
? '未找到 Claude CLI,请安装并确保在 PATH 中(如 npm i -g @anthropic-ai/claude-code)。'
|
|
47
|
+
: msg;
|
|
48
|
+
callbacks.onError(friendly);
|
|
49
|
+
},
|
|
50
|
+
onSessionId: callbacks.onSessionId,
|
|
51
|
+
onSessionInvalid: callbacks.onSessionInvalid,
|
|
52
|
+
onStderr: callbacks.onStderr,
|
|
53
|
+
};
|
|
54
|
+
return runClaudeCLI(this.cliPath, prompt, sessionId, workDir, claudeCallbacks, opts);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { RunCallbacks, RunHandle, RunOptions, ToolAdapter } from './tool-adapter.interface.js';
|
|
2
2
|
export declare class CodeBuddyAdapter implements ToolAdapter {
|
|
3
3
|
private cliPath;
|
|
4
|
+
private usePersistentCLI;
|
|
4
5
|
readonly toolId = "codebuddy";
|
|
5
|
-
constructor(cliPath: string);
|
|
6
|
+
constructor(cliPath: string, usePersistentCLI?: boolean);
|
|
6
7
|
run(prompt: string, sessionId: string | undefined, workDir: string, callbacks: RunCallbacks, options?: RunOptions): RunHandle;
|
|
7
8
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { runCodeBuddy } from '../codebuddy/cli-runner.js';
|
|
2
2
|
export class CodeBuddyAdapter {
|
|
3
3
|
cliPath;
|
|
4
|
+
usePersistentCLI;
|
|
4
5
|
toolId = 'codebuddy';
|
|
5
|
-
constructor(cliPath) {
|
|
6
|
+
constructor(cliPath, usePersistentCLI = false) {
|
|
6
7
|
this.cliPath = cliPath;
|
|
8
|
+
this.usePersistentCLI = usePersistentCLI;
|
|
7
9
|
}
|
|
8
10
|
run(prompt, sessionId, workDir, callbacks, options) {
|
|
9
|
-
|
|
11
|
+
const codebuddyCallbacks = {
|
|
10
12
|
onText: callbacks.onText,
|
|
11
13
|
onThinking: callbacks.onThinking,
|
|
12
14
|
onToolUse: callbacks.onToolUse,
|
|
@@ -37,11 +39,34 @@ export class CodeBuddyAdapter {
|
|
|
37
39
|
},
|
|
38
40
|
onSessionId: callbacks.onSessionId,
|
|
39
41
|
onSessionInvalid: callbacks.onSessionInvalid,
|
|
40
|
-
|
|
42
|
+
onStderr: callbacks.onStderr,
|
|
43
|
+
};
|
|
44
|
+
const opts = {
|
|
41
45
|
skipPermissions: options?.skipPermissions,
|
|
42
46
|
permissionMode: options?.permissionMode,
|
|
43
47
|
timeoutMs: options?.timeoutMs,
|
|
44
48
|
model: options?.model,
|
|
45
|
-
}
|
|
49
|
+
};
|
|
50
|
+
return runCodeBuddy(this.cliPath, prompt, sessionId, workDir, {
|
|
51
|
+
onText: codebuddyCallbacks.onText,
|
|
52
|
+
onThinking: codebuddyCallbacks.onThinking,
|
|
53
|
+
onToolUse: codebuddyCallbacks.onToolUse,
|
|
54
|
+
onComplete: (raw) => {
|
|
55
|
+
const result = {
|
|
56
|
+
success: raw.success,
|
|
57
|
+
result: raw.result,
|
|
58
|
+
accumulated: raw.accumulated,
|
|
59
|
+
cost: raw.cost,
|
|
60
|
+
durationMs: raw.durationMs,
|
|
61
|
+
model: raw.model,
|
|
62
|
+
numTurns: raw.numTurns,
|
|
63
|
+
toolStats: raw.toolStats,
|
|
64
|
+
};
|
|
65
|
+
callbacks.onComplete(result);
|
|
66
|
+
},
|
|
67
|
+
onError: codebuddyCallbacks.onError,
|
|
68
|
+
onSessionId: codebuddyCallbacks.onSessionId,
|
|
69
|
+
onSessionInvalid: codebuddyCallbacks.onSessionInvalid,
|
|
70
|
+
}, opts);
|
|
46
71
|
}
|
|
47
72
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Codex Adapter - run tasks through OpenAI Codex CLI (`codex exec`)
|
|
3
|
+
* usePersistentCLI 时采用 Happy 式:直连 spawn 真实 CLI(不用 wrapper),一会话用 --resume 延续,stderr 推到 IM。
|
|
3
4
|
*/
|
|
4
5
|
import type { RunCallbacks, RunHandle, RunOptions, ToolAdapter } from "./tool-adapter.interface.js";
|
|
5
6
|
export declare class CodexAdapter implements ToolAdapter {
|
|
6
7
|
private cliPath;
|
|
8
|
+
private usePersistentCLI;
|
|
7
9
|
readonly toolId = "codex";
|
|
8
|
-
constructor(cliPath: string);
|
|
10
|
+
constructor(cliPath: string, usePersistentCLI?: boolean);
|
|
9
11
|
run(prompt: string, sessionId: string | undefined, workDir: string, callbacks: RunCallbacks, options?: RunOptions): RunHandle;
|
|
10
12
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Codex Adapter - run tasks through OpenAI Codex CLI (`codex exec`)
|
|
3
|
+
* usePersistentCLI 时采用 Happy 式:直连 spawn 真实 CLI(不用 wrapper),一会话用 --resume 延续,stderr 推到 IM。
|
|
3
4
|
*/
|
|
4
5
|
import { runCodex } from "../codex/cli-runner.js";
|
|
5
6
|
export class CodexAdapter {
|
|
6
7
|
cliPath;
|
|
8
|
+
usePersistentCLI;
|
|
7
9
|
toolId = "codex";
|
|
8
|
-
constructor(cliPath) {
|
|
10
|
+
constructor(cliPath, usePersistentCLI = false) {
|
|
9
11
|
this.cliPath = cliPath;
|
|
12
|
+
this.usePersistentCLI = usePersistentCLI;
|
|
10
13
|
}
|
|
11
14
|
run(prompt, sessionId, workDir, callbacks, options) {
|
|
12
15
|
const opts = {
|
|
@@ -18,7 +21,7 @@ export class CodexAdapter {
|
|
|
18
21
|
hookPort: options?.hookPort,
|
|
19
22
|
proxy: options?.proxy,
|
|
20
23
|
};
|
|
21
|
-
|
|
24
|
+
const codexCallbacks = {
|
|
22
25
|
onText: callbacks.onText,
|
|
23
26
|
onThinking: callbacks.onThinking,
|
|
24
27
|
onToolUse: callbacks.onToolUse,
|
|
@@ -54,6 +57,8 @@ export class CodexAdapter {
|
|
|
54
57
|
},
|
|
55
58
|
onSessionId: callbacks.onSessionId,
|
|
56
59
|
onSessionInvalid: callbacks.onSessionInvalid,
|
|
57
|
-
|
|
60
|
+
onStderr: callbacks.onStderr,
|
|
61
|
+
};
|
|
62
|
+
return runCodex(this.cliPath, prompt, sessionId, workDir, codexCallbacks, opts);
|
|
58
63
|
}
|
|
59
64
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getConfiguredAiCommands } from '../config.js';
|
|
2
|
-
import { ClaudeAdapter } from './claude-adapter.js';
|
|
3
2
|
import { ClaudeSDKAdapter } from './claude-sdk-adapter.js';
|
|
3
|
+
import { ClaudeCLIAdapter } from './claude-cli-adapter.js';
|
|
4
4
|
import { CodexAdapter } from './codex-adapter.js';
|
|
5
5
|
import { CodeBuddyAdapter } from './codebuddy-adapter.js';
|
|
6
6
|
import { createLogger } from '../logger.js';
|
|
@@ -10,27 +10,24 @@ export function initAdapters(config) {
|
|
|
10
10
|
adapters.clear();
|
|
11
11
|
for (const aiCommand of getConfiguredAiCommands(config)) {
|
|
12
12
|
if (aiCommand === 'claude') {
|
|
13
|
-
if (config.
|
|
14
|
-
log.info('Claude
|
|
15
|
-
adapters.set('claude', new
|
|
13
|
+
if (config.claudeUseCLI) {
|
|
14
|
+
log.info(config.usePersistentCLI ? 'Claude CLI adapter enabled (Happy-style: direct spawn, session resume)' : 'Claude CLI adapter enabled (IM permission + console output)');
|
|
15
|
+
adapters.set('claude', new ClaudeCLIAdapter(config.claudeCliPath, config.usePersistentCLI));
|
|
16
16
|
}
|
|
17
17
|
else {
|
|
18
|
-
log.info('Claude
|
|
19
|
-
adapters.set('claude', new
|
|
20
|
-
useProcessPool: true,
|
|
21
|
-
idleTimeoutMs: 2 * 60 * 1000,
|
|
22
|
-
}));
|
|
18
|
+
log.info('Claude Agent SDK adapter enabled');
|
|
19
|
+
adapters.set('claude', new ClaudeSDKAdapter());
|
|
23
20
|
}
|
|
24
21
|
continue;
|
|
25
22
|
}
|
|
26
23
|
if (aiCommand === 'codex') {
|
|
27
|
-
log.info('Codex CLI adapter enabled');
|
|
28
|
-
adapters.set('codex', new CodexAdapter(config.codexCliPath));
|
|
24
|
+
log.info(config.usePersistentCLI ? 'Codex CLI adapter enabled (Happy-style: direct spawn, session resume)' : 'Codex CLI adapter enabled');
|
|
25
|
+
adapters.set('codex', new CodexAdapter(config.codexCliPath, config.usePersistentCLI));
|
|
29
26
|
continue;
|
|
30
27
|
}
|
|
31
28
|
if (aiCommand === 'codebuddy') {
|
|
32
|
-
log.info('CodeBuddy CLI adapter enabled');
|
|
33
|
-
adapters.set('codebuddy', new CodeBuddyAdapter(config.codebuddyCliPath));
|
|
29
|
+
log.info(config.usePersistentCLI ? 'CodeBuddy CLI adapter enabled (Happy-style: direct spawn, session resume)' : 'CodeBuddy CLI adapter enabled');
|
|
30
|
+
adapters.set('codebuddy', new CodeBuddyAdapter(config.codebuddyCliPath, config.usePersistentCLI));
|
|
34
31
|
}
|
|
35
32
|
}
|
|
36
33
|
}
|
|
@@ -38,7 +35,6 @@ export function getAdapter(aiCommand) {
|
|
|
38
35
|
return adapters.get(aiCommand);
|
|
39
36
|
}
|
|
40
37
|
export function cleanupAdapters() {
|
|
41
|
-
ClaudeAdapter.destroy();
|
|
42
38
|
ClaudeSDKAdapter.destroy();
|
|
43
39
|
adapters.clear();
|
|
44
40
|
}
|
|
@@ -20,10 +20,12 @@ export interface RunCallbacks {
|
|
|
20
20
|
onSessionId?: (sessionId: string) => void;
|
|
21
21
|
/** SDK 报 "No conversation found" 时调用,用于清除无效 session */
|
|
22
22
|
onSessionInvalid?: () => void;
|
|
23
|
+
/** 常驻 CLI 模式:子进程 stderr(日志等)可推到 IM 展示 */
|
|
24
|
+
onStderr?: (data: string) => void;
|
|
23
25
|
}
|
|
24
26
|
export interface RunOptions {
|
|
25
27
|
skipPermissions?: boolean;
|
|
26
|
-
/** Claude --permission-mode: default | acceptEdits | plan(
|
|
28
|
+
/** Claude --permission-mode: default | acceptEdits | plan(bypassPermissions 时用 skipPermissions) */
|
|
27
29
|
permissionMode?: 'default' | 'acceptEdits' | 'plan';
|
|
28
30
|
timeoutMs?: number;
|
|
29
31
|
model?: string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code CLI runner - spawn `claude -p` with hook for IM permission.
|
|
3
|
+
* Uses same permission-server as Codex; supports --permission-mode and HTTP PermissionRequest hook via --settings.
|
|
4
|
+
*/
|
|
5
|
+
export interface ClaudeCLIRunCallbacks {
|
|
6
|
+
onText: (accumulated: string) => void;
|
|
7
|
+
onThinking?: (accumulated: string) => void;
|
|
8
|
+
onToolUse?: (toolName: string, toolInput?: Record<string, unknown>) => void;
|
|
9
|
+
onComplete: (result: {
|
|
10
|
+
success: boolean;
|
|
11
|
+
result: string;
|
|
12
|
+
accumulated: string;
|
|
13
|
+
cost: number;
|
|
14
|
+
durationMs: number;
|
|
15
|
+
model?: string;
|
|
16
|
+
numTurns: number;
|
|
17
|
+
toolStats: Record<string, number>;
|
|
18
|
+
}) => void;
|
|
19
|
+
onError: (error: string) => void;
|
|
20
|
+
onSessionId?: (sessionId: string) => void;
|
|
21
|
+
onSessionInvalid?: () => void;
|
|
22
|
+
onStderr?: (data: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface ClaudeCLIRunOptions {
|
|
25
|
+
skipPermissions?: boolean;
|
|
26
|
+
permissionMode?: 'default' | 'acceptEdits' | 'plan';
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
model?: string;
|
|
29
|
+
chatId?: string;
|
|
30
|
+
hookPort?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface ClaudeCLIRunHandle {
|
|
33
|
+
abort: () => void;
|
|
34
|
+
}
|
|
35
|
+
/** 供常驻包装进程使用:返回 spawn 参数(prompt 已在 args 中)。调用方需在 turn 结束后 unlink settingsPath。 */
|
|
36
|
+
export interface ClaudeCLISpawnPayload {
|
|
37
|
+
executable: string;
|
|
38
|
+
args: string[];
|
|
39
|
+
cwd: string;
|
|
40
|
+
env: Record<string, string>;
|
|
41
|
+
settingsPath?: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function buildClaudeSpawnPayload(cliPath: string, prompt: string, sessionId: string | undefined, workDir: string, options?: ClaudeCLIRunOptions): ClaudeCLISpawnPayload;
|
|
44
|
+
export declare function runClaudeCLI(cliPath: string, prompt: string, sessionId: string | undefined, workDir: string, callbacks: ClaudeCLIRunCallbacks, options?: ClaudeCLIRunOptions): ClaudeCLIRunHandle;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code CLI runner - spawn `claude -p` with hook for IM permission.
|
|
3
|
+
* Uses same permission-server as Codex; supports --permission-mode and HTTP PermissionRequest hook via --settings.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { createLogger } from '../logger.js';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
const log = createLogger('ClaudeCLI');
|
|
11
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
12
|
+
function buildClaudeArgs(prompt, sessionId, workDir, options) {
|
|
13
|
+
const args = ['-p', '--output-format', 'text'];
|
|
14
|
+
if (options?.model) {
|
|
15
|
+
args.push('--model', options.model);
|
|
16
|
+
}
|
|
17
|
+
if (options?.skipPermissions) {
|
|
18
|
+
args.push('--dangerously-skip-permissions');
|
|
19
|
+
}
|
|
20
|
+
else if (options?.permissionMode === 'plan') {
|
|
21
|
+
args.push('--permission-mode', 'plan');
|
|
22
|
+
}
|
|
23
|
+
else if (options?.permissionMode === 'acceptEdits') {
|
|
24
|
+
args.push('--permission-mode', 'acceptEdits');
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
args.push('--permission-mode', 'default');
|
|
28
|
+
}
|
|
29
|
+
if (sessionId) {
|
|
30
|
+
args.push('-r', sessionId);
|
|
31
|
+
}
|
|
32
|
+
let settingsPath;
|
|
33
|
+
if (options?.chatId && options?.hookPort && !options?.skipPermissions) {
|
|
34
|
+
const baseDir = join(tmpdir(), 'open-im-claude-hooks');
|
|
35
|
+
mkdirSync(baseDir, { recursive: true });
|
|
36
|
+
settingsPath = join(baseDir, `hook-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.json`);
|
|
37
|
+
const hookUrl = `http://127.0.0.1:${options.hookPort}/permission?chatId=${encodeURIComponent(options.chatId)}`;
|
|
38
|
+
const settings = {
|
|
39
|
+
hooks: {
|
|
40
|
+
PermissionRequest: [
|
|
41
|
+
{
|
|
42
|
+
matcher: '*',
|
|
43
|
+
hooks: [{ type: 'http', url: hookUrl }],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
writeFileSync(settingsPath, JSON.stringify(settings), 'utf-8');
|
|
49
|
+
args.push('--settings', settingsPath);
|
|
50
|
+
}
|
|
51
|
+
args.push(prompt);
|
|
52
|
+
return { args, settingsPath };
|
|
53
|
+
}
|
|
54
|
+
export function buildClaudeSpawnPayload(cliPath, prompt, sessionId, workDir, options) {
|
|
55
|
+
const { args, settingsPath } = buildClaudeArgs(prompt, sessionId, workDir, options);
|
|
56
|
+
const env = {};
|
|
57
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
58
|
+
if (v !== undefined)
|
|
59
|
+
env[k] = v;
|
|
60
|
+
}
|
|
61
|
+
return { executable: cliPath, args, cwd: workDir, env, settingsPath };
|
|
62
|
+
}
|
|
63
|
+
export function runClaudeCLI(cliPath, prompt, sessionId, workDir, callbacks, options) {
|
|
64
|
+
const payload = buildClaudeSpawnPayload(cliPath, prompt, sessionId, workDir, options);
|
|
65
|
+
const { executable, args, cwd: workDirResolved, env, settingsPath } = payload;
|
|
66
|
+
const timeoutMs = options?.timeoutMs && options.timeoutMs > 0
|
|
67
|
+
? Math.min(options.timeoutMs, MAX_TIMEOUT_MS)
|
|
68
|
+
: 600_000;
|
|
69
|
+
log.info(`Spawning Claude CLI: path=${cliPath}, cwd=${workDirResolved}, session=${sessionId ?? 'new'}`);
|
|
70
|
+
const child = spawn(executable, args, {
|
|
71
|
+
cwd: workDirResolved,
|
|
72
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
73
|
+
env,
|
|
74
|
+
windowsHide: process.platform === 'win32',
|
|
75
|
+
});
|
|
76
|
+
let accumulated = '';
|
|
77
|
+
let completed = false;
|
|
78
|
+
const toolStats = {};
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
let timeoutHandle = null;
|
|
81
|
+
const clearTimers = () => {
|
|
82
|
+
if (timeoutHandle) {
|
|
83
|
+
clearTimeout(timeoutHandle);
|
|
84
|
+
timeoutHandle = null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const failAndTerminate = (message) => {
|
|
88
|
+
if (completed)
|
|
89
|
+
return;
|
|
90
|
+
completed = true;
|
|
91
|
+
clearTimers();
|
|
92
|
+
if (settingsPath) {
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(settingsPath);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* ignore */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!child.killed)
|
|
101
|
+
child.kill('SIGTERM');
|
|
102
|
+
callbacks.onError(message);
|
|
103
|
+
};
|
|
104
|
+
timeoutHandle = setTimeout(() => {
|
|
105
|
+
if (!completed && !child.killed) {
|
|
106
|
+
failAndTerminate(`执行超时(${timeoutMs}ms),已终止进程`);
|
|
107
|
+
}
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
child.stdout?.on('data', (chunk) => {
|
|
110
|
+
const text = chunk.toString();
|
|
111
|
+
accumulated += text;
|
|
112
|
+
callbacks.onText(accumulated);
|
|
113
|
+
});
|
|
114
|
+
let stderrBuf = '';
|
|
115
|
+
child.stderr?.on('data', (chunk) => {
|
|
116
|
+
const text = chunk.toString();
|
|
117
|
+
stderrBuf += text;
|
|
118
|
+
callbacks.onStderr?.(text);
|
|
119
|
+
log.debug(`[Claude CLI stderr] ${text.trimEnd()}`);
|
|
120
|
+
});
|
|
121
|
+
child.on('close', (code) => {
|
|
122
|
+
clearTimers();
|
|
123
|
+
if (settingsPath) {
|
|
124
|
+
try {
|
|
125
|
+
unlinkSync(settingsPath);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* ignore */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (completed)
|
|
132
|
+
return;
|
|
133
|
+
completed = true;
|
|
134
|
+
if (code !== 0 && code !== null) {
|
|
135
|
+
const errMsg = stderrBuf ||
|
|
136
|
+
`Claude CLI exited with code ${code}`;
|
|
137
|
+
if (sessionId &&
|
|
138
|
+
(errMsg.includes('No session found') ||
|
|
139
|
+
errMsg.includes('No conversation found') ||
|
|
140
|
+
(errMsg.includes('session') && errMsg.includes('not found')))) {
|
|
141
|
+
callbacks.onSessionInvalid?.();
|
|
142
|
+
}
|
|
143
|
+
callbacks.onError(errMsg);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
callbacks.onComplete({
|
|
147
|
+
success: true,
|
|
148
|
+
result: accumulated,
|
|
149
|
+
accumulated,
|
|
150
|
+
cost: 0,
|
|
151
|
+
durationMs: Date.now() - startTime,
|
|
152
|
+
numTurns: 0,
|
|
153
|
+
toolStats,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
child.on('error', (err) => {
|
|
157
|
+
log.error(`Claude CLI spawn error: ${err.message}`);
|
|
158
|
+
clearTimers();
|
|
159
|
+
if (!completed) {
|
|
160
|
+
completed = true;
|
|
161
|
+
callbacks.onError(`Failed to start Claude CLI: ${err.message}`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
abort: () => {
|
|
166
|
+
completed = true;
|
|
167
|
+
clearTimers();
|
|
168
|
+
if (!child.killed)
|
|
169
|
+
child.kill('SIGTERM');
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude CLI 常驻运行器:通过包装进程维持一会话一进程,全量监听 stdout/stderr 并推到 IM。
|
|
3
|
+
* Claude 的 prompt 在 args 中,wrapper 发送空 prompt 即可。
|
|
4
|
+
*/
|
|
5
|
+
import { type ClaudeCLIRunCallbacks, type ClaudeCLIRunHandle, type ClaudeCLIRunOptions } from '../claude-cli/cli-runner.js';
|
|
6
|
+
export declare function runClaudeCLIPersistent(cliPath: string, prompt: string, sessionId: string | undefined, workDir: string, callbacks: ClaudeCLIRunCallbacks, options?: ClaudeCLIRunOptions): ClaudeCLIRunHandle;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude CLI 常驻运行器:通过包装进程维持一会话一进程,全量监听 stdout/stderr 并推到 IM。
|
|
3
|
+
* Claude 的 prompt 在 args 中,wrapper 发送空 prompt 即可。
|
|
4
|
+
*/
|
|
5
|
+
import { unlinkSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createLogger } from '../logger.js';
|
|
9
|
+
import { buildClaudeSpawnPayload, } from '../claude-cli/cli-runner.js';
|
|
10
|
+
import { getOrCreatePersistentSession } from './session.js';
|
|
11
|
+
import { END_PROMPT, TURN_DONE } from './cli-wrapper.js';
|
|
12
|
+
const log = createLogger('ClaudeCLIPersistent');
|
|
13
|
+
const DEFAULT_IDLE_MS = 10 * 60 * 1000;
|
|
14
|
+
function getWrapperPath() {
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const inDist = __dirname.includes('dist');
|
|
17
|
+
const ext = inDist ? 'js' : 'ts';
|
|
18
|
+
const wrapperPath = join(__dirname, `cli-wrapper.${ext}`);
|
|
19
|
+
if (inDist) {
|
|
20
|
+
return { command: process.execPath, args: [wrapperPath] };
|
|
21
|
+
}
|
|
22
|
+
return { command: 'npx', args: ['tsx', wrapperPath] };
|
|
23
|
+
}
|
|
24
|
+
export function runClaudeCLIPersistent(cliPath, prompt, sessionId, workDir, callbacks, options) {
|
|
25
|
+
const payload = buildClaudeSpawnPayload(cliPath, prompt, sessionId, workDir, options);
|
|
26
|
+
const settingsPath = payload.settingsPath;
|
|
27
|
+
const sessionKey = `claude-cli:${workDir}:${sessionId ?? 'new'}`;
|
|
28
|
+
let currentCallbacks = callbacks;
|
|
29
|
+
let turnDone = false;
|
|
30
|
+
let aborted = false;
|
|
31
|
+
let stdoutBuffer = '';
|
|
32
|
+
const startTime = Date.now();
|
|
33
|
+
const callbacksGetter = {
|
|
34
|
+
getCallbacks: () => {
|
|
35
|
+
const c = currentCallbacks;
|
|
36
|
+
return c
|
|
37
|
+
? {
|
|
38
|
+
onStdout(data) {
|
|
39
|
+
if (aborted || !c)
|
|
40
|
+
return;
|
|
41
|
+
stdoutBuffer += data;
|
|
42
|
+
const idx = stdoutBuffer.indexOf(TURN_DONE);
|
|
43
|
+
if (idx >= 0) {
|
|
44
|
+
const before = stdoutBuffer.slice(0, idx).trimEnd();
|
|
45
|
+
turnDone = true;
|
|
46
|
+
c.onComplete({
|
|
47
|
+
success: true,
|
|
48
|
+
result: before,
|
|
49
|
+
accumulated: before,
|
|
50
|
+
cost: 0,
|
|
51
|
+
durationMs: Date.now() - startTime,
|
|
52
|
+
numTurns: 0,
|
|
53
|
+
toolStats: {},
|
|
54
|
+
});
|
|
55
|
+
currentCallbacks = undefined;
|
|
56
|
+
if (settingsPath) {
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(settingsPath);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* ignore */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
c.onText(stdoutBuffer);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
onStderr(data) {
|
|
70
|
+
currentCallbacks?.onStderr?.(data);
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
: undefined;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const wrapper = getWrapperPath();
|
|
77
|
+
const session = getOrCreatePersistentSession(sessionKey, {
|
|
78
|
+
command: wrapper.command,
|
|
79
|
+
args: wrapper.args,
|
|
80
|
+
cwd: workDir,
|
|
81
|
+
idleTimeoutMs: options?.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_IDLE_MS,
|
|
82
|
+
}, callbacksGetter);
|
|
83
|
+
const payloadLine = JSON.stringify({
|
|
84
|
+
executable: payload.executable,
|
|
85
|
+
args: payload.args,
|
|
86
|
+
cwd: payload.cwd,
|
|
87
|
+
env: payload.env,
|
|
88
|
+
});
|
|
89
|
+
session.writeStdin(payloadLine + '\n' + END_PROMPT + '\n');
|
|
90
|
+
log.info(`Claude CLI persistent turn sent for sessionKey=${sessionKey}`);
|
|
91
|
+
return {
|
|
92
|
+
abort: () => {
|
|
93
|
+
aborted = true;
|
|
94
|
+
if (settingsPath) {
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(settingsPath);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* ignore */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!turnDone && currentCallbacks) {
|
|
103
|
+
currentCallbacks.onError('已取消');
|
|
104
|
+
currentCallbacks = undefined;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 常驻 CLI 包装进程(Happy 同构)
|
|
3
|
+
*
|
|
4
|
+
* 从 stdin 读取「首行 JSON (spawn 参数) + prompt 至 <<<END_PROMPT>>>」,
|
|
5
|
+
* 对每个 prompt spawn 子进程、转发 stdout/stderr,子进程退出后写 TURN_DONE,再读下一轮。
|
|
6
|
+
* 桥接层只需维持本进程,即可实现「一直监听」并把所有控制台输出推到 IM。
|
|
7
|
+
*/
|
|
8
|
+
export declare const END_PROMPT = "<<<END_PROMPT>>>";
|
|
9
|
+
export declare const TURN_DONE = "TURN_DONE";
|