chatccc 0.2.28 → 0.2.30
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 +29 -29
- package/im-skills/feishu-skill/receive-send-file.md +36 -6
- package/im-skills/feishu-skill/receive-send-image.md +10 -5
- package/im-skills/feishu-skill/skill.md +1 -0
- package/package.json +1 -1
- package/src/__tests__/cards.test.ts +1 -1
- package/src/__tests__/im-skills.test.ts +4 -8
- package/src/agent-grants-rpc.ts +71 -0
- package/src/cards.ts +27 -27
- package/src/config.ts +21 -17
- package/src/index.ts +8 -7
- package/src/session.ts +8 -6
package/README.md
CHANGED
|
@@ -12,17 +12,17 @@ ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
|
|
|
12
12
|
|
|
13
13
|
- **手机上也能用 Claude Code / Cursor / Codex** —— 在飞书群里发消息就等于在终端输入指令,AI 的思考和回复会流式展示在群卡片里
|
|
14
14
|
- **多会话并行** —— 一个群就是一个 AI 会话,完全隔离、互不干扰,并行工作效率更高
|
|
15
|
-
- **多工具切换** —— `/new` 使用默认 Agent 创建会话,也可用 `/new claude`、`/new cursor`、`/new codex` 指定工具,各取所长
|
|
15
|
+
- **多工具切换** —— `/new` 使用默认 Agent 创建会话,也可用 `/new claude`、`/new cursor`、`/new codex` 指定工具,各取所长
|
|
16
16
|
|
|
17
17
|
一句话:**在任何设备上打开飞书,就能让 Claude Code / Cursor / Codex 帮你写代码、排查问题、分析项目。**
|
|
18
18
|
|
|
19
|
-
<p align="center">
|
|
20
|
-
<img src="images/img_readme_messages.jpg" alt="飞书会话列表" width="220" align="top" />
|
|
21
|
-
|
|
22
|
-
<img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="220" align="top" />
|
|
23
|
-
|
|
24
|
-
<img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="220" align="top" />
|
|
25
|
-
</p>
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="images/img_readme_messages.jpg" alt="飞书会话列表" width="220" align="top" />
|
|
21
|
+
|
|
22
|
+
<img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="220" align="top" />
|
|
23
|
+
|
|
24
|
+
<img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="220" align="top" />
|
|
25
|
+
</p>
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -120,7 +120,7 @@ ChatCCC 启动时会按以下顺序确定 Cursor Agent CLI:
|
|
|
120
120
|
|
|
121
121
|
> 旧版字段 `cursor.command` 仍可读取(启动时会打印一次 warning 提示改名),新配置请统一使用 `cursor.path`。
|
|
122
122
|
|
|
123
|
-
> **说明**:只使用 Claude Code(`/new claude`,或把 Claude Code 设为默认 Agent 后使用 `/new`)的用户无需安装 Cursor CLI。
|
|
123
|
+
> **说明**:只使用 Claude Code(`/new claude`,或把 Claude Code 设为默认 Agent 后使用 `/new`)的用户无需安装 Cursor CLI。
|
|
124
124
|
|
|
125
125
|
#### Codex CLI(使用 Codex 会话时需要)
|
|
126
126
|
|
|
@@ -217,24 +217,24 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
217
217
|
},
|
|
218
218
|
"port": 18080,
|
|
219
219
|
"gitTimeoutSeconds": 180,
|
|
220
|
-
"claude": {
|
|
221
|
-
"enabled": false,
|
|
222
|
-
"defaultAgent": true,
|
|
223
|
-
"model": "",
|
|
220
|
+
"claude": {
|
|
221
|
+
"enabled": false,
|
|
222
|
+
"defaultAgent": true,
|
|
223
|
+
"model": "",
|
|
224
224
|
"effort": "",
|
|
225
225
|
"apiKey": "",
|
|
226
226
|
"baseUrl": ""
|
|
227
227
|
},
|
|
228
|
-
"cursor": {
|
|
229
|
-
"enabled": false,
|
|
230
|
-
"defaultAgent": false,
|
|
231
|
-
"path": "",
|
|
228
|
+
"cursor": {
|
|
229
|
+
"enabled": false,
|
|
230
|
+
"defaultAgent": false,
|
|
231
|
+
"path": "",
|
|
232
232
|
"model": ""
|
|
233
233
|
},
|
|
234
|
-
"codex": {
|
|
235
|
-
"enabled": false,
|
|
236
|
-
"defaultAgent": false,
|
|
237
|
-
"path": "",
|
|
234
|
+
"codex": {
|
|
235
|
+
"enabled": false,
|
|
236
|
+
"defaultAgent": false,
|
|
237
|
+
"path": "",
|
|
238
238
|
"model": "",
|
|
239
239
|
"effort": ""
|
|
240
240
|
}
|
|
@@ -247,10 +247,10 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
247
247
|
| --- | --- | --- |
|
|
248
248
|
| `feishu.appId` / `feishu.appSecret` | 是 | 飞书应用「凭证与基础信息」中的 App ID / App Secret |
|
|
249
249
|
| `port` | 否 | 本地 WebSocket 中继 + Web 向导监听端口,默认 `18080`;同机多实例时改成不同值即可 |
|
|
250
|
-
| `gitTimeoutSeconds` | 否 | `/git` 命令在会话工作目录执行时的单次超时秒数,默认 `180`,允许范围 `1–3600`,超时会被 `SIGKILL` 强制终止 |
|
|
251
|
-
| `claude.enabled` / `cursor.enabled` / `codex.enabled` | 否 | 是否启用对应 AI Agent;Web 向导/管理页只展示 `enabled: true` 的 agent 卡片。字段缺省时按「任一配置字段非空」自动判定(向后兼容) |
|
|
252
|
-
| `claude.defaultAgent` / `cursor.defaultAgent` / `codex.defaultAgent` | 否 | `/new` 未指定具体 Agent 时使用哪个默认 Agent;同一时间应只有一个为 `true`。Web 配置页切换某个 Agent 为默认时会自动关闭其它 Agent 的默认开关 |
|
|
253
|
-
| `claude.model` | 否 | Claude Code 会话使用的模型;留空(`""` / 全空白)→ 不向 SDK 传 `model`,由 SDK / 服务商默认决定 |
|
|
250
|
+
| `gitTimeoutSeconds` | 否 | `/git` 命令在会话工作目录执行时的单次超时秒数,默认 `180`,允许范围 `1–3600`,超时会被 `SIGKILL` 强制终止 |
|
|
251
|
+
| `claude.enabled` / `cursor.enabled` / `codex.enabled` | 否 | 是否启用对应 AI Agent;Web 向导/管理页只展示 `enabled: true` 的 agent 卡片。字段缺省时按「任一配置字段非空」自动判定(向后兼容) |
|
|
252
|
+
| `claude.defaultAgent` / `cursor.defaultAgent` / `codex.defaultAgent` | 否 | `/new` 未指定具体 Agent 时使用哪个默认 Agent;同一时间应只有一个为 `true`。Web 配置页切换某个 Agent 为默认时会自动关闭其它 Agent 的默认开关 |
|
|
253
|
+
| `claude.model` | 否 | Claude Code 会话使用的模型;留空(`""` / 全空白)→ 不向 SDK 传 `model`,由 SDK / 服务商默认决定 |
|
|
254
254
|
| `claude.effort` | 否 | Claude 思考深度(如 `low` / `medium` / `high` / `max`);留空 → 不向 SDK 传 `effort` |
|
|
255
255
|
| `claude.apiKey` | 否 | 第三方 Anthropic 兼容网关的 API 密钥;**官方 Claude 用户保持 `""` 即可**,详见下文「第三方 API」一节 |
|
|
256
256
|
| `claude.baseUrl` | 否 | 第三方 Anthropic 兼容网关的 base URL(例如 `https://api.deepseek.com/anthropic`);**官方 Claude 用户保持 `""` 即可** |
|
|
@@ -295,20 +295,20 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
295
295
|
|
|
296
296
|
### 5. 开始使用
|
|
297
297
|
|
|
298
|
-
在飞书中找到你的机器人,发送 `/new`(使用 `config.json` 中 `defaultAgent: true` 的默认 Agent)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
|
|
298
|
+
在飞书中找到你的机器人,发送 `/new`(使用 `config.json` 中 `defaultAgent: true` 的默认 Agent)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
|
|
299
299
|
|
|
300
300
|
### 可用指令
|
|
301
301
|
|
|
302
302
|
|
|
303
303
|
| 指令 | 作用 |
|
|
304
304
|
| --------------- | ---------------------------- |
|
|
305
|
-
| `/new` | 使用默认 Agent 创建新会话 |
|
|
305
|
+
| `/new` | 使用默认 Agent 创建新会话 |
|
|
306
306
|
| `/new claude` | 创建新的 Claude Code 会话 |
|
|
307
307
|
| `/new cursor` | 创建新的 Cursor 会话 |
|
|
308
308
|
| `/new codex` | 创建新的 Codex 会话(OpenAI) |
|
|
309
309
|
| `/stop` | 停止当前正在生成的回复 |
|
|
310
310
|
| `/status` | 查看当前会话的状态(轮数、模型、上下文 token 等) |
|
|
311
|
-
| `/cd` |
|
|
311
|
+
| `/cd` | 查看/设置当前会话的默认工作目录 |
|
|
312
312
|
| `/sessions` | 查看所有会话状态 |
|
|
313
313
|
| `/forget` | 重置当前会话(创建新 Session,保留工作目录,同一群内继续) |
|
|
314
314
|
| `/git <子命令>` | 在**当前会话工作目录**执行 `git ...` 并把 stdout/stderr 回发到群里(仅会话群内可用,超时见 `config.json` 的 `gitTimeoutSeconds` 字段) |
|
|
@@ -319,4 +319,4 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
319
319
|
|
|
320
320
|
## 技术栈
|
|
321
321
|
|
|
322
|
-
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI (OpenAI) / 飞书 WebSocket API / CardKit
|
|
322
|
+
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI (OpenAI) / 飞书 WebSocket API / CardKit
|
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
Videos are sent as regular files (not media), which looks cleaner in Feishu.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
First, query the current send token:
|
|
8
|
+
```bash
|
|
9
|
+
curl "{{session_grants_url}}?sid={{session_id}}"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Then send with the returned url and token:
|
|
8
13
|
|
|
14
|
+
### Script (recommended)
|
|
9
15
|
```bash
|
|
10
|
-
node "{{send_file_script}}" --url
|
|
16
|
+
node "{{send_file_script}}" --url <url_from_query> --token <token_from_query> --path "<absolute file path>" --caption "<optional caption>"
|
|
11
17
|
```
|
|
12
18
|
|
|
13
19
|
### Direct HTTP
|
|
14
|
-
|
|
15
20
|
```http
|
|
16
|
-
POST
|
|
17
|
-
Authorization: Bearer
|
|
21
|
+
POST <url_from_query>
|
|
22
|
+
Authorization: Bearer <token_from_query>
|
|
18
23
|
Content-Type: application/json; charset=utf-8
|
|
19
24
|
|
|
20
25
|
{"path":"<absolute file path>","caption":"<optional caption>"}
|
|
@@ -24,10 +29,35 @@ Content-Type: application/json; charset=utf-8
|
|
|
24
29
|
|
|
25
30
|
- Save or choose a local file first.
|
|
26
31
|
- Use an absolute local path.
|
|
27
|
-
- Max file size:
|
|
32
|
+
- Max file size: 30MB.
|
|
28
33
|
- Supported formats: .mp4 .mov .avi .mkv .webm .flv .mp3 .wav .ogg .aac .m4a .pdf .doc .docx .xls .xlsx .csv .ppt .pptx .txt .zip .tar .gz.
|
|
29
34
|
- Only send a file/video when the user asked for one or when it materially helps the answer.
|
|
30
35
|
|
|
36
|
+
### Video Compression (when file > 30MB)
|
|
37
|
+
|
|
38
|
+
If the video exceeds 30MB, compress it with ffmpeg before sending.
|
|
39
|
+
|
|
40
|
+
**Ensure ffmpeg is available** (install if missing):
|
|
41
|
+
|
|
42
|
+
| OS | Install command |
|
|
43
|
+
|----|----------------|
|
|
44
|
+
| macOS | `brew install ffmpeg` |
|
|
45
|
+
| Linux (Debian/Ubuntu) | `sudo apt install ffmpeg` |
|
|
46
|
+
| Linux (RHEL/Fedora) | `sudo dnf install ffmpeg` |
|
|
47
|
+
| Windows | `winget install Gyan.FFmpeg` |
|
|
48
|
+
|
|
49
|
+
**Two-pass compression** (target ~28MB for 30s video, adjust `b:v` for other durations):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ffmpeg -y -i "<input>" -c:v libx264 -b:v <bitrate>k -pass 1 -f mp4 NUL
|
|
53
|
+
ffmpeg -y -i "<input>" -c:v libx264 -b:v <bitrate>k -pass 2 -c:a aac -b:a 128k "<output>"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Bitrate formula: `bitrate = 28 × 8 × 1000 ÷ duration_seconds - 128` (target ~28MB, safe under 30MB).
|
|
57
|
+
On Windows replace `NUL` with `NUL` (same); on Linux/macOS use `/dev/null`.
|
|
58
|
+
|
|
59
|
+
If the compressed file still exceeds 30MB, explain to the user that automatic compression wasn't enough and suggest they manually trim or re-encode the source.
|
|
60
|
+
|
|
31
61
|
## Download Files or Videos
|
|
32
62
|
|
|
33
63
|
When the user sends a file or video to the bot, the message contains `message_id` and `file_key`. Download it with:
|
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## Send Images
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
First, query the current send token:
|
|
6
|
+
```bash
|
|
7
|
+
curl "{{session_grants_url}}?sid={{session_id}}"
|
|
8
|
+
```
|
|
6
9
|
|
|
10
|
+
Then send with the returned url and token:
|
|
11
|
+
|
|
12
|
+
### Script (recommended)
|
|
7
13
|
```bash
|
|
8
|
-
node "{{send_image_script}}" --url
|
|
14
|
+
node "{{send_image_script}}" --url <url_from_query> --token <token_from_query> --path "<absolute image path>" --caption "<optional caption>"
|
|
9
15
|
```
|
|
10
16
|
|
|
11
17
|
### Direct HTTP
|
|
12
|
-
|
|
13
18
|
```http
|
|
14
|
-
POST
|
|
15
|
-
Authorization: Bearer
|
|
19
|
+
POST <url_from_query>
|
|
20
|
+
Authorization: Bearer <token_from_query>
|
|
16
21
|
Content-Type: application/json; charset=utf-8
|
|
17
22
|
|
|
18
23
|
{"path":"<absolute image path>","caption":"<optional caption>"}
|
|
@@ -7,5 +7,6 @@ Current working directory: {{cwd}}
|
|
|
7
7
|
|
|
8
8
|
Use local endpoints instead of calling Feishu Open Platform directly.
|
|
9
9
|
|
|
10
|
+
- **Get send tokens**: `GET {{session_grants_url}}?sid={{session_id}}` — returns `{ image: {url,token}, file: {url,token} }`
|
|
10
11
|
- **Images** (send & receive): read `{{im_skills_cache_dir}}/feishu-skill/receive-send-image.md`
|
|
11
12
|
- **Files & Videos** (send & download): read `{{im_skills_cache_dir}}/feishu-skill/receive-send-file.md`
|
package/package.json
CHANGED
|
@@ -232,7 +232,7 @@ describe("buildCdCard", () => {
|
|
|
232
232
|
it("shows current working directory in markdown", () => {
|
|
233
233
|
const card = buildCdCard("/home/project", entries, []);
|
|
234
234
|
const parsed = JSON.parse(card);
|
|
235
|
-
const cwdContent = mdContents(parsed).find((c) => c.includes("
|
|
235
|
+
const cwdContent = mdContents(parsed).find((c) => c.includes("本会话默认工作路径"));
|
|
236
236
|
expect(cwdContent).toBeDefined();
|
|
237
237
|
expect(cwdContent).toContain("/home/project");
|
|
238
238
|
});
|
|
@@ -23,9 +23,7 @@ describe("IM skills prompt rendering", () => {
|
|
|
23
23
|
"---",
|
|
24
24
|
"name: feishu-skill",
|
|
25
25
|
"---",
|
|
26
|
-
"
|
|
27
|
-
"Authorization: Bearer {{send_image_token}}",
|
|
28
|
-
"Content-Type: application/json; charset=utf-8",
|
|
26
|
+
"GET {{session_grants_url}}?sid={{session_id}}",
|
|
29
27
|
"cwd={{cwd}}",
|
|
30
28
|
].join("\n"),
|
|
31
29
|
"utf-8",
|
|
@@ -35,15 +33,13 @@ describe("IM skills prompt rendering", () => {
|
|
|
35
33
|
skillsDir: tempRoot,
|
|
36
34
|
variables: {
|
|
37
35
|
cwd: "C:/work",
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
session_grants_url: "http://127.0.0.1:18080/api/agent/session-grants",
|
|
37
|
+
session_id: "sid_test",
|
|
40
38
|
},
|
|
41
39
|
});
|
|
42
40
|
|
|
43
41
|
expect(prompt).toContain("[ChatCCC IM skill: feishu-skill]");
|
|
44
|
-
expect(prompt).toContain("
|
|
45
|
-
expect(prompt).toContain("Authorization: Bearer tok_test");
|
|
46
|
-
expect(prompt).toContain("Content-Type: application/json; charset=utf-8");
|
|
42
|
+
expect(prompt).toContain("GET http://127.0.0.1:18080/api/agent/session-grants?sid=sid_test");
|
|
47
43
|
expect(prompt).toContain("cwd=C:/work");
|
|
48
44
|
expect(prompt).not.toContain("{{");
|
|
49
45
|
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
import type { AgentImageGrant } from "./agent-image-rpc.ts";
|
|
4
|
+
import type { AgentFileGrant } from "./agent-file-rpc.ts";
|
|
5
|
+
import { ts } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
export const AGENT_SESSION_GRANTS_PATH = "/api/agent/session-grants";
|
|
8
|
+
|
|
9
|
+
interface SessionGrantsEntry {
|
|
10
|
+
image: AgentImageGrant;
|
|
11
|
+
file: AgentFileGrant;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sessionGrants = new Map<string, SessionGrantsEntry>();
|
|
15
|
+
|
|
16
|
+
export function setSessionGrants(
|
|
17
|
+
sessionId: string,
|
|
18
|
+
imageGrant: AgentImageGrant,
|
|
19
|
+
fileGrant: AgentFileGrant,
|
|
20
|
+
): void {
|
|
21
|
+
sessionGrants.set(sessionId, { image: imageGrant, file: fileGrant });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearSessionGrants(sessionId: string): void {
|
|
25
|
+
sessionGrants.delete(sessionId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
29
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
30
|
+
res.end(JSON.stringify(data));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function handleAgentGrantsRequest(
|
|
34
|
+
req: IncomingMessage,
|
|
35
|
+
res: ServerResponse,
|
|
36
|
+
): Promise<boolean> {
|
|
37
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
38
|
+
if (url.pathname !== AGENT_SESSION_GRANTS_PATH) return false;
|
|
39
|
+
|
|
40
|
+
if (req.method !== "GET") {
|
|
41
|
+
jsonReply(res, 405, { ok: false, error: "Method not allowed" });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sid = url.searchParams.get("sid");
|
|
46
|
+
if (!sid) {
|
|
47
|
+
jsonReply(res, 400, { ok: false, error: "Missing sid parameter" });
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const entry = sessionGrants.get(sid);
|
|
52
|
+
if (!entry) {
|
|
53
|
+
jsonReply(res, 404, { ok: false, error: "No active grants for this session" });
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
if (entry.image.expiresAt <= now || entry.file.expiresAt <= now) {
|
|
59
|
+
sessionGrants.delete(sid);
|
|
60
|
+
jsonReply(res, 410, { ok: false, error: "Session grants expired" });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`[${ts()}] [AGENT-GRANTS] query session=${sid}`);
|
|
65
|
+
jsonReply(res, 200, {
|
|
66
|
+
ok: true,
|
|
67
|
+
image: { url: entry.image.url, token: entry.image.token },
|
|
68
|
+
file: { url: entry.file.url, token: entry.file.token },
|
|
69
|
+
});
|
|
70
|
+
return true;
|
|
71
|
+
}
|
package/src/cards.ts
CHANGED
|
@@ -108,17 +108,17 @@ export function buildProgressCard(
|
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
export function buildHelpCard(
|
|
112
|
-
userText: string,
|
|
113
|
-
opts: { greeting?: string; defaultToolLabel?: string } = {},
|
|
114
|
-
): string {
|
|
115
|
-
const greeting = opts.greeting ?? `你发送了: ${userText}`;
|
|
116
|
-
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
117
|
-
const lines = [
|
|
118
|
-
`发送 **/new** 创建新会话(默认 ${defaultToolLabel})`,
|
|
119
|
-
"发送 **/new claude** 创建新 Claude 对话",
|
|
120
|
-
"发送 **/new cursor** 创建新 Cursor 会话",
|
|
121
|
-
"发送 **/new codex** 创建新 Codex 会话",
|
|
111
|
+
export function buildHelpCard(
|
|
112
|
+
userText: string,
|
|
113
|
+
opts: { greeting?: string; defaultToolLabel?: string } = {},
|
|
114
|
+
): string {
|
|
115
|
+
const greeting = opts.greeting ?? `你发送了: ${userText}`;
|
|
116
|
+
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
117
|
+
const lines = [
|
|
118
|
+
`发送 **/new** 创建新会话(默认 ${defaultToolLabel})`,
|
|
119
|
+
"发送 **/new claude** 创建新 Claude 对话",
|
|
120
|
+
"发送 **/new cursor** 创建新 Cursor 会话",
|
|
121
|
+
"发送 **/new codex** 创建新 Codex 会话",
|
|
122
122
|
"发送 **/forget** 重置当前会话(保留工作目录,同一群内继续)",
|
|
123
123
|
].join("\n");
|
|
124
124
|
return JSON.stringify({
|
|
@@ -128,7 +128,7 @@ export function buildHelpCard(
|
|
|
128
128
|
{ tag: "div", text: { tag: "lark_md", content: greeting } },
|
|
129
129
|
{ tag: "div", text: { tag: "lark_md", content: lines } },
|
|
130
130
|
buildButtons([
|
|
131
|
-
{ text: `新建默认会话(/new,${defaultToolLabel})`, value: JSON.stringify({ cmd: "new" }), type: "primary" },
|
|
131
|
+
{ text: `新建默认会话(/new,${defaultToolLabel})`, value: JSON.stringify({ cmd: "new" }), type: "primary" },
|
|
132
132
|
{ text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
|
|
133
133
|
{ text: "新建 Codex 会话(/new codex)", value: JSON.stringify({ cmd: "new codex" }), type: "primary" },
|
|
134
134
|
{ text: "重启 ChatCCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
|
|
@@ -155,15 +155,15 @@ export function buildCdContent(
|
|
|
155
155
|
: "";
|
|
156
156
|
|
|
157
157
|
const statusLine = isUpdate
|
|
158
|
-
?
|
|
159
|
-
:
|
|
158
|
+
? `**本会话默认工作路径(已切换):** \`${dirPath}\``
|
|
159
|
+
: `**本会话默认工作路径:** \`${dirPath}\``;
|
|
160
160
|
|
|
161
161
|
const lines: string[] = [];
|
|
162
162
|
if (currentLine) lines.push(currentLine, "");
|
|
163
163
|
lines.push(
|
|
164
164
|
statusLine,
|
|
165
165
|
``,
|
|
166
|
-
|
|
166
|
+
`此路径仅影响**本会话中新建**的会话工作路径。`,
|
|
167
167
|
``,
|
|
168
168
|
`---`,
|
|
169
169
|
`**目录内容** (最多 ${maxFiles} 个):`,
|
|
@@ -205,7 +205,7 @@ export function buildCdCard(
|
|
|
205
205
|
}
|
|
206
206
|
elements.push({
|
|
207
207
|
tag: "div",
|
|
208
|
-
text: { tag: "lark_md", content:
|
|
208
|
+
text: { tag: "lark_md", content: `**本会话默认工作路径:** \`${dirPath}\`` },
|
|
209
209
|
});
|
|
210
210
|
|
|
211
211
|
if (recentDirs.length > 0) {
|
|
@@ -252,15 +252,15 @@ export function buildCdCard(
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
// 所有会话列表卡片(Claude Code 优先,然后 Cursor)
|
|
255
|
-
export function buildSessionsCard(sessions: Array<{
|
|
256
|
-
sessionId: string;
|
|
257
|
-
active: boolean;
|
|
258
|
-
turnCount: number;
|
|
259
|
-
elapsedSeconds: number | null;
|
|
260
|
-
model: string;
|
|
261
|
-
tool: string;
|
|
262
|
-
}>, opts: { defaultToolLabel?: string } = {}): string {
|
|
263
|
-
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
255
|
+
export function buildSessionsCard(sessions: Array<{
|
|
256
|
+
sessionId: string;
|
|
257
|
+
active: boolean;
|
|
258
|
+
turnCount: number;
|
|
259
|
+
elapsedSeconds: number | null;
|
|
260
|
+
model: string;
|
|
261
|
+
tool: string;
|
|
262
|
+
}>, opts: { defaultToolLabel?: string } = {}): string {
|
|
263
|
+
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
264
264
|
// 按 tool 分组排序:Claude Code 在前,Cursor 其次,Codex 最后
|
|
265
265
|
const claudeCodeSessions = sessions.filter(s => s.tool !== "cursor" && s.tool !== "codex");
|
|
266
266
|
const cursorSessions = sessions.filter(s => s.tool === "cursor");
|
|
@@ -274,7 +274,7 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
274
274
|
config: { wide_screen_mode: true },
|
|
275
275
|
header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
|
|
276
276
|
elements: [
|
|
277
|
-
{ tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。` } },
|
|
277
|
+
{ tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。` } },
|
|
278
278
|
{ tag: "hr" },
|
|
279
279
|
{ tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
|
|
280
280
|
],
|
|
@@ -360,4 +360,4 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
|
|
|
360
360
|
},
|
|
361
361
|
],
|
|
362
362
|
});
|
|
363
|
-
}
|
|
363
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -547,9 +547,11 @@ export function reloadConfigFromDisk(): void {
|
|
|
547
547
|
applyLoadedConfig(loadConfig());
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
-
// 新建会话的默认工作路径(/cd
|
|
550
|
+
// 新建会话的默认工作路径(/cd 命令设置,按 chatId 持久化到本地文件)
|
|
551
551
|
// 该路径仅影响通过 /new 新建的会话,不影响已有会话的 resume。
|
|
552
|
-
export
|
|
552
|
+
export function getDefaultCwdFile(chatId: string): string {
|
|
553
|
+
return join(USER_DATA_DIR, "state", `working_dir_${chatId}.txt`);
|
|
554
|
+
}
|
|
553
555
|
|
|
554
556
|
/** 会话工具类型持久化文件 */
|
|
555
557
|
export const SESSIONS_FILE = join(USER_DATA_DIR, "state", "sessions.json");
|
|
@@ -583,24 +585,26 @@ export async function addRecentDir(dir: string): Promise<void> {
|
|
|
583
585
|
}
|
|
584
586
|
}
|
|
585
587
|
|
|
586
|
-
/** 读取 /cd
|
|
587
|
-
export async function getDefaultCwd(): Promise<string> {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
588
|
+
/** 读取 /cd 设置的默认工作路径。若 chatId 对应的文件不存在或路径已失效,回退到用户主目录。 */
|
|
589
|
+
export async function getDefaultCwd(chatId?: string): Promise<string> {
|
|
590
|
+
if (chatId) {
|
|
591
|
+
try {
|
|
592
|
+
const content = await readFile(getDefaultCwdFile(chatId), "utf-8");
|
|
593
|
+
const dir = content.trim();
|
|
594
|
+
if (dir) {
|
|
595
|
+
try {
|
|
596
|
+
const s = await stat(dir);
|
|
597
|
+
if (s.isDirectory()) return dir;
|
|
598
|
+
} catch { /* path gone, fall through */ }
|
|
599
|
+
}
|
|
600
|
+
} catch { /* file doesn't exist yet */ }
|
|
601
|
+
}
|
|
598
602
|
return homedir();
|
|
599
603
|
}
|
|
600
604
|
|
|
601
|
-
/** 设置新建会话的默认工作路径(由 /cd
|
|
602
|
-
export async function setDefaultCwd(dir: string): Promise<void> {
|
|
603
|
-
await writeFile(
|
|
605
|
+
/** 设置新建会话的默认工作路径(由 /cd 命令调用,按 chatId 持久化) */
|
|
606
|
+
export async function setDefaultCwd(dir: string, chatId: string): Promise<void> {
|
|
607
|
+
await writeFile(getDefaultCwdFile(chatId), dir, "utf-8");
|
|
604
608
|
}
|
|
605
609
|
|
|
606
610
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -84,6 +84,7 @@ import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buil
|
|
|
84
84
|
import { updateCardKitCard } from "./cardkit.ts";
|
|
85
85
|
import { handleAgentImageRequest } from "./agent-image-rpc.ts";
|
|
86
86
|
import { handleAgentFileRequest } from "./agent-file-rpc.ts";
|
|
87
|
+
import { handleAgentGrantsRequest } from "./agent-grants-rpc.ts";
|
|
87
88
|
import { formatGitResult, gitResultHeaderTemplate, runGitCommand } from "./git-command.ts";
|
|
88
89
|
import {
|
|
89
90
|
MAX_PROCESSED,
|
|
@@ -275,7 +276,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
275
276
|
if (text === "/cd" || text.startsWith("/cd ")) {
|
|
276
277
|
logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
|
|
277
278
|
const cdToken = await getTenantAccessToken();
|
|
278
|
-
const currentDir = await getDefaultCwd();
|
|
279
|
+
const currentDir = await getDefaultCwd(chatId);
|
|
279
280
|
|
|
280
281
|
// 获取当前会话的实际工作路径(若在会话群内)
|
|
281
282
|
let sessionCwd: string | undefined;
|
|
@@ -318,7 +319,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
318
319
|
// Change working dir if user provided a path
|
|
319
320
|
const isUpdate = !!arg && targetDir !== currentDir;
|
|
320
321
|
if (isUpdate) {
|
|
321
|
-
await setDefaultCwd(targetDir);
|
|
322
|
+
await setDefaultCwd(targetDir, chatId);
|
|
322
323
|
await addRecentDir(targetDir);
|
|
323
324
|
}
|
|
324
325
|
|
|
@@ -395,7 +396,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
395
396
|
let sessionId: string;
|
|
396
397
|
let sessionCwd: string;
|
|
397
398
|
try {
|
|
398
|
-
const init = await initClaudeSession(tool);
|
|
399
|
+
const init = await initClaudeSession(tool, undefined, chatId);
|
|
399
400
|
sessionId = init.sessionId;
|
|
400
401
|
sessionCwd = init.cwd;
|
|
401
402
|
console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
|
|
@@ -476,7 +477,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
476
477
|
const prefix = text.slice(0, MAX_PREFIX);
|
|
477
478
|
const adapter = getAdapterForTool(descriptionTool);
|
|
478
479
|
const info = await adapter.getSessionInfo(sessionId).catch(() => undefined);
|
|
479
|
-
const sessionCwd = info?.cwd ?? (await getDefaultCwd());
|
|
480
|
+
const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
480
481
|
const newName = sessionChatName(prefix, sessionCwd);
|
|
481
482
|
try {
|
|
482
483
|
await updateChatInfo(freshToken, chatId, newName, description);
|
|
@@ -578,9 +579,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
578
579
|
let cwd: string;
|
|
579
580
|
try {
|
|
580
581
|
const info = await adapter.getSessionInfo(sessionId);
|
|
581
|
-
cwd = info?.cwd ?? (await getDefaultCwd());
|
|
582
|
+
cwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
582
583
|
} catch {
|
|
583
|
-
cwd = await getDefaultCwd();
|
|
584
|
+
cwd = await getDefaultCwd(chatId);
|
|
584
585
|
}
|
|
585
586
|
|
|
586
587
|
const existing = chatSessionMap.get(chatId);
|
|
@@ -1058,7 +1059,7 @@ async function main(): Promise<void> {
|
|
|
1058
1059
|
});
|
|
1059
1060
|
});
|
|
1060
1061
|
setExtraApiHandler(async (req, res) => {
|
|
1061
|
-
return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
|
|
1062
|
+
return (await handleAgentGrantsRequest(req, res)) || (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
|
|
1062
1063
|
});
|
|
1063
1064
|
|
|
1064
1065
|
console.log(`[启动 2/7] 环境与凭证检查`);
|
package/src/session.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
createAgentFileGrant,
|
|
41
41
|
revokeAgentFileGrant,
|
|
42
42
|
} from "./agent-file-rpc.ts";
|
|
43
|
+
import { setSessionGrants, clearSessionGrants, AGENT_SESSION_GRANTS_PATH } from "./agent-grants-rpc.ts";
|
|
43
44
|
import { buildImSkillsPrompt, exportSkillSubDocs } from "./im-skills.ts";
|
|
44
45
|
|
|
45
46
|
// ---------------------------------------------------------------------------
|
|
@@ -288,8 +289,8 @@ function formatToolConfigForLog(tool: string, sessionModel?: string): string {
|
|
|
288
289
|
return `model=${anthropicConfigDisplay(CLAUDE_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
export async function initClaudeSession(tool: string, overrideCwd?: string): Promise<{ sessionId: string; cwd: string }> {
|
|
292
|
-
const cwd = overrideCwd ?? (await getDefaultCwd());
|
|
292
|
+
export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
|
|
293
|
+
const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
|
|
293
294
|
const adapter = getAdapterForTool(tool);
|
|
294
295
|
console.log(
|
|
295
296
|
`[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`
|
|
@@ -318,7 +319,7 @@ export async function resumeAndPrompt(
|
|
|
318
319
|
const tid = traceId ?? "";
|
|
319
320
|
const adapter = getAdapterForTool(tool);
|
|
320
321
|
const info = await adapter.getSessionInfo(sessionId);
|
|
321
|
-
const cwd = info?.cwd ?? (await getDefaultCwd());
|
|
322
|
+
const cwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
322
323
|
if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(chatId)?.turnCount ?? 0) + 1 });
|
|
323
324
|
console.log(
|
|
324
325
|
`[${ts()}] Resuming ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
|
|
@@ -341,15 +342,15 @@ export async function resumeAndPrompt(
|
|
|
341
342
|
const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
|
|
342
343
|
const skillVariables = {
|
|
343
344
|
cwd,
|
|
345
|
+
session_id: sessionId,
|
|
344
346
|
im_skills_cache_dir: imSkillsCacheDir,
|
|
347
|
+
session_grants_url: `http://127.0.0.1:${CHATCCC_PORT}${AGENT_SESSION_GRANTS_PATH}`,
|
|
345
348
|
send_image_url: imageGrant.url,
|
|
346
|
-
send_image_token: imageGrant.token,
|
|
347
|
-
send_file_url: fileGrant.url,
|
|
348
|
-
send_file_token: fileGrant.token,
|
|
349
349
|
send_image_script: join(feishuSkillDir, "send-image.mjs"),
|
|
350
350
|
send_file_script: join(feishuSkillDir, "send-file.mjs"),
|
|
351
351
|
download_video_script: join(feishuSkillDir, "download-video.mjs"),
|
|
352
352
|
};
|
|
353
|
+
setSessionGrants(sessionId, imageGrant, fileGrant);
|
|
353
354
|
const imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
|
|
354
355
|
// 渲染子文档到缓存目录,供 Agent 按需读取
|
|
355
356
|
await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
|
|
@@ -516,6 +517,7 @@ export async function resumeAndPrompt(
|
|
|
516
517
|
if (sendInterval) clearInterval(sendInterval);
|
|
517
518
|
revokeAgentImageGrant(imageGrant.token);
|
|
518
519
|
revokeAgentFileGrant(fileGrant.token);
|
|
520
|
+
clearSessionGrants(sessionId);
|
|
519
521
|
}
|
|
520
522
|
|
|
521
523
|
const cEntry = chatSessionMap.get(chatId);
|