chatccc 0.1.1 → 0.1.3

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
@@ -1,106 +1,143 @@
1
- # ChatCCC
2
-
3
- **飞书聊天控制 Claude Code,未来支持 Cursor、Codex**
4
-
5
- ---
6
-
7
- ## 解决的核心痛点
8
-
9
- 传统的 Claude Code(尤其是使用第三方 API 的,如 DeepSeek),需要坐在电脑桌前才能用。**离开电脑就没法用了。**
10
-
11
- ChatCCC 把 Claude Code 接入了飞书群聊:
12
-
13
- - **手机上也能用 Claude Code** —— 在飞书群里发消息就等于在终端输入指令,Claude 的思考和回复会流式展示在群卡片里
14
- - **多会话并行** —— 一个群就是一个 Claude 会话,完全隔离、互不干扰,并行工作效率更高
15
- - **流式打字机效果** —— 体验丝滑,流式输出响应迅速
16
-
17
- 一句话:**在任何设备上打开飞书,就能让 Claude 帮你写代码、排查问题、分析项目。**
18
-
19
- ---
20
-
21
- ## 怎么部署
22
-
23
- ### 1. 创建飞书应用
24
-
25
- 打开 [飞书开放平台](https://open.feishu.cn),创建一个**企业自建应用**。
26
-
27
- **添加应用能力**:开启「机器人」功能。
28
-
29
- **权限配置**(在「权限管理」中搜索并开通以下权限):
30
-
31
- | 权限 | 用途 |
32
- |------|------|
33
- | `im:chat` | 创建和管理群聊 |
34
- | `im:message` | 收发消息 |
35
- | `im:message:send_as_bot` | 以机器人身份发消息 |
36
- | `im:message.p2p_msg:readonly` | 读取私聊消息 |
37
- | `im:message.group_msg:readonly` | 读取群聊消息 |
38
-
39
- **事件订阅**(在「事件与回调」中):订阅 `im.message.receive_v1` 和 `card.action.trigger` 事件。
40
-
41
- **发布版本**:配置完成后创建应用版本并发布(仅企业内部可用即可)。
42
-
43
- ### 2. 获取凭证
44
-
45
- 在飞书应用详情页的「凭证与基础信息」中,复制 **App ID** 和 **App Secret**。
46
-
47
- ### 3. 配置环境变量
48
-
49
- ```bash
50
- cp .env.example .env
51
- ```
52
-
53
- 编辑 `.env`,填入上一步拿到的凭证:
54
-
55
- ```env
56
- FEISHU_CLAUDER_APP_ID=cli_xxxxxxxxxxxx
57
- FEISHU_CLAUDER_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxx
58
- ```
59
-
60
- 可选的高级配置:
61
-
62
- ```env
63
- # Claude 模型(见下方优先级说明)
64
- CHATCCC_ANTHROPIC_MODEL=dashscope/deepseek-v4-pro-anthropic
65
-
66
- # 思考深度,可选值: low | medium | high | max
67
- CHATCCC_ANTHROPIC_EFFORT=max
68
- ```
69
-
70
- **`CHATCCC_ANTHROPIC_MODEL` 优先级**:环境变量设置的值 > 代码内默认值 `dashscope/deepseek-v4-pro-anthropic`。不设或设为空白时自动使用默认模型。
71
-
72
- **`CHATCCC_ANTHROPIC_EFFORT` 优先级**:环境变量设置的值 > 代码内默认值 `max`。
73
-
74
- > **权限说明**:目前 ChatCCC 以 `bypassPermissions` 模式运行,即跳过所有权限确认、允许所有操作。后续会考虑引入细粒度权限控制,让你可以按需放行特定操作。
75
-
76
- > **Linux 用户注意**:不能将项目装在 `/root` 目录下运行。
77
-
78
- ### 4. 安装依赖并启动
79
-
80
- ```bash
81
- # 要求 Node.js >= 20
82
- npm install
83
- npm run dev
84
- ```
85
-
86
- 启动后机器人通过 WebSocket 连接飞书服务器,日志会写入 `logs/` 目录。
87
-
88
- ### 5. 开始使用
89
-
90
- 在飞书中找到你的机器人,发送 `/new`,机器人会自动创建一个群聊并把你的 Claude 会话绑定到该群。之后直接在群里发消息就能和 Claude 对话。
91
-
92
- ### 可用指令
93
-
94
- | 指令 | 作用 |
95
- |------|------|
96
- | `/new` | 创建新的 Claude 会话(绑定一个新群聊) |
97
- | `/stop` | 停止当前正在生成的回复 |
98
- | `/status` | 查看当前会话的状态(轮数、模型、上下文 token 等) |
99
- | `/cd` | 查看/切换工作目录 |
100
- | `/restart` | 重启机器人进程 |
101
-
102
- ---
103
-
104
- ## 技术栈
105
-
1
+ # ChatCCC
2
+
3
+ **飞书聊天控制 Claude Code,未来支持 Cursor、Codex**
4
+
5
+ ---
6
+
7
+ ## 解决的核心痛点
8
+
9
+ 传统的 Claude Code(尤其是使用第三方 API 的,如 DeepSeek),需要坐在电脑桌前才能用。**离开电脑就没法用了。**
10
+
11
+ ChatCCC 把 Claude Code 接入了飞书群聊:
12
+
13
+ - **手机上也能用 Claude Code** —— 在飞书群里发消息就等于在终端输入指令,Claude 的思考和回复会流式展示在群卡片里
14
+ - **多会话并行** —— 一个群就是一个 Claude 会话,完全隔离、互不干扰,并行工作效率更高
15
+ - **体验丝滑** —— 思考过程完整,响应迅速
16
+
17
+ 一句话:**在任何设备上打开飞书,就能让 Claude 帮你写代码、排查问题、分析项目。**
18
+
19
+ <p align="center">
20
+ <img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="280" />
21
+ &emsp;
22
+ <img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="280" />
23
+ </p>
24
+
25
+ ---
26
+
27
+ ## 怎么部署
28
+
29
+ ### 1. 安装
30
+
31
+ #### npm 全局安装(推荐)
32
+
33
+ ```bash
34
+ npm install -g chatccc
35
+ ```
36
+
37
+ 要求 Node.js >= 20。安装完成后在项目目录创建 `.env`文件(参照.env.example文件),然后输入启动指令就启动了:
38
+
39
+ ```bash
40
+ chatccc
41
+ ```
42
+
43
+ #### 从源码安装
44
+
45
+ ```bash
46
+ git clone https://github.com/wzj998/ChatCCC.git
47
+ cd ChatCCC
48
+ npm install
49
+ npm run dev
50
+ ```
51
+
52
+ 启动后机器人通过 WebSocket 连接飞书服务器,日志会写入 `logs/` 目录。
53
+
54
+ ### 2. 创建飞书应用
55
+
56
+ 打开 [飞书开放平台](https://open.feishu.cn),创建一个**企业自建应用**。
57
+
58
+ **添加应用能力**:开启「机器人」功能。
59
+
60
+ **权限配置**(在「权限管理」中搜索并开通以下权限):
61
+
62
+
63
+ | 权限 | 用途 |
64
+ | ------------------------------- | --------- |
65
+ | `im:chat` | 创建和管理群聊 |
66
+ | `im:message` | 收发消息 |
67
+ | `im:message:send_as_bot` | 以机器人身份发消息 |
68
+ | `im:message.p2p_msg:readonly` | 读取私聊消息 |
69
+ | `im:message.group_msg:readonly` | 读取群聊消息 |
70
+
71
+
72
+ **事件订阅**(在「事件与回调」中):订阅 `im.message.receive_v1` `card.action.trigger` 事件。
73
+
74
+ **发布版本**:配置完成后创建应用版本并发布(仅企业内部可用即可)。
75
+
76
+ ### 3. 获取凭证
77
+
78
+ 在飞书应用详情页的「凭证与基础信息」中,复制 **App ID** 和 **App Secret**。
79
+
80
+ ### 4. 配置环境变量
81
+
82
+ ```bash
83
+ cp .env.example .env
84
+ ```
85
+
86
+ 编辑 `.env`,填入上一步拿到的凭证:
87
+
88
+ ```env
89
+ FEISHU_CLAUDER_APP_ID=cli_xxxxxxxxxxxx
90
+ FEISHU_CLAUDER_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxx
91
+ ```
92
+
93
+ 可选的高级配置:
94
+
95
+ ```env
96
+ # Claude 模型(见下方优先级说明)
97
+ CHATCCC_ANTHROPIC_MODEL=dashscope/deepseek-v4-pro-anthropic
98
+
99
+ # 思考深度,可选值: low | medium | high | max
100
+ CHATCCC_ANTHROPIC_EFFORT=max
101
+ ```
102
+
103
+ `**CHATCCC_ANTHROPIC_MODEL` 优先级**:环境变量设置的值 > 代码内默认值 `dashscope/deepseek-v4-pro-anthropic`。不设或设为空白时自动使用默认模型。
104
+
105
+ `**CHATCCC_ANTHROPIC_EFFORT` 优先级**:环境变量设置的值 > 代码内默认值 `max`。
106
+
107
+ `**CHATCCC_PORT` 端口配置**:默认端口为 `18080`。如果你需要在一台机器上同时运行多个 ChatCCC 实例(例如用不同飞书应用分别接入不同项目),可以在各自的 `.env` 中设置不同的端口:
108
+
109
+ ```env
110
+ # 实例 A(项目1)的 .env
111
+ CHATCCC_PORT=18080
112
+
113
+ # 实例 B(项目2)的 .env
114
+ CHATCCC_PORT=18081
115
+ ```
116
+
117
+ 不同端口的实例互不冲突,启动时会自动清理各自端口上的旧进程。
118
+
119
+ > **权限说明**:目前 ChatCCC 以 `bypassPermissions` 模式运行,即跳过所有权限确认、允许所有操作。后续会考虑引入细粒度权限控制,让你可以按需放行特定操作。
120
+
121
+ > **Linux 用户注意**:不能将项目装在 `/root` 目录下运行。
122
+
123
+ ### 5. 开始使用
124
+
125
+ 在飞书中找到你的机器人,发送 `/new`,机器人会自动创建一个群聊并把你的 Claude 会话绑定到该群。之后直接在群里发消息就能和 Claude 对话。
126
+
127
+ ### 可用指令
128
+
129
+
130
+ | 指令 | 作用 |
131
+ | ---------- | ---------------------------- |
132
+ | `/new` | 创建新的 Claude 会话(绑定一个新群聊) |
133
+ | `/stop` | 停止当前正在生成的回复 |
134
+ | `/status` | 查看当前会话的状态(轮数、模型、上下文 token 等) |
135
+ | `/cd` | 查看/切换工作目录 |
136
+ | `/restart` | 重启机器人进程 |
137
+
138
+
139
+ ---
140
+
141
+ ## 技术栈
142
+
106
143
  TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / 飞书 WebSocket API / CardKit
package/bin/chatccc.mjs CHANGED
@@ -1,7 +1,10 @@
1
- #!/usr/bin/env node
2
- import { register } from "node:module";
3
- import { pathToFileURL } from "node:url";
4
-
5
- register("tsx/esm", pathToFileURL("./"));
6
-
7
- await import("../src/index.js");
1
+ #!/usr/bin/env node
2
+ import { createRequire, register } from "node:module";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const pkgRoot = new URL("../", import.meta.url);
6
+ // 勿用 pathToFileURL("./"):会相对于进程 cwd。用 createRequire 从本脚本所在包解析 tsx
7
+ const require = createRequire(import.meta.url);
8
+ register(pathToFileURL(require.resolve("tsx/esm")), pkgRoot);
9
+
10
+ await import(new URL("../src/index.ts", import.meta.url));
package/package.json CHANGED
@@ -1,40 +1,40 @@
1
- {
2
- "name": "chatccc",
3
- "version": "0.1.1",
4
- "description": "Feishu bot bridge for Claude Code",
5
- "type": "module",
6
- "main": "./src/index.ts",
7
- "bin": {
8
- "chatccc": "./bin/chatccc.mjs"
9
- },
10
- "files": [
11
- "src/",
12
- "bin/",
13
- "package.json",
14
- "README.md"
15
- ],
16
- "scripts": {
17
- "dev": "tsx --env-file=.env src/index.ts",
18
- "chatccc": "tsx --env-file=.env src/index.ts",
19
- "start": "tsx --env-file=.env src/index.ts",
20
- "demo:bot-test": "tsx --env-file=.env demo/bot_test.ts",
21
- "demo:bot-test:local": "tsx --env-file=.env demo/bot_test.ts --local",
22
- "demo:create-group": "tsx --env-file=.env src/index.ts",
23
- "demo:create-group:local": "tsx --env-file=.env src/index.ts --local",
24
- "demo:permission-check": "tsx --env-file=.env demo/permission_check.ts"
25
- },
26
- "dependencies": {
27
- "@anthropic-ai/claude-agent-sdk": "^0.2.133",
28
- "@larksuiteoapi/node-sdk": "^1.59.0",
29
- "tsx": "^4.0.0",
30
- "ws": "^8.18.0"
31
- },
32
- "devDependencies": {
33
- "@types/node": "^20.0.0",
34
- "@types/ws": "^8.18.1",
35
- "typescript": "^5.0.0"
36
- },
37
- "engines": {
38
- "node": ">=20"
39
- }
40
- }
1
+ {
2
+ "name": "chatccc",
3
+ "version": "0.1.3",
4
+ "description": "Feishu bot bridge for Claude Code",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "bin": {
8
+ "chatccc": "bin/chatccc.mjs"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "bin/",
13
+ "package.json",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "dev": "tsx --env-file=.env src/index.ts",
18
+ "chatccc": "tsx --env-file=.env src/index.ts",
19
+ "start": "tsx --env-file=.env src/index.ts",
20
+ "demo:bot-test": "tsx --env-file=.env demo/bot_test.ts",
21
+ "demo:bot-test:local": "tsx --env-file=.env demo/bot_test.ts --local",
22
+ "demo:create-group": "tsx --env-file=.env src/index.ts",
23
+ "demo:create-group:local": "tsx --env-file=.env src/index.ts --local",
24
+ "demo:permission-check": "tsx --env-file=.env demo/permission_check.ts"
25
+ },
26
+ "dependencies": {
27
+ "@anthropic-ai/claude-agent-sdk": "^0.2.133",
28
+ "@larksuiteoapi/node-sdk": "^1.59.0",
29
+ "tsx": "^4.0.0",
30
+ "ws": "^8.18.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^20.0.0",
34
+ "@types/ws": "^8.18.1",
35
+ "typescript": "^5.0.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ }
40
+ }
package/src/cards.ts CHANGED
@@ -93,7 +93,7 @@ export function buildThinkingCardV2(
93
93
  schema: "2.0",
94
94
  config: {
95
95
  update_multi: true,
96
- streaming_mode: showStop,
96
+ streaming_mode: false,
97
97
  },
98
98
  header: {
99
99
  template: headerTemplate,
@@ -133,8 +133,8 @@ export function buildCdContent(
133
133
  const listing = display.map(e => e.isDir ? `📁 ${e.name}/` : `📄 ${e.name}`).join("\n");
134
134
 
135
135
  const statusLine = isUpdate
136
- ? `**工作路径已更新为:** \`${dirPath}\``
137
- : `**当前工作路径:** \`${dirPath}\``;
136
+ ? `**新会话工作路径已切换至:** \`${dirPath}\``
137
+ : `**新会话默认工作路径:** \`${dirPath}\``;
138
138
 
139
139
  return [
140
140
  statusLine,
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
4
 
5
5
  import { setupFileLogging } from "./shared.ts";
6
6
 
@@ -38,25 +38,36 @@ export const APP_SECRET: string = process.env.FEISHU_CLAUDER_APP_SECRET ?? "";
38
38
  export const BASE_URL = "https://open.feishu.cn/open-apis";
39
39
  export const LOCAL_RELAY_URL = "ws://127.0.0.1:18080";
40
40
 
41
+ export const CHATCCC_PORT = parseInt(process.env.CHATCCC_PORT?.trim() ?? "18080", 10);
42
+
41
43
  export const CLAUDE_MODEL =
42
44
  process.env.CHATCCC_ANTHROPIC_MODEL?.trim() || "dashscope/deepseek-v4-pro-anthropic";
43
45
 
44
46
  export const CLAUDE_EFFORT =
45
47
  process.env.CHATCCC_ANTHROPIC_EFFORT?.trim() || "max";
46
48
 
47
- export const WORKING_DIR_FILE = join(PROJECT_ROOT, ".claude", "working_dir.txt");
49
+ // 新建会话的默认工作路径(/cd 命令设置,持久化到本地文件)
50
+ // 该路径仅影响通过 /new 新建的 Claude 会话,不影响已有会话的 resume。
51
+ export const DEFAULT_CWD_FILE = join(PROJECT_ROOT, ".claude", "working_dir.txt");
48
52
 
49
- export async function getWorkingDir(): Promise<string> {
53
+ /** 读取 /cd 设置的默认工作路径。若文件不存在或路径已失效,回退到 PROJECT_ROOT。 */
54
+ export async function getDefaultCwd(): Promise<string> {
50
55
  try {
51
- const content = await readFile(WORKING_DIR_FILE, "utf-8");
56
+ const content = await readFile(DEFAULT_CWD_FILE, "utf-8");
52
57
  const dir = content.trim();
53
- if (dir) return dir;
58
+ if (dir) {
59
+ try {
60
+ const s = await stat(dir);
61
+ if (s.isDirectory()) return dir;
62
+ } catch { /* path gone, fall through */ }
63
+ }
54
64
  } catch { /* file doesn't exist yet */ }
55
65
  return PROJECT_ROOT;
56
66
  }
57
67
 
58
- export async function setWorkingDir(dir: string): Promise<void> {
59
- await writeFile(WORKING_DIR_FILE, dir, "utf-8");
68
+ /** 设置新建会话的默认工作路径(由 /cd 命令调用) */
69
+ export async function setDefaultCwd(dir: string): Promise<void> {
70
+ await writeFile(DEFAULT_CWD_FILE, dir, "utf-8");
60
71
  }
61
72
 
62
73
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import WebSocket from "ws";
30
30
 
31
31
  import { ensureSingleInstance, createRelayServer } from "./shared.ts";
32
32
  import {
33
+ CHATCCC_PORT,
33
34
  APP_ID,
34
35
  APP_SECRET,
35
36
  BASE_URL,
@@ -40,8 +41,8 @@ import {
40
41
  USE_LOCAL,
41
42
  appendChatLog,
42
43
  fileLog,
43
- getWorkingDir,
44
- setWorkingDir,
44
+ getDefaultCwd,
45
+ setDefaultCwd,
45
46
  ts,
46
47
  } from "./config.ts";
47
48
  import {
@@ -58,7 +59,7 @@ import {
58
59
  sendRestartCard,
59
60
  } from "./feishu-api.ts";
60
61
  import { buildHelpCard, buildStatusCard, buildThinkingCardV2, buildCdContent } from "./cards.ts";
61
- import { setCardKitSettings, updateCardKitCard } from "./cardkit.ts";
62
+ import { updateCardKitCard } from "./cardkit.ts";
62
63
  import {
63
64
  MAX_PROCESSED,
64
65
  chatSessionMap,
@@ -171,7 +172,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
171
172
 
172
173
  if (text === "/cd" || text.startsWith("/cd ")) {
173
174
  const cdToken = await getTenantAccessToken();
174
- const currentDir = await getWorkingDir();
175
+ const currentDir = await getDefaultCwd();
175
176
  const arg = text.slice(3).trim(); // everything after "/cd" (may be empty)
176
177
 
177
178
  // Resolve target directory
@@ -188,18 +189,18 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
188
189
  try {
189
190
  const s = await stat(targetDir);
190
191
  if (!s.isDirectory()) {
191
- await sendCardReply(cdToken, chatId, "工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
192
+ await sendCardReply(cdToken, chatId, "新会话工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
192
193
  return;
193
194
  }
194
195
  } catch {
195
- await sendCardReply(cdToken, chatId, "工作路径", `路径不存在:\n\`${targetDir}\``, "red");
196
+ await sendCardReply(cdToken, chatId, "新会话工作路径", `路径不存在:\n\`${targetDir}\``, "red");
196
197
  return;
197
198
  }
198
199
 
199
200
  // Change working dir if user provided a path
200
201
  const isUpdate = !!arg && targetDir !== currentDir;
201
202
  if (isUpdate) {
202
- await setWorkingDir(targetDir);
203
+ await setDefaultCwd(targetDir);
203
204
  }
204
205
 
205
206
  // Read directory entries
@@ -207,7 +208,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
207
208
  try {
208
209
  entries = await readdir(targetDir);
209
210
  } catch (err) {
210
- await sendCardReply(cdToken, chatId, "工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
211
+ await sendCardReply(cdToken, chatId, "新会话工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
211
212
  return;
212
213
  }
213
214
 
@@ -225,7 +226,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
225
226
  });
226
227
 
227
228
  const content = buildCdContent(targetDir, withStats, isUpdate);
228
- await sendCardReply(cdToken, chatId, "工作路径", content, "blue");
229
+ await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
229
230
  return;
230
231
  }
231
232
 
@@ -386,7 +387,6 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
386
387
  { showStop: false, headerTitle: "已中断", headerTemplate: "yellow" }
387
388
  );
388
389
  let nextSeq = existing.sequence + 1;
389
- await setCardKitSettings(freshToken, cardId, { streaming_mode: false }, nextSeq++).catch(() => {});
390
390
  await updateCardKitCard(freshToken, cardId, interruptedCard, nextSeq).catch((err) => {
391
391
  console.error(`[${ts()}] [INTERRUPT] CardKit update failed: ${(err as Error).message}`);
392
392
  });
@@ -427,14 +427,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
427
427
  // ---------------------------------------------------------------------------
428
428
 
429
429
  async function main(): Promise<void> {
430
- ensureSingleInstance(PID_FILE, 18080);
430
+ ensureSingleInstance(PID_FILE, CHATCCC_PORT);
431
431
 
432
432
  if (!APP_ID || !APP_SECRET) {
433
433
  console.log("ERROR: FEISHU_CLAUDER_APP_ID / FEISHU_CLAUDER_APP_SECRET not set");
434
434
  process.exit(1);
435
435
  }
436
436
 
437
- const { server: relayServer, broadcast } = createRelayServer(18080);
437
+ const { server: relayServer, broadcast } = createRelayServer(CHATCCC_PORT);
438
438
  broadcastToRelay = broadcast;
439
439
 
440
440
  const modeTag = USE_LOCAL ? " (local relay mode)" : "";
package/src/session.ts CHANGED
@@ -1,12 +1,10 @@
1
- import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk";
1
+ import { getSessionInfo, unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk";
2
2
 
3
- import { CLAUDE_EFFORT, CLAUDE_MODEL, fileLog, ts } from "./config.ts";
3
+ import { CLAUDE_EFFORT, CLAUDE_MODEL, fileLog, getDefaultCwd, ts } from "./config.ts";
4
4
  import { buildThinkingCardV2, getToolEmoji, truncateContent } from "./cards.ts";
5
5
  import {
6
6
  createCardKitCard,
7
7
  sendCardKitMessage,
8
- setCardKitSettings,
9
- streamCardKitElement,
10
8
  updateCardKitCard,
11
9
  } from "./cardkit.ts";
12
10
  import { sendTextReply } from "./feishu-api.ts";
@@ -58,11 +56,13 @@ export function resetState(): void {
58
56
  // ---------------------------------------------------------------------------
59
57
 
60
58
  export async function initClaudeSession(): Promise<string> {
61
- console.log(`[${ts()}] [STEP 1/5] Creating Claude session via SDK (model=${CLAUDE_MODEL}, effort=${CLAUDE_EFFORT})`);
59
+ const cwd = await getDefaultCwd();
60
+ console.log(`[${ts()}] [STEP 1/5] Creating Claude session via SDK (model=${CLAUDE_MODEL}, effort=${CLAUDE_EFFORT}, cwd=${cwd})`);
62
61
 
63
62
  const session = unstable_v2_createSession({
64
63
  model: CLAUDE_MODEL,
65
64
  effort: CLAUDE_EFFORT,
65
+ cwd,
66
66
  permissionMode: "bypassPermissions",
67
67
  allowDangerouslySkipPermissions: true,
68
68
  autoCompactEnabled: true,
@@ -104,18 +104,19 @@ export async function resumeAndPrompt(
104
104
  chatId: string,
105
105
  msgTimestamp: number
106
106
  ): Promise<void> {
107
- console.log(`[${ts()}] Resuming Claude session: ${sessionId} (model=${CLAUDE_MODEL}, effort=${CLAUDE_EFFORT})`);
107
+ const cwd = (await getSessionInfo(sessionId))?.cwd ?? (await getDefaultCwd());
108
+ console.log(`[${ts()}] Resuming Claude session: ${sessionId} (model=${CLAUDE_MODEL}, effort=${CLAUDE_EFFORT}, cwd=${cwd})`);
108
109
 
109
110
  const session = unstable_v2_resumeSession(sessionId, {
110
111
  model: CLAUDE_MODEL,
111
112
  effort: CLAUDE_EFFORT,
113
+ cwd,
112
114
  permissionMode: "bypassPermissions",
113
115
  allowDangerouslySkipPermissions: true,
114
116
  autoCompactEnabled: true,
115
117
  } as any);
116
118
 
117
119
  let cardId: string | null = null;
118
- let streamingEnabled = false;
119
120
  chatSessionMap.set(chatId, {
120
121
  gen: ++sessionGen,
121
122
  close: () => session.close(),
@@ -152,25 +153,16 @@ export async function resumeAndPrompt(
152
153
  });
153
154
  if (cardId) {
154
155
  const cEntry = chatSessionMap.get(chatId);
155
- if (cEntry) { cEntry.cardId = cardId; }
156
- const settingsOk = await setCardKitSettings(token, cardId, { streaming_mode: true }, 1).catch((err) => {
157
- console.error(`[${ts()}] [CARDIKT] enableStreaming FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
158
- fileLog.flush();
159
- return false;
160
- });
161
- const settingsSucceeded = settingsOk !== false;
162
- if (settingsSucceeded && cEntry) { cEntry.sequence = 1; }
163
- const sendOk = settingsSucceeded ? await sendCardKitMessage(token, chatId, cardId).catch((err) => {
156
+ if (cEntry) { cEntry.cardId = cardId; cEntry.sequence = 1; }
157
+ const sendOk = await sendCardKitMessage(token, chatId, cardId).catch((err) => {
164
158
  console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
165
159
  fileLog.flush();
166
160
  return false;
167
- }) : false;
161
+ });
168
162
  if (!sendOk) {
169
163
  sendTextReply(token, chatId, "⚠️ 卡片发送失败,将使用文本回复。").catch(() => {});
170
164
  cardId = null;
171
165
  if (cEntry) { cEntry.cardId = null; cEntry.sequence = 0; }
172
- } else {
173
- streamingEnabled = true;
174
166
  }
175
167
  }
176
168
 
@@ -181,8 +173,6 @@ export async function resumeAndPrompt(
181
173
  let finalText = "";
182
174
 
183
175
  let cardCreatedAt = Date.now();
184
- let cardStartThinkingLen = 0;
185
- let cardStartTextLen = 0;
186
176
  const CARD_ROTATE_MS = 9 * 60 * 1000; // 飞书 CardKit 流式超时 10 分钟,提前 1 分钟切换
187
177
 
188
178
  let dotCount = 0;
@@ -199,26 +189,20 @@ export async function resumeAndPrompt(
199
189
  cEntry.cardBusy = true;
200
190
  const oldCardId = cardId;
201
191
  try {
202
- // 1. 结束旧卡片的流式模式
192
+ // 1. 用当前累积内容更新旧卡片为静态版本
203
193
  const oldSeqBase = cEntry.sequence;
204
- await setCardKitSettings(token, oldCardId!, { streaming_mode: false }, oldSeqBase + 1).catch(() => {});
205
- // 2. 用当前累积内容更新旧卡片为静态版本
206
194
  const oldDisplay = truncateContent(accumulatedThinking + finalText) || "处理中...";
207
195
  const oldCard = buildThinkingCardV2(oldDisplay, { showStop: false, headerTitle: "生成中...(上轮)" });
208
- await updateCardKitCard(token, oldCardId!, oldCard, oldSeqBase + 2).catch(() => {});
209
- // 3. 创建新卡片
196
+ await updateCardKitCard(token, oldCardId!, oldCard, oldSeqBase + 1).catch(() => {});
197
+ // 2. 创建新卡片并发送
210
198
  const newCardId = await createCardKitCard(token, buildThinkingCardV2("", { showStop: true, headerTitle: "生成中..." }));
211
199
  if (!newCardId) throw new Error("createCardKitCard returned empty");
212
- // 4. 开启流式模式并发送
213
- await setCardKitSettings(token, newCardId, { streaming_mode: true }, 1);
214
200
  await sendCardKitMessage(token, chatId, newCardId);
215
- // 5. 切换到新卡片
201
+ // 3. 切换到新卡片
216
202
  cardId = newCardId;
217
203
  cEntry.cardId = newCardId;
218
204
  cEntry.sequence = 1;
219
205
  cardCreatedAt = Date.now();
220
- cardStartThinkingLen = accumulatedThinking.length;
221
- cardStartTextLen = finalText.length;
222
206
  lastSentContent = "";
223
207
  streamErrorNotified = false;
224
208
  console.log(`[${ts()}] [CARDIKT] rotated: old=${oldCardId} new=${newCardId} (9min timeout)`);
@@ -231,27 +215,27 @@ export async function resumeAndPrompt(
231
215
  }
232
216
 
233
217
  dotCount = (dotCount % 9) + 1;
234
- const currentContent = accumulatedThinking.slice(cardStartThinkingLen) + finalText.slice(cardStartTextLen);
235
- const content = truncateContent(currentContent + "\n" + ".".repeat(dotCount));
218
+ const content = truncateContent(accumulatedThinking + finalText + "\n" + "。".repeat(dotCount));
236
219
  if (content === lastSentContent) return;
237
220
 
238
221
  lastSentContent = content;
239
222
  cEntry.cardBusy = true;
240
223
  const mySeq = cEntry.sequence + 1;
241
224
  try {
242
- await streamCardKitElement(token, cardId!, "main_content", content, mySeq);
225
+ const card = buildThinkingCardV2(content, { showStop: true, headerTitle: "生成中..." });
226
+ await updateCardKitCard(token, cardId!, card, mySeq);
243
227
  cEntry.sequence = mySeq;
244
228
  cEntry.accumulatedThinking = accumulatedThinking;
245
229
  streamErrorNotified = false;
246
230
  healthLogTicks++;
247
231
  if (healthLogTicks % 10 === 0) {
248
- console.log(`[${ts()}] [CARDIKT] stream health: seq=${mySeq} thinking=${accumulatedThinking.length}chars text=${finalText.length}chars cardAge=${Math.round((Date.now() - cardCreatedAt) / 1000)}s`);
232
+ console.log(`[${ts()}] [CARDIKT] update health: seq=${mySeq} thinking=${accumulatedThinking.length}chars text=${finalText.length}chars cardAge=${Math.round((Date.now() - cardCreatedAt) / 1000)}s`);
249
233
  }
250
234
  } catch (err) {
251
- console.error(`[${ts()}] CardKit stream error: chatId=${chatId} cardId=${cardId} seq=${mySeq} ${(err as Error).message}`);
235
+ console.error(`[${ts()}] CardKit update error: chatId=${chatId} cardId=${cardId} seq=${mySeq} ${(err as Error).message}`);
252
236
  if (!streamErrorNotified) {
253
237
  streamErrorNotified = true;
254
- sendTextReply(token, chatId, "⚠️ 卡片流式更新失败,结果将以文本形式发送。").catch(() => {});
238
+ sendTextReply(token, chatId, "⚠️ 卡片更新失败,结果将以文本形式发送。").catch(() => {});
255
239
  }
256
240
  } finally {
257
241
  cEntry.cardBusy = false;
@@ -335,10 +319,7 @@ export async function resumeAndPrompt(
335
319
  while (cEntry.cardBusy) {
336
320
  await new Promise(r => setTimeout(r, 20));
337
321
  }
338
- let nextSeq = cEntry.sequence + 1;
339
- if (streamingEnabled) {
340
- await setCardKitSettings(token, cardId, { streaming_mode: false }, nextSeq++).catch(() => {});
341
- }
322
+ const nextSeq = cEntry.sequence + 1;
342
323
  if (wasStopped) {
343
324
  const stopCard = buildThinkingCardV2(accumulatedThinking || "已停止", { showStop: false, headerTitle: "已停止", headerTemplate: "red" });
344
325
  await updateCardKitCard(token, cardId, stopCard, nextSeq).catch((err) => {