chatccc 0.2.19 → 0.2.21

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
@@ -12,7 +12,7 @@ ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
12
12
 
13
13
  - **手机上也能用 Claude Code / Cursor / Codex** —— 在飞书群里发消息就等于在终端输入指令,AI 的思考和回复会流式展示在群卡片里
14
14
  - **多会话并行** —— 一个群就是一个 AI 会话,完全隔离、互不干扰,并行工作效率更高
15
- - **多工具切换** —— `/new claude` 创建 Claude Code 会话,`/new cursor` 创建 Cursor 会话,`/new codex` 创建 Codex 会话,各取所长
15
+ - **多工具切换** —— `/new` 使用默认 Agent 创建会话,也可用 `/new claude`、`/new cursor`、`/new codex` 指定工具,各取所长
16
16
 
17
17
  一句话:**在任何设备上打开飞书,就能让 Claude Code / Cursor / Codex 帮你写代码、排查问题、分析项目。**
18
18
 
@@ -118,7 +118,7 @@ ChatCCC 启动时会按以下顺序确定 Cursor Agent CLI:
118
118
 
119
119
  > 旧版字段 `cursor.command` 仍可读取(启动时会打印一次 warning 提示改名),新配置请统一使用 `cursor.path`。
120
120
 
121
- > **说明**:只使用 Claude Code(`/new claude` `/new`)的用户无需安装 Cursor CLI。
121
+ > **说明**:只使用 Claude Code(`/new claude`,或把 Claude Code 设为默认 Agent 后使用 `/new`)的用户无需安装 Cursor CLI。
122
122
 
123
123
  #### Codex CLI(使用 Codex 会话时需要)
124
124
 
@@ -215,21 +215,24 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
215
215
  },
216
216
  "port": 18080,
217
217
  "gitTimeoutSeconds": 180,
218
- "claude": {
219
- "enabled": false,
220
- "model": "",
218
+ "claude": {
219
+ "enabled": false,
220
+ "defaultAgent": true,
221
+ "model": "",
221
222
  "effort": "",
222
223
  "apiKey": "",
223
224
  "baseUrl": ""
224
225
  },
225
- "cursor": {
226
- "enabled": false,
227
- "path": "",
226
+ "cursor": {
227
+ "enabled": false,
228
+ "defaultAgent": false,
229
+ "path": "",
228
230
  "model": ""
229
231
  },
230
- "codex": {
231
- "enabled": false,
232
- "path": "",
232
+ "codex": {
233
+ "enabled": false,
234
+ "defaultAgent": false,
235
+ "path": "",
233
236
  "model": "",
234
237
  "effort": ""
235
238
  }
@@ -242,9 +245,10 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
242
245
  | --- | --- | --- |
243
246
  | `feishu.appId` / `feishu.appSecret` | 是 | 飞书应用「凭证与基础信息」中的 App ID / App Secret |
244
247
  | `port` | 否 | 本地 WebSocket 中继 + Web 向导监听端口,默认 `18080`;同机多实例时改成不同值即可 |
245
- | `gitTimeoutSeconds` | 否 | `/git` 命令在会话工作目录执行时的单次超时秒数,默认 `180`,允许范围 `1–3600`,超时会被 `SIGKILL` 强制终止 |
246
- | `claude.enabled` / `cursor.enabled` / `codex.enabled` | 否 | 是否启用对应 AI Agent;Web 向导/管理页只展示 `enabled: true` 的 agent 卡片。字段缺省时按「任一配置字段非空」自动判定(向后兼容) |
247
- | `claude.model` | 否 | Claude Code 会话使用的模型;留空(`""` / 全空白)→ 不向 SDK `model`,由 SDK / 服务商默认决定 |
248
+ | `gitTimeoutSeconds` | 否 | `/git` 命令在会话工作目录执行时的单次超时秒数,默认 `180`,允许范围 `1–3600`,超时会被 `SIGKILL` 强制终止 |
249
+ | `claude.enabled` / `cursor.enabled` / `codex.enabled` | 否 | 是否启用对应 AI Agent;Web 向导/管理页只展示 `enabled: true` 的 agent 卡片。字段缺省时按「任一配置字段非空」自动判定(向后兼容) |
250
+ | `claude.defaultAgent` / `cursor.defaultAgent` / `codex.defaultAgent` | 否 | `/new` 未指定具体 Agent 时使用哪个默认 Agent;同一时间应只有一个为 `true`。Web 配置页切换某个 Agent 为默认时会自动关闭其它 Agent 的默认开关 |
251
+ | `claude.model` | 否 | Claude Code 会话使用的模型;留空(`""` / 全空白)→ 不向 SDK 传 `model`,由 SDK / 服务商默认决定 |
248
252
  | `claude.effort` | 否 | Claude 思考深度(如 `low` / `medium` / `high` / `max`);留空 → 不向 SDK 传 `effort` |
249
253
  | `claude.apiKey` | 否 | 第三方 Anthropic 兼容网关的 API 密钥;**官方 Claude 用户保持 `""` 即可**,详见下文「第三方 API」一节 |
250
254
  | `claude.baseUrl` | 否 | 第三方 Anthropic 兼容网关的 base URL(例如 `https://api.deepseek.com/anthropic`);**官方 Claude 用户保持 `""` 即可** |
@@ -289,14 +293,14 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
289
293
 
290
294
  ### 5. 开始使用
291
295
 
292
- 在飞书中找到你的机器人,发送 `/new`(默认 Claude Code)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
296
+ 在飞书中找到你的机器人,发送 `/new`(使用 `config.json` 中 `defaultAgent: true` 的默认 Agent)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
293
297
 
294
298
  ### 可用指令
295
299
 
296
300
 
297
301
  | 指令 | 作用 |
298
302
  | --------------- | ---------------------------- |
299
- | `/new` | 创建新的 Claude Code 会话(默认) |
303
+ | `/new` | 使用默认 Agent 创建新会话 |
300
304
  | `/new claude` | 创建新的 Claude Code 会话 |
301
305
  | `/new cursor` | 创建新的 Cursor 会话 |
302
306
  | `/new codex` | 创建新的 Codex 会话(OpenAI) |
@@ -313,4 +317,4 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
313
317
 
314
318
  ## 技术栈
315
319
 
316
- TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI (OpenAI) / 飞书 WebSocket API / CardKit
320
+ TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI (OpenAI) / 飞书 WebSocket API / CardKit
@@ -7,6 +7,7 @@
7
7
  "gitTimeoutSeconds": 180,
8
8
  "claude": {
9
9
  "enabled": false,
10
+ "defaultAgent": true,
10
11
  "model": "",
11
12
  "effort": "",
12
13
  "apiKey": "",
@@ -14,11 +15,13 @@
14
15
  },
15
16
  "cursor": {
16
17
  "enabled": false,
18
+ "defaultAgent": false,
17
19
  "path": "",
18
20
  "model": ""
19
21
  },
20
22
  "codex": {
21
23
  "enabled": false,
24
+ "defaultAgent": false,
22
25
  "path": "",
23
26
  "model": "",
24
27
  "effort": ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -16,10 +16,11 @@ import {
16
16
  CURSOR_AGENT_COMMAND,
17
17
  GIT_TIMEOUT_MS,
18
18
  GIT_TIMEOUT_SECONDS,
19
- applyLoadedConfig,
20
- config,
21
- type AppConfig,
22
- } from "../config.ts";
19
+ applyLoadedConfig,
20
+ config,
21
+ resolveDefaultAgentTool,
22
+ type AppConfig,
23
+ } from "../config.ts";
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // applyLoadedConfig — setup → service「在线切换」时刷新进程内 config 的核心机制
@@ -36,15 +37,16 @@ const baseAppConfig: AppConfig = {
36
37
  feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
37
38
  port: 18080,
38
39
  gitTimeoutSeconds: 180,
39
- claude: {
40
- enabled: true,
41
- model: "initial-model",
40
+ claude: {
41
+ enabled: true,
42
+ defaultAgent: true,
43
+ model: "initial-model",
42
44
  effort: "initial-effort",
43
45
  apiKey: "sk-initial",
44
46
  baseUrl: "https://initial.gw/anthropic",
45
47
  },
46
- cursor: { enabled: true, path: "/initial/cursor", model: "initial-cursor-model" },
47
- codex: { enabled: true, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
48
+ cursor: { enabled: true, defaultAgent: false, path: "/initial/cursor", model: "initial-cursor-model" },
49
+ codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
48
50
  };
49
51
 
50
52
  // 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
@@ -75,9 +77,10 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
75
77
  it("更新 Claude 配置(model / effort / apiKey / baseUrl)", () => {
76
78
  applyLoadedConfig({
77
79
  ...structuredClone(baseAppConfig),
78
- claude: {
79
- enabled: true,
80
- model: "deepseek-v4-pro",
80
+ claude: {
81
+ enabled: true,
82
+ defaultAgent: true,
83
+ model: "deepseek-v4-pro",
81
84
  effort: "high",
82
85
  apiKey: "sk-newkey",
83
86
  baseUrl: "https://gw2.example/anthropic",
@@ -104,7 +107,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
104
107
  it("CURSOR_AGENT_ARGS 跟随 cursor.model 重新解析", () => {
105
108
  applyLoadedConfig({
106
109
  ...structuredClone(baseAppConfig),
107
- cursor: { enabled: true, path: "/x/cursor", model: "claude-3.7-sonnet" },
110
+ cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet" },
108
111
  });
109
112
 
110
113
  // CURSOR_AGENT_ARGS 是 ['-p', '--force', ..., '--model', 'claude-3.7-sonnet']
@@ -115,7 +118,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
115
118
  it("cursor.model 留空时 CURSOR_AGENT_ARGS 不含 --model", () => {
116
119
  applyLoadedConfig({
117
120
  ...structuredClone(baseAppConfig),
118
- cursor: { enabled: true, path: "/x/cursor", model: "" },
121
+ cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "" },
119
122
  });
120
123
 
121
124
  expect(CURSOR_AGENT_ARGS).not.toContain("--model");
@@ -124,7 +127,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
124
127
  it("CURSOR_AGENT_COMMAND 优先取 config.cursor.path", () => {
125
128
  applyLoadedConfig({
126
129
  ...structuredClone(baseAppConfig),
127
- cursor: { enabled: true, path: "C:/custom/cursor.exe", model: "" },
130
+ cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "" },
128
131
  });
129
132
 
130
133
  expect(CURSOR_AGENT_COMMAND).toBe("C:/custom/cursor.exe");
@@ -142,14 +145,14 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
142
145
  });
143
146
  });
144
147
 
145
- describe("applyLoadedConfig — config 对象引用契约", () => {
148
+ describe("applyLoadedConfig — config 对象引用契约", () => {
146
149
  it("config 引用保持不变(就地更新),但字段被刷新", () => {
147
150
  const refBefore = config;
148
151
 
149
152
  applyLoadedConfig({
150
153
  ...structuredClone(baseAppConfig),
151
154
  feishu: { appId: "REF_TEST_APP", appSecret: "REF_TEST_SECRET" },
152
- codex: { enabled: true, path: "/refresh/codex", model: "fresh-model", effort: "low" },
155
+ codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", effort: "low" },
153
156
  });
154
157
 
155
158
  // 必须是同一个引用:codex-adapter 等下游模块"直接 import config",
@@ -167,5 +170,24 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
167
170
  expect(APP_ID).toBe("");
168
171
  expect(APP_SECRET).toBe("");
169
172
  expect(config.feishu.appId).toBe("");
170
- });
171
- });
173
+ });
174
+ });
175
+
176
+ describe("resolveDefaultAgentTool", () => {
177
+ it("优先使用显式 defaultAgent 且已启用的 Agent", () => {
178
+ const cfg = structuredClone(baseAppConfig);
179
+ cfg.claude.defaultAgent = false;
180
+ cfg.cursor.defaultAgent = true;
181
+
182
+ expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
183
+ });
184
+
185
+ it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
186
+ const cfg = structuredClone(baseAppConfig);
187
+ cfg.claude.enabled = false;
188
+ cfg.claude.defaultAgent = true;
189
+ cfg.cursor.defaultAgent = false;
190
+
191
+ expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
192
+ });
193
+ });
package/src/cards.ts CHANGED
@@ -108,13 +108,17 @@ export function buildProgressCard(
108
108
  });
109
109
  }
110
110
 
111
- export function buildHelpCard(userText: string, opts: { greeting?: string } = {}): string {
112
- const greeting = opts.greeting ?? `你发送了: ${userText}`;
113
- const lines = [
114
- "发送 **/new** 创建新会话(默认 Claude Code)",
115
- "发送 **/new claude** 创建新 Claude 对话",
116
- "发送 **/new cursor** 创建新 Cursor 会话",
117
- "发送 **/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 会话",
118
122
  "发送 **/forget** 重置当前会话(保留工作目录,同一群内继续)",
119
123
  ].join("\n");
120
124
  return JSON.stringify({
@@ -124,7 +128,7 @@ export function buildHelpCard(userText: string, opts: { greeting?: string } = {}
124
128
  { tag: "div", text: { tag: "lark_md", content: greeting } },
125
129
  { tag: "div", text: { tag: "lark_md", content: lines } },
126
130
  buildButtons([
127
- { text: "新建 Claude Code 会话(/new claude)", value: JSON.stringify({ cmd: "new" }), type: "primary" },
131
+ { text: `新建默认会话(/new,${defaultToolLabel})`, value: JSON.stringify({ cmd: "new" }), type: "primary" },
128
132
  { text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
129
133
  { text: "新建 Codex 会话(/new codex)", value: JSON.stringify({ cmd: "new codex" }), type: "primary" },
130
134
  { text: "重启 ChatCCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
@@ -248,14 +252,15 @@ export function buildCdCard(
248
252
  }
249
253
 
250
254
  // 所有会话列表卡片(Claude Code 优先,然后 Cursor)
251
- export function buildSessionsCard(sessions: Array<{
252
- sessionId: string;
253
- active: boolean;
254
- turnCount: number;
255
- elapsedSeconds: number | null;
256
- model: string;
257
- tool: string;
258
- }>): string {
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";
259
264
  // 按 tool 分组排序:Claude Code 在前,Cursor 其次,Codex 最后
260
265
  const claudeCodeSessions = sessions.filter(s => s.tool !== "cursor" && s.tool !== "codex");
261
266
  const cursorSessions = sessions.filter(s => s.tool === "cursor");
@@ -269,7 +274,7 @@ export function buildSessionsCard(sessions: Array<{
269
274
  config: { wide_screen_mode: true },
270
275
  header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
271
276
  elements: [
272
- { tag: "div", text: { tag: "lark_md", content: "当前没有会话记录。\n\n使用 **/new**(默认 Claude Code)、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。" } },
277
+ { tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。` } },
273
278
  { tag: "hr" },
274
279
  { tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
275
280
  ],
@@ -355,4 +360,4 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
355
360
  },
356
361
  ],
357
362
  });
358
- }
363
+ }
package/src/config.ts CHANGED
@@ -58,6 +58,8 @@ export async function appendChatLog(chatId: string, sender: string, text: string
58
58
  export interface ClaudeConfig {
59
59
  /** 是否启用 Claude Code Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
60
60
  enabled: boolean;
61
+ /** 是否作为 /new 未指定工具时使用的默认 Agent */
62
+ defaultAgent: boolean;
61
63
  model: string;
62
64
  effort: string;
63
65
  apiKey: string;
@@ -67,6 +69,8 @@ export interface ClaudeConfig {
67
69
  export interface CursorConfig {
68
70
  /** 是否启用 Cursor Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
69
71
  enabled: boolean;
72
+ /** 是否作为 /new 未指定工具时使用的默认 Agent */
73
+ defaultAgent: boolean;
70
74
  /** Cursor Agent CLI 可执行文件绝对路径;留空时由运行时按 LocalAppData / PATH 兜底 */
71
75
  path: string;
72
76
  model: string;
@@ -75,6 +79,8 @@ export interface CursorConfig {
75
79
  export interface CodexConfig {
76
80
  /** 是否启用 Codex Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
77
81
  enabled: boolean;
82
+ /** 是否作为 /new 未指定工具时使用的默认 Agent */
83
+ defaultAgent: boolean;
78
84
  /** Codex CLI 可执行文件绝对路径;留空时退回到 PATH 中的 `codex` */
79
85
  path: string;
80
86
  model: string;
@@ -95,6 +101,9 @@ export interface AppConfig {
95
101
  codex: CodexConfig;
96
102
  }
97
103
 
104
+ export type AgentTool = "claude" | "cursor" | "codex";
105
+ export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
106
+
98
107
  const CONFIG_FILE = join(PROJECT_ROOT, "config.json");
99
108
  const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
100
109
 
@@ -203,9 +212,9 @@ function loadConfig(): AppConfig {
203
212
  feishu: { appId: "", appSecret: "" },
204
213
  port: 18080,
205
214
  gitTimeoutSeconds: 180,
206
- claude: { enabled: false, model: "", effort: "", apiKey: "", baseUrl: "" },
207
- cursor: { enabled: false, path: "", model: "claude-opus-4-7-max" },
208
- codex: { enabled: false, path: "", model: "", effort: "" },
215
+ claude: { enabled: false, defaultAgent: true, model: "", effort: "", apiKey: "", baseUrl: "" },
216
+ cursor: { enabled: false, defaultAgent: false, path: "", model: "claude-opus-4-7-max" },
217
+ codex: { enabled: false, defaultAgent: false, path: "", model: "", effort: "" },
209
218
  };
210
219
 
211
220
  if (!existsSync(CONFIG_FILE)) {
@@ -239,8 +248,8 @@ function loadConfig(): AppConfig {
239
248
 
240
249
  let parsed: Partial<AppConfig> & {
241
250
  claude?: Partial<ClaudeConfig> & { enabled?: unknown };
242
- cursor?: { enabled?: unknown; path?: unknown; command?: unknown; model?: unknown };
243
- codex?: { enabled?: unknown; path?: unknown; command?: unknown; model?: unknown; effort?: unknown };
251
+ cursor?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown };
252
+ codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; effort?: unknown };
244
253
  };
245
254
  try {
246
255
  parsed = JSON.parse(raw);
@@ -251,8 +260,8 @@ function loadConfig(): AppConfig {
251
260
 
252
261
  const feishu = parsed.feishu ?? { appId: "", appSecret: "" };
253
262
  const claude = parsed.claude ?? {} as Partial<ClaudeConfig>;
254
- const cursorRaw = parsed.cursor ?? {};
255
- const codexRaw = parsed.codex ?? {};
263
+ const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
264
+ const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
256
265
 
257
266
  // 兼容旧字段 `command`:命中时打印一次性 warning 提示用户改名
258
267
  const onLegacyField = (label: string, value: string): void => {
@@ -292,6 +301,21 @@ function loadConfig(): AppConfig {
292
301
  (typeof codexRaw.effort === "string" && (codexRaw.effort as string).trim()),
293
302
  );
294
303
 
304
+ const claudeEnabled = resolveEnabled(claude.enabled, claudeNonEmpty);
305
+ const cursorEnabled = resolveEnabled(cursorRaw.enabled, cursorNonEmpty);
306
+ const codexEnabled = resolveEnabled(codexRaw.enabled, codexNonEmpty);
307
+ const explicitDefaultTool: AgentTool | null =
308
+ typeof claude.defaultAgent === "boolean" && claude.defaultAgent && claudeEnabled ? "claude" :
309
+ typeof cursorRaw.defaultAgent === "boolean" && cursorRaw.defaultAgent && cursorEnabled ? "cursor" :
310
+ typeof codexRaw.defaultAgent === "boolean" && codexRaw.defaultAgent && codexEnabled ? "codex" :
311
+ null;
312
+ const fallbackDefaultTool: AgentTool =
313
+ claudeEnabled ? "claude" :
314
+ cursorEnabled ? "cursor" :
315
+ codexEnabled ? "codex" :
316
+ "claude";
317
+ const defaultTool = explicitDefaultTool ?? fallbackDefaultTool;
318
+
295
319
  return {
296
320
  feishu: {
297
321
  appId: feishu.appId ?? "",
@@ -300,19 +324,22 @@ function loadConfig(): AppConfig {
300
324
  port: typeof parsed.port === "number" ? parsed.port : 18080,
301
325
  gitTimeoutSeconds: typeof parsed.gitTimeoutSeconds === "number" ? parsed.gitTimeoutSeconds : 180,
302
326
  claude: {
303
- enabled: resolveEnabled(claude.enabled, claudeNonEmpty),
327
+ enabled: claudeEnabled,
328
+ defaultAgent: defaultTool === "claude",
304
329
  model: normalizeOptionalConfigField(claude.model, { label: "claude.model" }),
305
330
  effort: normalizeOptionalConfigField(claude.effort, { label: "claude.effort" }),
306
331
  apiKey: claude.apiKey ?? "",
307
332
  baseUrl: claude.baseUrl ?? "",
308
333
  },
309
334
  cursor: {
310
- enabled: resolveEnabled(cursorRaw.enabled, cursorNonEmpty),
335
+ enabled: cursorEnabled,
336
+ defaultAgent: defaultTool === "cursor",
311
337
  path: readToolCliPath(cursorRaw, { label: "cursor", onLegacyField }),
312
338
  model: normalizeOptionalConfigField(cursorRaw.model, { label: "cursor.model", fallback: "claude-opus-4-7-max" }),
313
339
  },
314
340
  codex: {
315
- enabled: resolveEnabled(codexRaw.enabled, codexNonEmpty),
341
+ enabled: codexEnabled,
342
+ defaultAgent: defaultTool === "codex",
316
343
  path: readToolCliPath(codexRaw, { label: "codex", onLegacyField }),
317
344
  model: normalizeOptionalConfigField(codexRaw.model, { label: "codex.model" }),
318
345
  effort: normalizeOptionalConfigField(codexRaw.effort, { label: "codex.effort" }),
@@ -612,5 +639,13 @@ export function toolDisplayName(tool: string): string {
612
639
  return "Claude Code";
613
640
  }
614
641
 
642
+ /** 解析 /new 未指定工具时使用的默认 Agent。旧配置缺省 defaultAgent 时保持 Claude 优先。 */
643
+ export function resolveDefaultAgentTool(cfg: AppConfig = config): AgentTool {
644
+ const explicit = AGENT_TOOLS.find((tool) => cfg[tool].enabled && cfg[tool].defaultAgent);
645
+ if (explicit) return explicit;
646
+ const enabledFallback = AGENT_TOOLS.find((tool) => cfg[tool].enabled);
647
+ return enabledFallback ?? "claude";
648
+ }
649
+
615
650
  // 导出 config 对象供其他模块直接访问原始配置
616
- export { CONFIG_FILE, CONFIG_SAMPLE_FILE };
651
+ export { CONFIG_FILE, CONFIG_SAMPLE_FILE };
package/src/feishu-api.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { readdir, stat, readFile, mkdir, writeFile } from "node:fs/promises";
2
- import { resolve as resolvePath } from "node:path";
3
- import { dirname, join } from "node:path";
1
+ import { readdir, stat, readFile, mkdir, writeFile } from "node:fs/promises";
2
+ import { resolve as resolvePath } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import sharp from "sharp";
5
5
 
6
6
  import {
@@ -260,94 +260,94 @@ export function extractSessionId(description: string): string | null {
260
260
  // Avatar
261
261
  // ---------------------------------------------------------------------------
262
262
 
263
- const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
264
- const AVATAR_BADGE_DIR = resolvePath(AVATAR_DIR, "badges");
265
- const AVATAR_KEY_CACHE_FILE = resolvePath(PROJECT_ROOT, "state", "avatar-image-keys.json");
266
- const AVATAR_SOURCES: Record<string, string> = {
267
- new: resolvePath(AVATAR_DIR, "status_new.png"),
268
- busy: resolvePath(AVATAR_DIR, "status_busy.png"),
269
- idle: resolvePath(AVATAR_DIR, "status_idle.png"),
270
- };
271
- const AVATAR_BADGES: Record<string, string> = {
272
- claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
273
- cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
274
- codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
275
- };
276
- const AVATAR_SIZE = 256;
277
- const BADGE_SIZE = 92;
278
- const BADGE_MARGIN = 10;
279
-
280
- const avatarKeyCache = new Map<string, string>();
281
- let avatarKeyCacheLoaded = false;
282
-
283
- function normalizeAvatarTool(tool: string): string {
284
- return AVATAR_BADGES[tool] ? tool : "claude";
285
- }
286
-
287
- function normalizeAvatarStatus(status: string): string {
288
- return AVATAR_SOURCES[status] ? status : "idle";
289
- }
290
-
291
- function avatarCacheKey(tool: string, status: string): string {
292
- return `${normalizeAvatarTool(tool)}:${normalizeAvatarStatus(status)}`;
293
- }
294
-
295
- async function loadAvatarKeyCache(): Promise<void> {
296
- if (avatarKeyCacheLoaded) return;
297
- avatarKeyCacheLoaded = true;
298
- try {
299
- const raw = await readFile(AVATAR_KEY_CACHE_FILE, "utf-8");
300
- const parsed = JSON.parse(raw) as Record<string, unknown>;
301
- for (const [key, value] of Object.entries(parsed)) {
302
- if (typeof value === "string" && value.trim()) avatarKeyCache.set(key, value);
303
- }
304
- } catch {
305
- // Missing or malformed cache should not block avatar updates.
306
- }
307
- }
308
-
309
- async function persistAvatarKeyCache(): Promise<void> {
310
- await mkdir(dirname(AVATAR_KEY_CACHE_FILE), { recursive: true });
311
- await writeFile(
312
- AVATAR_KEY_CACHE_FILE,
313
- JSON.stringify(Object.fromEntries(avatarKeyCache.entries()), null, 2),
314
- "utf-8",
315
- );
316
- }
317
-
318
- async function renderAvatar(tool: string, status: string): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
319
- const normalizedTool = normalizeAvatarTool(tool);
320
- const normalizedStatus = normalizeAvatarStatus(status);
321
- const badge = await sharp(await readFile(AVATAR_BADGES[normalizedTool]))
322
- .resize(BADGE_SIZE, BADGE_SIZE, {
323
- fit: "contain",
324
- kernel: sharp.kernel.lanczos3,
325
- background: { r: 0, g: 0, b: 0, alpha: 0 },
326
- })
327
- .png()
328
- .toBuffer();
329
-
330
- const jpeg = await sharp(await readFile(AVATAR_SOURCES[normalizedStatus]))
331
- .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" })
332
- .composite([{
333
- input: badge,
334
- left: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
335
- top: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
336
- }])
337
- .flatten({ background: "#ffffff" })
338
- .removeAlpha()
339
- .jpeg({ quality: 95, progressive: false })
263
+ const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
264
+ const AVATAR_BADGE_DIR = resolvePath(AVATAR_DIR, "badges");
265
+ const AVATAR_KEY_CACHE_FILE = resolvePath(PROJECT_ROOT, "state", "avatar-image-keys.json");
266
+ const AVATAR_SOURCES: Record<string, string> = {
267
+ new: resolvePath(AVATAR_DIR, "status_new.png"),
268
+ busy: resolvePath(AVATAR_DIR, "status_busy.png"),
269
+ idle: resolvePath(AVATAR_DIR, "status_idle.png"),
270
+ };
271
+ const AVATAR_BADGES: Record<string, string> = {
272
+ claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
273
+ cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
274
+ codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
275
+ };
276
+ const AVATAR_SIZE = 256;
277
+ const BADGE_SIZE = 92;
278
+ const BADGE_MARGIN = 10;
279
+
280
+ const avatarKeyCache = new Map<string, string>();
281
+ let avatarKeyCacheLoaded = false;
282
+
283
+ function normalizeAvatarTool(tool: string): string {
284
+ return AVATAR_BADGES[tool] ? tool : "claude";
285
+ }
286
+
287
+ function normalizeAvatarStatus(status: string): string {
288
+ return AVATAR_SOURCES[status] ? status : "idle";
289
+ }
290
+
291
+ function avatarCacheKey(tool: string, status: string): string {
292
+ return `${normalizeAvatarTool(tool)}:${normalizeAvatarStatus(status)}`;
293
+ }
294
+
295
+ async function loadAvatarKeyCache(): Promise<void> {
296
+ if (avatarKeyCacheLoaded) return;
297
+ avatarKeyCacheLoaded = true;
298
+ try {
299
+ const raw = await readFile(AVATAR_KEY_CACHE_FILE, "utf-8");
300
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
301
+ for (const [key, value] of Object.entries(parsed)) {
302
+ if (typeof value === "string" && value.trim()) avatarKeyCache.set(key, value);
303
+ }
304
+ } catch {
305
+ // Missing or malformed cache should not block avatar updates.
306
+ }
307
+ }
308
+
309
+ async function persistAvatarKeyCache(): Promise<void> {
310
+ await mkdir(dirname(AVATAR_KEY_CACHE_FILE), { recursive: true });
311
+ await writeFile(
312
+ AVATAR_KEY_CACHE_FILE,
313
+ JSON.stringify(Object.fromEntries(avatarKeyCache.entries()), null, 2),
314
+ "utf-8",
315
+ );
316
+ }
317
+
318
+ async function renderAvatar(tool: string, status: string): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
319
+ const normalizedTool = normalizeAvatarTool(tool);
320
+ const normalizedStatus = normalizeAvatarStatus(status);
321
+ const badge = await sharp(await readFile(AVATAR_BADGES[normalizedTool]))
322
+ .resize(BADGE_SIZE, BADGE_SIZE, {
323
+ fit: "contain",
324
+ kernel: sharp.kernel.lanczos3,
325
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
326
+ })
327
+ .png()
340
328
  .toBuffer();
341
329
 
342
- return {
343
- buffer: jpeg,
344
- contentType: "image/jpeg",
345
- filename: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
346
- };
347
- }
348
-
349
- async function uploadImage(token: string, tool: string, status: string): Promise<string> {
350
- const image = await renderAvatar(tool, status);
330
+ const jpeg = await sharp(await readFile(AVATAR_SOURCES[normalizedStatus]))
331
+ .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" })
332
+ .composite([{
333
+ input: badge,
334
+ left: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
335
+ top: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
336
+ }])
337
+ .flatten({ background: "#ffffff" })
338
+ .removeAlpha()
339
+ .jpeg({ quality: 95, progressive: false })
340
+ .toBuffer();
341
+
342
+ return {
343
+ buffer: jpeg,
344
+ contentType: "image/jpeg",
345
+ filename: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
346
+ };
347
+ }
348
+
349
+ async function uploadImage(token: string, tool: string, status: string): Promise<string> {
350
+ const image = await renderAvatar(tool, status);
351
351
  const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
352
352
  const form = new FormData();
353
353
  form.append("image_type", "avatar");
@@ -368,23 +368,23 @@ async function uploadImage(token: string, tool: string, status: string): Promise
368
368
  return data.data!.image_key!;
369
369
  }
370
370
 
371
- async function getOrUploadAvatarKey(token: string, tool: string, status: string): Promise<string> {
372
- await loadAvatarKeyCache();
373
- const keyName = avatarCacheKey(tool, status);
374
- const cached = avatarKeyCache.get(keyName);
371
+ async function getOrUploadAvatarKey(token: string, tool: string, status: string): Promise<string> {
372
+ await loadAvatarKeyCache();
373
+ const keyName = avatarCacheKey(tool, status);
374
+ const cached = avatarKeyCache.get(keyName);
375
375
  if (cached) return cached;
376
- const key = await uploadImage(token, tool, status);
377
- avatarKeyCache.set(keyName, key);
378
- await persistAvatarKeyCache().catch((err) => {
379
- console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
380
- });
376
+ const key = await uploadImage(token, tool, status);
377
+ avatarKeyCache.set(keyName, key);
378
+ await persistAvatarKeyCache().catch((err) => {
379
+ console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
380
+ });
381
381
  console.log(`[${ts()}] [AVATAR] Uploaded "${status}" → image_key=${key}`);
382
382
  return key;
383
383
  }
384
384
 
385
- export async function setChatAvatar(token: string, chatId: string, tool: string, status: string): Promise<void> {
386
- try {
387
- const avatarKey = await getOrUploadAvatarKey(token, tool, status);
385
+ export async function setChatAvatar(token: string, chatId: string, tool: string, status: string): Promise<void> {
386
+ try {
387
+ const avatarKey = await getOrUploadAvatarKey(token, tool, status);
388
388
  const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
389
389
  method: "PUT",
390
390
  headers: {
@@ -401,14 +401,122 @@ export async function setChatAvatar(token: string, chatId: string, tool: string,
401
401
  }
402
402
  if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
403
403
  } catch (err) {
404
- console.error(`[${ts()}] [AVATAR] setChatAvatar FAIL: chatId=${chatId} tool=${tool} status=${status} ${(err as Error).message}`);
405
- }
406
- }
404
+ console.error(`[${ts()}] [AVATAR] setChatAvatar FAIL: chatId=${chatId} tool=${tool} status=${status} ${(err as Error).message}`);
405
+ }
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Image download & cache
410
+ // ---------------------------------------------------------------------------
411
+
412
+ const IMAGE_DOWNLOAD_DIR = resolvePath(PROJECT_ROOT, "images", "downloads");
413
+ const IMAGE_CACHE_FILE = resolvePath(PROJECT_ROOT, "state", "image-cache.json");
414
+
415
+ const imageCache = new Map<string, string>();
416
+ let imageCacheLoaded = false;
417
+
418
+ async function loadImageCache(): Promise<void> {
419
+ if (imageCacheLoaded) return;
420
+ imageCacheLoaded = true;
421
+ try {
422
+ const raw = await readFile(IMAGE_CACHE_FILE, "utf-8");
423
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
424
+ for (const [key, value] of Object.entries(parsed)) {
425
+ if (typeof value === "string" && value.trim()) imageCache.set(key, value);
426
+ }
427
+ } catch { /* missing or malformed cache is not fatal */ }
428
+ }
429
+
430
+ async function persistImageCache(): Promise<void> {
431
+ await mkdir(dirname(IMAGE_CACHE_FILE), { recursive: true });
432
+ await writeFile(
433
+ IMAGE_CACHE_FILE,
434
+ JSON.stringify(Object.fromEntries(imageCache.entries()), null, 2),
435
+ "utf-8",
436
+ );
437
+ }
438
+
439
+ function extFromContentType(contentType: string | null): string {
440
+ if (!contentType) return ".png";
441
+ const mime = contentType.split(";")[0].trim().toLowerCase();
442
+ const map: Record<string, string> = {
443
+ "image/png": ".png",
444
+ "image/jpeg": ".jpg",
445
+ "image/webp": ".webp",
446
+ "image/gif": ".gif",
447
+ "image/bmp": ".bmp",
448
+ "image/svg+xml": ".svg",
449
+ };
450
+ return map[mime] ?? ".png";
451
+ }
452
+
453
+ async function downloadImage(token: string, messageId: string, fileKey: string): Promise<string> {
454
+ const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}/resources/${fileKey}?type=image`, {
455
+ headers: { Authorization: `Bearer ${token}` },
456
+ });
457
+ if (!resp.ok) {
458
+ const text = await resp.text().catch(() => "");
459
+ throw new Error(`downloadImage HTTP ${resp.status}: ${text.slice(0, 200)}`);
460
+ }
461
+ const ext = extFromContentType(resp.headers.get("content-type"));
462
+ const buffer = Buffer.from(await resp.arrayBuffer());
463
+ await mkdir(IMAGE_DOWNLOAD_DIR, { recursive: true });
464
+ const localPath = resolvePath(IMAGE_DOWNLOAD_DIR, `${fileKey}${ext}`);
465
+ await writeFile(localPath, buffer);
466
+ return localPath;
467
+ }
468
+
469
+ export async function getOrDownloadImage(token: string, messageId: string, fileKey: string): Promise<string> {
470
+ await loadImageCache();
471
+ const cached = imageCache.get(fileKey);
472
+ if (cached) return cached;
473
+ const localPath = await downloadImage(token, messageId, fileKey);
474
+ imageCache.set(fileKey, localPath);
475
+ await persistImageCache().catch((err) => {
476
+ console.error(`[${ts()}] [IMAGE] persist cache FAIL: ${(err as Error).message}`);
477
+ });
478
+ console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
479
+ return localPath;
480
+ }
407
481
 
408
482
  // ---------------------------------------------------------------------------
409
483
  // Messaging
410
484
  // ---------------------------------------------------------------------------
411
485
 
486
+ const DELAY_NOTICE_THRESHOLD_MS = 15 * 60 * 1000; // 15 分钟
487
+
488
+ /** 消息延迟超过阈值时生成提醒文本,否则返回 null */
489
+ export function formatDelayNotice(createTimeMs: number, nowMs?: number): string | null {
490
+ const now = nowMs ?? Date.now();
491
+ const delayMs = now - createTimeMs;
492
+ if (delayMs < DELAY_NOTICE_THRESHOLD_MS) return null;
493
+
494
+ const sendDate = new Date(createTimeMs);
495
+ const month = sendDate.getMonth() + 1;
496
+ const day = sendDate.getDate();
497
+ const hour = String(sendDate.getHours()).padStart(2, "0");
498
+ const min = String(sendDate.getMinutes()).padStart(2, "0");
499
+ const sendTimeStr = `${month}月${day}日 ${hour}:${min}`;
500
+
501
+ const totalMinutes = Math.floor(delayMs / 60000);
502
+ let delayStr: string;
503
+ if (totalMinutes < 60) {
504
+ delayStr = `${totalMinutes} 分钟`;
505
+ } else {
506
+ const hours = Math.floor(totalMinutes / 60);
507
+ const mins = totalMinutes % 60;
508
+ if (hours < 24) {
509
+ delayStr = mins > 0 ? `${hours} 小时 ${mins} 分钟` : `${hours} 小时`;
510
+ } else {
511
+ const days = Math.floor(hours / 24);
512
+ const remainHours = hours % 24;
513
+ delayStr = remainHours > 0 ? `${days} 天 ${remainHours} 小时` : `${days} 天`;
514
+ }
515
+ }
516
+
517
+ return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,因服务离线,延迟约 ${delayStr}后送达`;
518
+ }
519
+
412
520
  export async function sendTextReply(
413
521
  token: string,
414
522
  chatId: string,
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * =================================================================
4
4
  * Supported tools: Claude Code, Cursor, Codex (OpenAI).
5
5
  *
6
- * When a user sends "/new [tool]" to the bot:
6
+ * When a user sends "/new [tool]" to the bot (omitting tool uses the configured default Agent):
7
7
  * 1. Create an AI tool session via the corresponding adapter, get session ID
8
8
  * 2. Create a new Feishu group chat and add the user
9
9
  * 3. Rename the group (name + description) to the session ID
@@ -56,6 +56,7 @@ import {
56
56
  getRecentDirs,
57
57
  addRecentDir,
58
58
  sessionPrefixForTool,
59
+ resolveDefaultAgentTool,
59
60
  toolDisplayName,
60
61
  ts,
61
62
  } from "./config.ts";
@@ -64,6 +65,7 @@ import {
64
65
  addReaction,
65
66
  createGroupChat,
66
67
  extractSessionInfo,
68
+ formatDelayNotice,
67
69
  getChatInfo,
68
70
  getTenantAccessToken,
69
71
  recallMessage,
@@ -72,6 +74,7 @@ import {
72
74
  setChatAvatar,
73
75
  updateCardMessage,
74
76
  updateChatInfo,
77
+ getOrDownloadImage,
75
78
  sendRestartCard,
76
79
  verifyAllPermissions,
77
80
  reportPermissionResults,
@@ -124,7 +127,7 @@ function getInnerEvent(data: Evt): InnerEvent {
124
127
  * 将飞书消息的原始 content JSON 结构转成可读文本,保留代码块等结构信息。
125
128
  * 未知类型直接返回 JSON 原文,让 AI 自行理解。
126
129
  */
127
- function formatMessageContent(message: { message_type?: string; content?: string }): string {
130
+ async function formatMessageContent(message: { message_id?: string; message_type?: string; content?: string }): Promise<string> {
128
131
  const contentStr = message.content ?? "{}";
129
132
  let content: Record<string, unknown>;
130
133
  try { content = JSON.parse(contentStr); } catch { return ""; }
@@ -141,7 +144,21 @@ function formatMessageContent(message: { message_type?: string; content?: string
141
144
  return formatPostContent(content);
142
145
  }
143
146
 
144
- // 其他类型(image, file, audio, media, sticker)直接给原始 JSON
147
+ if (message.message_type === "image") {
148
+ const imageKey = content.image_key as string | undefined;
149
+ const messageId = message.message_id;
150
+ if (!imageKey || !messageId) return contentStr;
151
+ try {
152
+ const token = await getTenantAccessToken();
153
+ const localPath = await getOrDownloadImage(token, messageId, imageKey);
154
+ return `[图片] ${localPath}`;
155
+ } catch (err) {
156
+ console.error(`[${ts()}] [IMAGE] download failed for ${imageKey}: ${(err as Error).message}`);
157
+ return `[图片: ${imageKey}]`;
158
+ }
159
+ }
160
+
161
+ // 其他类型(file, audio, media, sticker)直接给原始 JSON
145
162
  return contentStr;
146
163
  }
147
164
 
@@ -195,7 +212,7 @@ function parseCardAction(data: unknown): CardActionResult | null {
195
212
  }
196
213
  if (!cmd) return null;
197
214
 
198
- const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions", forget: "/forget" };
215
+ const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", "new claude": "/new claude", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions", forget: "/forget" };
199
216
  let text = CMD_MAP[cmd] ?? "";
200
217
  if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
201
218
  const path = (action.value as Record<string, string>).path;
@@ -405,7 +422,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
405
422
  );
406
423
 
407
424
  console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
408
- setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
425
+ setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
409
426
  console.log(`${"=".repeat(60)}`);
410
427
  return;
411
428
  }
@@ -558,7 +575,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
558
575
  tool: descriptionTool,
559
576
  });
560
577
 
561
- setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
578
+ setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
562
579
 
563
580
  await sendCardReply(
564
581
  freshToken, chatId, `${toolLabel} Session Reset`,
@@ -721,8 +738,8 @@ async function startBotServiceCore(): Promise<void> {
721
738
  console.log(`${"=".repeat(60)}`);
722
739
  console.log(` ChatCCC — Feishu Bot Bridge for Claude Code${modeTag}`);
723
740
  console.log(`${"=".repeat(60)}`);
724
- console.log(` Send "/new" to the bot to create a new group + Claude session.`);
725
- console.log(` In a Claude session group, send any message to resume & prompt.`);
741
+ console.log(` Send "/new" to the bot to create a new group + ${toolDisplayName(resolveDefaultAgentTool())} session.`);
742
+ console.log(` In a session group, send any message to resume & prompt.`);
726
743
  console.log(`${"=".repeat(60)}`);
727
744
 
728
745
  console.log(`\n[启动 4/7] 向飞书开放平台申请 tenant_access_token …`);
@@ -782,7 +799,7 @@ async function startBotServiceCore(): Promise<void> {
782
799
  }
783
800
  }
784
801
 
785
- const text = formatMessageContent(message);
802
+ const text = await formatMessageContent(message);
786
803
  const sender = event.sender;
787
804
  const openId = sender?.sender_id?.open_id ?? "";
788
805
  const chatId = message.chat_id ?? "";
@@ -802,6 +819,11 @@ async function startBotServiceCore(): Promise<void> {
802
819
 
803
820
  if (!text) return;
804
821
  const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
822
+ const delayNotice = formatDelayNotice(msgTimestamp);
823
+ if (delayNotice) {
824
+ const delayToken = await getTenantAccessToken();
825
+ await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
826
+ }
805
827
  await handleCommand(text, chatId, openId, msgTimestamp);
806
828
  } catch (err) {
807
829
  console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
@@ -861,7 +883,7 @@ async function startBotServiceCore(): Promise<void> {
861
883
  console.log("[启动 7/7] 已连接本地中继,可接收转发事件。\n");
862
884
  printServiceRunningHint("local", `http://127.0.0.1:${CHATCCC_PORT}`);
863
885
  });
864
- ws.on("message", (raw: Buffer) => {
886
+ ws.on("message", async (raw: Buffer) => {
865
887
  try {
866
888
  const data = JSON.parse(raw.toString()) as Evt;
867
889
  const action = parseCardAction(data);
@@ -874,7 +896,7 @@ async function startBotServiceCore(): Promise<void> {
874
896
  const event = getInnerEvent(data);
875
897
  const message = event.message;
876
898
  if (!message) return;
877
- const text = formatMessageContent(message);
899
+ const text = await formatMessageContent(message);
878
900
  const openId = event.sender?.sender_id?.open_id ?? "";
879
901
  const chatId = message.chat_id ?? "";
880
902
  appendChatLog(chatId, openId, text);
package/src/session.ts CHANGED
@@ -251,7 +251,7 @@ export function accumulateBlockContent(
251
251
  }
252
252
 
253
253
  // ---------------------------------------------------------------------------
254
- // Claude session management
254
+ // AI tool session management
255
255
  // ---------------------------------------------------------------------------
256
256
 
257
257
  /**
@@ -324,7 +324,7 @@ export async function resumeAndPrompt(
324
324
  });
325
325
  const myGen = sessionGen;
326
326
 
327
- setChatAvatar(token, chatId, tool, "busy").catch(() => {});
327
+ setChatAvatar(token, chatId, tool, "busy").catch(() => {});
328
328
 
329
329
  const now = Date.now();
330
330
  const existingInfo = sessionInfoMap.get(chatId);
@@ -466,7 +466,7 @@ export async function resumeAndPrompt(
466
466
  if (!cEntry || cEntry.gen !== myGen) return;
467
467
  const wasStopped = cEntry.stopped;
468
468
  chatSessionMap.delete(chatId);
469
- setChatAvatar(token, chatId, tool, "idle").catch(() => {});
469
+ setChatAvatar(token, chatId, tool, "idle").catch(() => {});
470
470
 
471
471
  const finalCardContent = state.accumulatedContent || " ";
472
472
  if (cardId) {
package/src/web-ui.ts CHANGED
@@ -27,10 +27,10 @@ interface AppConfig {
27
27
  feishu?: { appId?: string; appSecret?: string };
28
28
  port?: number;
29
29
  gitTimeoutSeconds?: number;
30
- claude?: { enabled?: boolean; model?: string; effort?: string; apiKey?: string; baseUrl?: string };
30
+ claude?: { enabled?: boolean; defaultAgent?: boolean; model?: string; effort?: string; apiKey?: string; baseUrl?: string };
31
31
  // `command` 是已废弃的旧字段名,保留只读以兼容升级前的 config.json
32
- cursor?: { enabled?: boolean; path?: string; command?: string; model?: string };
33
- codex?: { enabled?: boolean; path?: string; command?: string; model?: string; effort?: string };
32
+ cursor?: { enabled?: boolean; defaultAgent?: boolean; path?: string; command?: string; model?: string };
33
+ codex?: { enabled?: boolean; defaultAgent?: boolean; path?: string; command?: string; model?: string; effort?: string };
34
34
  }
35
35
 
36
36
  // ---------------------------------------------------------------------------
@@ -343,6 +343,9 @@ function unflattenConfig(flat: Record<string, unknown>): Record<string, unknown>
343
343
  } else if (key === "CHATCCC_CLAUDE_ENABLED") {
344
344
  result.claude = result.claude || {};
345
345
  (result.claude as Record<string, unknown>).enabled = val === true || val === "true";
346
+ } else if (key === "CHATCCC_CLAUDE_DEFAULT_AGENT") {
347
+ result.claude = result.claude || {};
348
+ (result.claude as Record<string, unknown>).defaultAgent = val === true || val === "true";
346
349
  } else if (key === "CHATCCC_CURSOR_PATH") {
347
350
  result.cursor = result.cursor || {};
348
351
  (result.cursor as Record<string, unknown>).path = val;
@@ -352,6 +355,9 @@ function unflattenConfig(flat: Record<string, unknown>): Record<string, unknown>
352
355
  } else if (key === "CHATCCC_CURSOR_ENABLED") {
353
356
  result.cursor = result.cursor || {};
354
357
  (result.cursor as Record<string, unknown>).enabled = val === true || val === "true";
358
+ } else if (key === "CHATCCC_CURSOR_DEFAULT_AGENT") {
359
+ result.cursor = result.cursor || {};
360
+ (result.cursor as Record<string, unknown>).defaultAgent = val === true || val === "true";
355
361
  } else if (key === "CHATCCC_CODEX_PATH") {
356
362
  result.codex = result.codex || {};
357
363
  (result.codex as Record<string, unknown>).path = val;
@@ -364,6 +370,9 @@ function unflattenConfig(flat: Record<string, unknown>): Record<string, unknown>
364
370
  } else if (key === "CHATCCC_CODEX_ENABLED") {
365
371
  result.codex = result.codex || {};
366
372
  (result.codex as Record<string, unknown>).enabled = val === true || val === "true";
373
+ } else if (key === "CHATCCC_CODEX_DEFAULT_AGENT") {
374
+ result.codex = result.codex || {};
375
+ (result.codex as Record<string, unknown>).defaultAgent = val === true || val === "true";
367
376
  }
368
377
  }
369
378
  return result;
@@ -519,6 +528,8 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
519
528
  .agent-toggle:checked{background:#3b82f6}
520
529
  .agent-toggle::before{content:"";position:absolute;width:18px;height:18px;border-radius:50%;background:#fff;top:2px;left:2px;transition:left .2s;box-shadow:0 1px 2px rgba(0,0,0,.2)}
521
530
  .agent-toggle:checked::before{left:18px}
531
+ .agent-default-row{display:flex;align-items:center;gap:8px;font-size:13px;color:#334155;margin:-4px 0 12px}
532
+ .agent-default-row input{margin:0}
522
533
  .agent-body{flex:1}
523
534
  .agent-body fieldset{border:none;padding:0;margin:0}
524
535
  .agent-body fieldset[disabled]{opacity:.45;pointer-events:none}
@@ -600,6 +611,10 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
600
611
  <div class="desc">Anthropic Claude Agent SDK<br>官方/第三方 API 均可</div>
601
612
  </div>
602
613
  </div>
614
+ <label class="agent-default-row">
615
+ <input type="checkbox" id="agent-default-claude" onchange="onDefaultAgentToggle('claude', this.checked)">
616
+ 设为默认 Agent
617
+ </label>
603
618
  <fieldset class="agent-body" id="agent-body-claude" disabled>
604
619
  <div class="form-group" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 14px">
605
620
  <label style="margin-bottom:8px;font-weight:600;color:#334155">API 来源</label>
@@ -649,6 +664,10 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
649
664
  <div class="desc">Cursor Agent CLI<br>需安装 Cursor</div>
650
665
  </div>
651
666
  </div>
667
+ <label class="agent-default-row">
668
+ <input type="checkbox" id="agent-default-cursor" onchange="onDefaultAgentToggle('cursor', this.checked)">
669
+ 设为默认 Agent
670
+ </label>
652
671
  <fieldset class="agent-body" id="agent-body-cursor" disabled>
653
672
  <div class="form-group">
654
673
  <label>CLI 路径</label>
@@ -673,6 +692,10 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
673
692
  <div class="desc">OpenAI Codex CLI<br>需安装并登录</div>
674
693
  </div>
675
694
  </div>
695
+ <label class="agent-default-row">
696
+ <input type="checkbox" id="agent-default-codex" onchange="onDefaultAgentToggle('codex', this.checked)">
697
+ 设为默认 Agent
698
+ </label>
676
699
  <fieldset class="agent-body" id="agent-body-codex" disabled>
677
700
  <div class="form-group">
678
701
  <label>CLI 路径</label>
@@ -741,6 +764,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
741
764
  <div class="config-row" id="cfg-row-CLAUDE_BASE_URL"><span class="key">Base URL</span><span class="val" id="cfg-CLAUDE_BASE_URL">-</span></div>
742
765
  <div class="config-row"><span class="key">模型</span><span class="val" id="cfg-ANTHROPIC_MODEL">-</span></div>
743
766
  <div class="config-row"><span class="key">Effort</span><span class="val" id="cfg-ANTHROPIC_EFFORT">-</span></div>
767
+ <label class="agent-default-row" style="margin-top:10px"><input type="checkbox" id="dash-default-claude" onchange="setDashboardDefaultAgent('claude', this.checked)"> 设为默认 Agent</label>
744
768
  <button class="btn btn-outline" style="margin-top:8px" onclick="editSection('claude')">编辑</button>
745
769
  </div>
746
770
  </details>
@@ -750,6 +774,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
750
774
  <div class="section-detail">
751
775
  <div class="config-row"><span class="key">CLI 路径</span><span class="val" id="cfg-CURSOR_PATH">-</span></div>
752
776
  <div class="config-row"><span class="key">模型</span><span class="val" id="cfg-CURSOR_MODEL">-</span></div>
777
+ <label class="agent-default-row" style="margin-top:10px"><input type="checkbox" id="dash-default-cursor" onchange="setDashboardDefaultAgent('cursor', this.checked)"> 设为默认 Agent</label>
753
778
  <button class="btn btn-outline" style="margin-top:8px" onclick="editSection('cursor')">编辑</button>
754
779
  </div>
755
780
  </details>
@@ -760,6 +785,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
760
785
  <div class="config-row"><span class="key">CLI 路径</span><span class="val" id="cfg-CODEX_PATH">-</span></div>
761
786
  <div class="config-row"><span class="key">模型</span><span class="val" id="cfg-CODEX_MODEL">-</span></div>
762
787
  <div class="config-row"><span class="key">Effort</span><span class="val" id="cfg-CODEX_EFFORT">-</span></div>
788
+ <label class="agent-default-row" style="margin-top:10px"><input type="checkbox" id="dash-default-codex" onchange="setDashboardDefaultAgent('codex', this.checked)"> 设为默认 Agent</label>
763
789
  <button class="btn btn-outline" style="margin-top:8px" onclick="editSection('codex')">编辑</button>
764
790
  </div>
765
791
  </details>
@@ -791,6 +817,7 @@ let state = {
791
817
  view: 'loading',
792
818
  // 三个 Agent 各自的启用开关;初始全 false,renderStep2() 会按已存在 config 自动打开
793
819
  agentsEnabled: { claude: false, cursor: false, codex: false },
820
+ defaultAgent: 'claude',
794
821
  wizardStep: 1,
795
822
  config: {},
796
823
  running: false,
@@ -855,6 +882,51 @@ function onAgentToggle(agent, enabled) {
855
882
  if (card) card.classList.toggle('enabled', enabled);
856
883
  var body = document.getElementById('agent-body-' + agent);
857
884
  if (body) body.disabled = !enabled;
885
+ if (!enabled && state.defaultAgent === agent) {
886
+ state.defaultAgent = firstEnabledAgent();
887
+ } else if (enabled && !state.defaultAgent) {
888
+ state.defaultAgent = agent;
889
+ }
890
+ updateDefaultAgentToggles();
891
+ updateStep2NextBtn();
892
+ }
893
+
894
+ function firstEnabledAgent() {
895
+ if (state.agentsEnabled.claude) return 'claude';
896
+ if (state.agentsEnabled.cursor) return 'cursor';
897
+ if (state.agentsEnabled.codex) return 'codex';
898
+ return null;
899
+ }
900
+
901
+ function resolveDefaultAgentFromConfig(c, claudeOn, cursorOn, codexOn) {
902
+ if (claudeOn && c.claude && c.claude.defaultAgent === true) return 'claude';
903
+ if (cursorOn && c.cursor && c.cursor.defaultAgent === true) return 'cursor';
904
+ if (codexOn && c.codex && c.codex.defaultAgent === true) return 'codex';
905
+ if (claudeOn) return 'claude';
906
+ if (cursorOn) return 'cursor';
907
+ if (codexOn) return 'codex';
908
+ return 'claude';
909
+ }
910
+
911
+ function updateDefaultAgentToggles() {
912
+ ['claude','cursor','codex'].forEach(function(agent){
913
+ var el = document.getElementById('agent-default-' + agent);
914
+ if (el) {
915
+ el.checked = state.defaultAgent === agent;
916
+ el.disabled = !state.agentsEnabled[agent];
917
+ }
918
+ var dashEl = document.getElementById('dash-default-' + agent);
919
+ if (dashEl) dashEl.checked = state.defaultAgent === agent;
920
+ });
921
+ }
922
+
923
+ function onDefaultAgentToggle(agent, enabled) {
924
+ if (enabled) {
925
+ state.defaultAgent = agent;
926
+ } else if (state.defaultAgent === agent) {
927
+ state.defaultAgent = null;
928
+ }
929
+ updateDefaultAgentToggles();
858
930
  updateStep2NextBtn();
859
931
  }
860
932
 
@@ -878,6 +950,10 @@ function updateStep2NextBtn() {
878
950
  if (state.agentsEnabled.claude && isClaudeFieldsValid()) validCount++;
879
951
  if (state.agentsEnabled.cursor) validCount++;
880
952
  if (state.agentsEnabled.codex) validCount++;
953
+ if (validCount > 0 && !state.defaultAgent) {
954
+ state.defaultAgent = firstEnabledAgent();
955
+ updateDefaultAgentToggles();
956
+ }
881
957
  btn.disabled = validCount === 0;
882
958
  }
883
959
 
@@ -1018,12 +1094,14 @@ function renderStep2() {
1018
1094
  var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
1019
1095
  var cursorOn = isAgentEnabled(c.cursor, CURSOR_FALLBACK_KEYS);
1020
1096
  var codexOn = isAgentEnabled(c.codex, CODEX_FALLBACK_KEYS);
1097
+ state.defaultAgent = resolveDefaultAgentFromConfig(c, claudeOn, cursorOn, codexOn);
1021
1098
  document.getElementById('agent-enable-claude').checked = claudeOn;
1022
1099
  document.getElementById('agent-enable-cursor').checked = cursorOn;
1023
1100
  document.getElementById('agent-enable-codex').checked = codexOn;
1024
1101
  onAgentToggle('claude', claudeOn);
1025
1102
  onAgentToggle('cursor', cursorOn);
1026
1103
  onAgentToggle('codex', codexOn);
1104
+ updateDefaultAgentToggles();
1027
1105
 
1028
1106
  // Cursor path placeholder/hint:把已探测到的路径显示为占位
1029
1107
  var hint = document.getElementById('cursor-path-hint');
@@ -1060,6 +1138,12 @@ function collectAllFields() {
1060
1138
  vars.CHATCCC_CLAUDE_ENABLED = !!state.agentsEnabled.claude;
1061
1139
  vars.CHATCCC_CURSOR_ENABLED = !!state.agentsEnabled.cursor;
1062
1140
  vars.CHATCCC_CODEX_ENABLED = !!state.agentsEnabled.codex;
1141
+ if (!state.defaultAgent || !state.agentsEnabled[state.defaultAgent]) {
1142
+ state.defaultAgent = firstEnabledAgent();
1143
+ }
1144
+ vars.CHATCCC_CLAUDE_DEFAULT_AGENT = state.defaultAgent === 'claude';
1145
+ vars.CHATCCC_CURSOR_DEFAULT_AGENT = state.defaultAgent === 'cursor';
1146
+ vars.CHATCCC_CODEX_DEFAULT_AGENT = state.defaultAgent === 'codex';
1063
1147
  if (state.agentsEnabled.claude) {
1064
1148
  AGENT_FIELDS.claude.forEach(function(key){
1065
1149
  var el = document.getElementById('field-' + key);
@@ -1099,6 +1183,9 @@ function renderStep3() {
1099
1183
  if (state.agentsEnabled.codex) enabledList.push('codex');
1100
1184
  if (enabledList.length === 0) {
1101
1185
  lines.push('<div style="color:#ef4444">未启用任何 AI Agent</div>');
1186
+ } else {
1187
+ var defaultLabel = state.defaultAgent === 'cursor' ? 'Cursor' : state.defaultAgent === 'codex' ? 'Codex' : 'Claude Code';
1188
+ lines.push('<div class="config-row"><span class="key">/new 默认 Agent</span><span class="val">' + defaultLabel + '</span></div>');
1102
1189
  }
1103
1190
  enabledList.forEach(function(t){
1104
1191
  if (t === 'claude') {
@@ -1146,6 +1233,33 @@ async function saveConfig(vars) {
1146
1233
  return result.ok;
1147
1234
  }
1148
1235
 
1236
+ async function setDashboardDefaultAgent(agent, enabled) {
1237
+ if (!enabled) {
1238
+ updateDefaultAgentToggles();
1239
+ return;
1240
+ }
1241
+ state.defaultAgent = agent;
1242
+ var vars = {
1243
+ CHATCCC_CLAUDE_DEFAULT_AGENT: agent === 'claude',
1244
+ CHATCCC_CURSOR_DEFAULT_AGENT: agent === 'cursor',
1245
+ CHATCCC_CODEX_DEFAULT_AGENT: agent === 'codex'
1246
+ };
1247
+ var result = await api('/api/config', 'POST', { vars: vars, claudeApiMode: detectClaudeApiModeFromConfig(state.config && state.config.claude) });
1248
+ if (result.ok) {
1249
+ state.config.claude = state.config.claude || {};
1250
+ state.config.cursor = state.config.cursor || {};
1251
+ state.config.codex = state.config.codex || {};
1252
+ state.config.claude.defaultAgent = agent === 'claude';
1253
+ state.config.cursor.defaultAgent = agent === 'cursor';
1254
+ state.config.codex.defaultAgent = agent === 'codex';
1255
+ updateDefaultAgentToggles();
1256
+ toast('默认 Agent 已更新');
1257
+ } else {
1258
+ toast('保存失败: ' + (result.error || '未知错误'), 'error');
1259
+ updateDefaultAgentToggles();
1260
+ }
1261
+ }
1262
+
1149
1263
  async function saveAndStart() {
1150
1264
  var vars = collectAllFields();
1151
1265
  if (!vars.CHATCCC_APP_ID || !vars.CHATCCC_APP_SECRET) {
@@ -1233,9 +1347,12 @@ function updateDashboardUI() {
1233
1347
  var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
1234
1348
  var cursorOn = isAgentEnabled(c.cursor, CURSOR_FALLBACK_KEYS);
1235
1349
  var codexOn = isAgentEnabled(c.codex, CODEX_FALLBACK_KEYS);
1350
+ state.agentsEnabled = { claude: claudeOn, cursor: cursorOn, codex: codexOn };
1351
+ state.defaultAgent = resolveDefaultAgentFromConfig(c, claudeOn, cursorOn, codexOn);
1236
1352
  document.getElementById('dash-claude').style.display = claudeOn ? '' : 'none';
1237
1353
  document.getElementById('dash-cursor').style.display = cursorOn ? '' : 'none';
1238
1354
  document.getElementById('dash-codex').style.display = codexOn ? '' : 'none';
1355
+ updateDefaultAgentToggles();
1239
1356
  // 三个都未启用时给一个空态提示,引导用户去配置向导启用
1240
1357
  var emptyHint = document.getElementById('dash-no-agent-hint');
1241
1358
  if (emptyHint) emptyHint.style.display = (!claudeOn && !cursorOn && !codexOn) ? '' : 'none';
@@ -1588,4 +1705,4 @@ export function startSetupMode(port: number, options: StartSetupModeOptions = {}
1588
1705
  console.log("");
1589
1706
  openInBrowser(url);
1590
1707
  });
1591
- }
1708
+ }