chatccc 0.2.218 → 0.2.220

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.
@@ -2,7 +2,9 @@ import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { createReadStream } from "node:fs";
4
4
  import { copyFile, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
5
- import { basename, dirname, isAbsolute, resolve } from "node:path";
5
+ import { createRequire } from "node:module";
6
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
7
+ import { createInterface } from "node:readline";
6
8
 
7
9
  import { jsonSchema, tool, type ToolSet } from "ai";
8
10
 
@@ -19,6 +21,8 @@ const SEARCH_TIMEOUT_MS = 15_000;
19
21
  const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024;
20
22
  const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
21
23
  const MAX_COMMAND_TIMEOUT_MS = 900_000;
24
+ const requireFromHere = createRequire(import.meta.url);
25
+ const FALLBACK_SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
22
26
 
23
27
  export interface ReadFileInput {
24
28
  path: string;
@@ -77,6 +81,11 @@ export interface SearchCodeOutput {
77
81
  truncated: boolean;
78
82
  }
79
83
 
84
+ /** @internal Allows tests to force the dependency-free fallback path. */
85
+ export interface SearchCodeRuntimeOptions {
86
+ ripgrepCommands?: readonly string[];
87
+ }
88
+
80
89
  export interface RunCommandInput {
81
90
  command: string;
82
91
  cwd?: string;
@@ -546,32 +555,44 @@ function parseRgLine(line: string): SearchCodeMatch | null {
546
555
  };
547
556
  }
548
557
 
549
- export async function searchCodeForTool(
550
- cwd: string,
551
- input: SearchCodeInput,
552
- signal?: AbortSignal,
553
- ): Promise<SearchCodeOutput> {
554
- const query = input.query?.trim();
555
- if (!query) throw new Error("query is required");
558
+ interface RipgrepOutput {
559
+ stdout: string;
560
+ stderr: string;
561
+ truncated: boolean;
562
+ }
556
563
 
557
- const searchPath = resolveToolPath(cwd, input.path);
558
- const maxResults = Math.min(toPositiveInt(input.maxResults) ?? 50, MAX_SEARCH_RESULTS);
559
- const args = [
560
- "--line-number",
561
- "--column",
562
- "--no-heading",
563
- "--color",
564
- "never",
565
- "--max-count",
566
- String(maxResults),
567
- ];
568
- if (input.glob?.trim()) {
569
- args.push("--glob", input.glob.trim());
564
+ function resolveBundledRipgrepPath(): string | undefined {
565
+ try {
566
+ const bundled = requireFromHere("@vscode/ripgrep") as { rgPath?: unknown };
567
+ return typeof bundled.rgPath === "string" && bundled.rgPath.trim()
568
+ ? bundled.rgPath
569
+ : undefined;
570
+ } catch {
571
+ // Unsupported platforms or damaged optional platform packages must not
572
+ // prevent ChatCCC itself from starting; system rg / Node fallback remain.
573
+ return undefined;
570
574
  }
571
- args.push("--", query, searchPath);
575
+ }
576
+
577
+ function defaultRipgrepCommands(): string[] {
578
+ const bundled = resolveBundledRipgrepPath();
579
+ return [...new Set([bundled, "rg"].filter((value): value is string => !!value))];
580
+ }
581
+
582
+ function isUnavailableExecutableError(err: unknown): boolean {
583
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
584
+ return code === "ENOENT" || code === "EACCES" || code === "EPERM";
585
+ }
572
586
 
573
- const output = await new Promise<{ stdout: string; stderr: string; truncated: boolean }>((resolvePromise, reject) => {
574
- const child = spawn("rg", args, {
587
+ async function runRipgrep(
588
+ command: string,
589
+ args: string[],
590
+ cwd: string,
591
+ signal?: AbortSignal,
592
+ ): Promise<RipgrepOutput> {
593
+ return new Promise<RipgrepOutput>((resolvePromise, reject) => {
594
+ let settled = false;
595
+ const child = spawn(command, args, {
575
596
  cwd,
576
597
  shell: false,
577
598
  windowsHide: true,
@@ -581,14 +602,23 @@ export async function searchCodeForTool(
581
602
  let stdout = "";
582
603
  let stderr = "";
583
604
  let truncated = false;
605
+ const cleanup = () => {
606
+ clearTimeout(timeout);
607
+ signal?.removeEventListener("abort", abort);
608
+ };
609
+ const rejectOnce = (err: Error) => {
610
+ if (settled) return;
611
+ settled = true;
612
+ cleanup();
613
+ reject(err);
614
+ };
584
615
  const timeout = setTimeout(() => {
585
616
  child.kill();
586
- reject(new Error(`search_code timed out after ${SEARCH_TIMEOUT_MS}ms`));
617
+ rejectOnce(new Error(`search_code timed out after ${SEARCH_TIMEOUT_MS}ms`));
587
618
  }, SEARCH_TIMEOUT_MS);
588
-
589
619
  const abort = () => {
590
620
  child.kill();
591
- reject(new Error("search_code aborted"));
621
+ rejectOnce(new Error("search_code aborted"));
592
622
  };
593
623
  signal?.addEventListener("abort", abort, { once: true });
594
624
 
@@ -606,14 +636,11 @@ export async function searchCodeForTool(
606
636
  child.stderr?.on("data", (chunk: Buffer) => {
607
637
  stderr += chunk.toString("utf8");
608
638
  });
609
- child.on("error", (err) => {
610
- clearTimeout(timeout);
611
- signal?.removeEventListener("abort", abort);
612
- reject(err);
613
- });
639
+ child.on("error", (err) => rejectOnce(err));
614
640
  child.on("close", (code) => {
615
- clearTimeout(timeout);
616
- signal?.removeEventListener("abort", abort);
641
+ if (settled) return;
642
+ settled = true;
643
+ cleanup();
617
644
  if (code !== 0 && code !== 1) {
618
645
  reject(new Error(stderr.trim() || `rg exited with code ${code}`));
619
646
  return;
@@ -621,20 +648,231 @@ export async function searchCodeForTool(
621
648
  resolvePromise({ stdout, stderr, truncated });
622
649
  });
623
650
  });
651
+ }
652
+
653
+ function expandGlobBraces(pattern: string): string[] {
654
+ const openIndex = pattern.indexOf("{");
655
+ if (openIndex < 0) return [pattern];
656
+ const closeIndex = pattern.indexOf("}", openIndex + 1);
657
+ if (closeIndex < 0) return [pattern];
658
+ const alternatives = pattern.slice(openIndex + 1, closeIndex).split(",");
659
+ if (alternatives.length < 2) return [pattern];
660
+ return alternatives.flatMap((alternative) => expandGlobBraces(
661
+ pattern.slice(0, openIndex) + alternative + pattern.slice(closeIndex + 1),
662
+ ));
663
+ }
664
+
665
+ function globToRegExp(pattern: string): RegExp {
666
+ let source = "";
667
+ for (let index = 0; index < pattern.length; index += 1) {
668
+ const char = pattern[index];
669
+ if (char === "*") {
670
+ if (pattern[index + 1] === "*") {
671
+ index += 1;
672
+ if (pattern[index + 1] === "/") {
673
+ index += 1;
674
+ source += "(?:.*/)?";
675
+ } else {
676
+ source += ".*";
677
+ }
678
+ } else {
679
+ source += "[^/]*";
680
+ }
681
+ } else if (char === "?") {
682
+ source += "[^/]";
683
+ } else {
684
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
685
+ }
686
+ }
687
+ return new RegExp(`^${source}$`);
688
+ }
689
+
690
+ function createGlobMatchers(glob: string | undefined): Array<{ regex: RegExp; basenameOnly: boolean }> {
691
+ if (!glob?.trim()) return [];
692
+ return expandGlobBraces(glob.trim().replaceAll("\\", "/")).map((pattern) => ({
693
+ regex: globToRegExp(pattern),
694
+ basenameOnly: !pattern.includes("/"),
695
+ }));
696
+ }
697
+
698
+ function matchesFallbackGlob(
699
+ filePath: string,
700
+ searchRoot: string,
701
+ matchers: Array<{ regex: RegExp; basenameOnly: boolean }>,
702
+ ): boolean {
703
+ if (matchers.length === 0) return true;
704
+ const relativePath = relative(searchRoot, filePath).split(sep).join("/");
705
+ return matchers.some(({ regex, basenameOnly }) => regex.test(
706
+ basenameOnly ? basename(filePath) : relativePath,
707
+ ));
708
+ }
709
+
710
+ async function searchCodeWithNode(
711
+ query: string,
712
+ searchPath: string,
713
+ glob: string | undefined,
714
+ maxResults: number,
715
+ signal?: AbortSignal,
716
+ ): Promise<{ matches: SearchCodeMatch[]; truncated: boolean }> {
717
+ let queryRegex: RegExp;
718
+ try {
719
+ queryRegex = new RegExp(query);
720
+ } catch (err) {
721
+ throw new Error(`invalid search regex: ${(err as Error).message}`);
722
+ }
723
+
724
+ const startedAt = Date.now();
725
+ const matches: SearchCodeMatch[] = [];
726
+ const rootInfo = await stat(searchPath);
727
+ const searchRoot = rootInfo.isDirectory() ? searchPath : dirname(searchPath);
728
+ const globMatchers = createGlobMatchers(glob);
729
+ let truncated = false;
730
+ let outputBytes = 0;
731
+
732
+ const ensureActive = () => {
733
+ if (signal?.aborted) throw new Error("search_code aborted");
734
+ if (Date.now() - startedAt >= SEARCH_TIMEOUT_MS) {
735
+ throw new Error(`search_code timed out after ${SEARCH_TIMEOUT_MS}ms`);
736
+ }
737
+ };
738
+
739
+ const searchFile = async (filePath: string) => {
740
+ if (!matchesFallbackGlob(filePath, searchRoot, globMatchers)) return;
741
+ ensureActive();
742
+ const input = createReadStream(filePath, { encoding: "utf8" });
743
+ const lines = createInterface({ input, crlfDelay: Infinity });
744
+ let lineNumber = 0;
745
+ try {
746
+ for await (const line of lines) {
747
+ ensureActive();
748
+ lineNumber += 1;
749
+ if (line.includes("\0")) break;
750
+ const match = queryRegex.exec(line);
751
+ queryRegex.lastIndex = 0;
752
+ if (!match) continue;
753
+ const lineBytes = Buffer.byteLength(line, "utf8");
754
+ if (outputBytes + lineBytes > MAX_SEARCH_BYTES) {
755
+ truncated = true;
756
+ break;
757
+ }
758
+ outputBytes += lineBytes;
759
+ matches.push({
760
+ path: filePath,
761
+ line: lineNumber,
762
+ column: match.index + 1,
763
+ text: line,
764
+ });
765
+ if (matches.length >= maxResults) {
766
+ truncated = true;
767
+ break;
768
+ }
769
+ }
770
+ } catch (err) {
771
+ if (signal?.aborted) throw new Error("search_code aborted");
772
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
773
+ if (code !== "EACCES" && code !== "EPERM" && code !== "ENOENT") throw err;
774
+ } finally {
775
+ lines.close();
776
+ input.destroy();
777
+ }
778
+ };
779
+
780
+ const visit = async (currentPath: string): Promise<void> => {
781
+ ensureActive();
782
+ if (truncated) return;
783
+ let info;
784
+ try {
785
+ info = currentPath === searchPath ? rootInfo : await stat(currentPath);
786
+ } catch (err) {
787
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
788
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT") return;
789
+ throw err;
790
+ }
791
+ if (info.isFile()) {
792
+ await searchFile(currentPath);
793
+ return;
794
+ }
795
+ if (!info.isDirectory()) return;
796
+
797
+ let entries;
798
+ try {
799
+ entries = await readdir(currentPath, { withFileTypes: true });
800
+ } catch (err) {
801
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
802
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT") return;
803
+ throw err;
804
+ }
805
+ entries.sort((left, right) => left.name.localeCompare(right.name));
806
+ for (const entry of entries) {
807
+ if (truncated) break;
808
+ if (entry.name.startsWith(".")) continue;
809
+ if (entry.isDirectory() && FALLBACK_SKIPPED_DIRECTORIES.has(entry.name)) continue;
810
+ if (entry.isSymbolicLink()) continue;
811
+ await visit(resolve(currentPath, entry.name));
812
+ }
813
+ };
814
+
815
+ await visit(searchPath);
816
+ return { matches, truncated };
817
+ }
818
+
819
+ export async function searchCodeForTool(
820
+ cwd: string,
821
+ input: SearchCodeInput,
822
+ signal?: AbortSignal,
823
+ runtimeOptions: SearchCodeRuntimeOptions = {},
824
+ ): Promise<SearchCodeOutput> {
825
+ const query = input.query?.trim();
826
+ if (!query) throw new Error("query is required");
827
+ if (signal?.aborted) throw new Error("search_code aborted");
828
+
829
+ const searchPath = resolveToolPath(cwd, input.path);
830
+ const maxResults = Math.min(toPositiveInt(input.maxResults) ?? 50, MAX_SEARCH_RESULTS);
831
+ const args = [
832
+ "--line-number",
833
+ "--column",
834
+ "--no-heading",
835
+ "--color",
836
+ "never",
837
+ "--max-count",
838
+ String(maxResults),
839
+ ];
840
+ if (input.glob?.trim()) {
841
+ args.push("--glob", input.glob.trim());
842
+ }
843
+ args.push("--", query, searchPath);
844
+
845
+ const commands = runtimeOptions.ripgrepCommands ?? defaultRipgrepCommands();
846
+ let output: RipgrepOutput | undefined;
847
+ for (const command of commands) {
848
+ try {
849
+ output = await runRipgrep(command, args, cwd, signal);
850
+ break;
851
+ } catch (err) {
852
+ if (!isUnavailableExecutableError(err)) throw err;
853
+ }
854
+ }
624
855
 
625
- const matches = output.stdout
626
- .split(/\r?\n/)
627
- .filter(Boolean)
628
- .map(parseRgLine)
629
- .filter((match): match is SearchCodeMatch => !!match)
630
- .slice(0, maxResults);
856
+ const fallback = output
857
+ ? undefined
858
+ : await searchCodeWithNode(query, searchPath, input.glob?.trim(), maxResults, signal);
859
+ const matches = output
860
+ ? output.stdout
861
+ .split(/\r?\n/)
862
+ .filter(Boolean)
863
+ .map(parseRgLine)
864
+ .filter((match): match is SearchCodeMatch => !!match)
865
+ .slice(0, maxResults)
866
+ : fallback!.matches;
631
867
 
632
868
  return {
633
869
  query,
634
870
  path: searchPath,
635
871
  ...(input.glob?.trim() ? { glob: input.glob.trim() } : {}),
636
872
  matches,
637
- truncated: output.truncated || matches.length >= maxResults,
873
+ truncated: output
874
+ ? output.truncated || matches.length >= maxResults
875
+ : fallback!.truncated,
638
876
  };
639
877
  }
640
878
 
package/src/cards.ts CHANGED
@@ -133,11 +133,12 @@ export function buildHelpCard(
133
133
  `发送 **/new** 创建新会话(使用 /cd 设置的目录,默认 ${defaultToolLabel})`,
134
134
  "发送 **/new claude** 创建新 Claude 会话",
135
135
  "发送 **/new cursor** 创建新 Cursor 会话",
136
- "发送 **/new codex** 创建新 Codex 会话",
136
+ "发送 **/new codex** 创建新 Codex 会话",
137
+ "发送 **/new ccc** 创建新 CCC Agent 会话",
137
138
  "发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
138
139
  "发送 **/plan** 以规划模式提问(只读,不执行写操作)",
139
140
  "发送 **/ask** 以问答模式提问(只读,不执行写操作)",
140
- "发送 **/usage** 查看 Codex 实际存在的 5h/7天用量窗口,以及查询/使用主动重置卡",
141
+ "发送 **/usage** 查看当前 Agent 的用量或余额",
141
142
  "发送 **/restart** 重启 ChatCCC 进程",
142
143
  "发送 **/update** 更新并重启(仅 npm 全局安装可用)",
143
144
  ABD_HELP_LINE,
@@ -152,7 +153,8 @@ export function buildHelpCard(
152
153
  { text: `新建默认会话(/new,${defaultToolLabel})`, value: JSON.stringify({ cmd: "new" }), type: "primary" },
153
154
  { text: "新建 Claude 会话(/new claude)", value: JSON.stringify({ cmd: "new claude" }), type: "primary" },
154
155
  { text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
155
- { text: "新建 Codex 会话(/new codex)", value: JSON.stringify({ cmd: "new codex" }), type: "primary" },
156
+ { text: "新建 Codex 会话(/new codex)", value: JSON.stringify({ cmd: "new codex" }), type: "primary" },
157
+ { text: "新建 CCC Agent 会话(/new ccc)", value: JSON.stringify({ cmd: "new ccc" }), type: "primary" },
156
158
  { text: "重启 ChatCCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
157
159
  { text: "更新并重启(/update)", value: JSON.stringify({ cmd: "update" }), type: "danger" },
158
160
  { text: "切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
@@ -344,8 +346,8 @@ export function buildSessionsCard(sessions: Array<{
344
346
  header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
345
347
  elements: [
346
348
  { tag: "div", text: { tag: "lark_md", content: fixedPrivateSession
347
- ? `当前没有会话记录。\n\n直接发送普通消息即可创建飞书私聊专属 ${defaultToolLabel} 会话;默认 Agent 变化后,下一条普通消息会创建对应 Agent 的新空会话。发送 **/new**、**/new claude**、**/new cursor** 或 **/new codex** 会另外创建会话群。`
348
- : `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。\n创建后可在任意会话群内发送 **/sessions** 查看列表,用 **/session 数字** 切换会话。` } },
349
+ ? `当前没有会话记录。\n\n直接发送普通消息即可创建飞书私聊专属 ${defaultToolLabel} 会话;默认 Agent 变化后,下一条普通消息会创建对应 Agent 的新空会话。发送 **/new**、**/new claude**、**/new cursor**、**/new codex** 或 **/new ccc** 会另外创建会话群。`
350
+ : `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor**、**/new codex** 或 **/new ccc** 创建新会话。\n创建后可在任意会话群内发送 **/sessions** 查看列表,用 **/session 数字** 切换会话。` } },
349
351
  { tag: "hr" },
350
352
  { tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
351
353
  ],
package/src/config.ts CHANGED
@@ -104,12 +104,18 @@ export interface CodexConfig {
104
104
  }
105
105
 
106
106
  export interface CccConfig {
107
+ /** Whether the built-in CCC Agent is available for new sessions. */
108
+ enabled: boolean;
109
+ /** Whether /new without an explicit tool should use CCC Agent. */
110
+ defaultAgent: boolean;
107
111
  /** DeepSeek API Key for the ChatCCC self-developed agent. */
108
112
  DEEPSEEK_API_KEY: string;
109
113
  /** DeepSeek-compatible API Base URL for the ChatCCC self-developed agent. */
110
114
  DEEPSEEK_BASE_URL: string;
111
115
  /** Model used by the ChatCCC self-developed agent. */
112
116
  model: string;
117
+ /** Optional model exposed through /model for manual per-session switching. */
118
+ alternativeModel: string;
113
119
  }
114
120
 
115
121
  export interface FeishuConfig {
@@ -168,8 +174,8 @@ export interface AppConfig {
168
174
  ccc: CccConfig;
169
175
  }
170
176
 
171
- export type AgentTool = "claude" | "cursor" | "codex";
172
- export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
177
+ export type AgentTool = "claude" | "cursor" | "codex" | "ccc";
178
+ export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex", "ccc"];
173
179
  export type CursorAvatarBatteryMode = "apiPercent" | "onDemandUse";
174
180
 
175
181
  /** 获取指定 agent 配置中所有模型相关的值(最多 100 个,去重) */
@@ -190,6 +196,7 @@ export function getAllModelsForTool(tool: string, cfg: AppConfig = config): stri
190
196
  collect(cfg.codex.alternativeModel);
191
197
  } else if (tool === "ccc") {
192
198
  collect(cfg.ccc.model);
199
+ collect(cfg.ccc.alternativeModel);
193
200
  }
194
201
 
195
202
  return Array.from(seen).slice(0, 100);
@@ -445,7 +452,14 @@ function loadConfig(): AppConfig {
445
452
  onDemandMonthlyBudget: 1000,
446
453
  },
447
454
  codex: { enabled: false, defaultAgent: false, path: "", model: "", alternativeModel: "", effort: "", fastMode: false },
448
- ccc: { DEEPSEEK_API_KEY: "", DEEPSEEK_BASE_URL: DEFAULT_CCC_DEEPSEEK_BASE_URL, model: DEFAULT_CCC_MODEL },
455
+ ccc: {
456
+ enabled: false,
457
+ defaultAgent: false,
458
+ DEEPSEEK_API_KEY: "",
459
+ DEEPSEEK_BASE_URL: DEFAULT_CCC_DEEPSEEK_BASE_URL,
460
+ model: DEFAULT_CCC_MODEL,
461
+ alternativeModel: "",
462
+ },
449
463
  };
450
464
 
451
465
  if (!IS_TEST_ENV) {
@@ -498,7 +512,14 @@ function loadConfig(): AppConfig {
498
512
  onDemandMonthlyBudget?: unknown;
499
513
  };
500
514
  codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; alternativeModel?: unknown; effort?: unknown; fastMode?: unknown };
501
- ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
515
+ ccc?: {
516
+ enabled?: unknown;
517
+ defaultAgent?: unknown;
518
+ DEEPSEEK_API_KEY?: unknown;
519
+ DEEPSEEK_BASE_URL?: unknown;
520
+ model?: unknown;
521
+ alternativeModel?: unknown;
522
+ };
502
523
  webUi?: { openOnStart?: unknown };
503
524
  chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
504
525
  rawStreamLogs?: unknown;
@@ -553,7 +574,7 @@ function loadConfig(): AppConfig {
553
574
  (typeof cursorRaw.model === "string" && (cursorRaw.model as string).trim()) ||
554
575
  (typeof cursorRaw.alternativeModel === "string" && (cursorRaw.alternativeModel as string).trim()),
555
576
  );
556
- const codexNonEmpty = (): boolean =>
577
+ const codexNonEmpty = (): boolean =>
557
578
  Boolean(
558
579
  (typeof codexRaw.path === "string" && codexRaw.path.trim()) ||
559
580
  (typeof codexRaw.command === "string" && (codexRaw.command as string).trim()) ||
@@ -561,21 +582,28 @@ function loadConfig(): AppConfig {
561
582
  (typeof codexRaw.alternativeModel === "string" && (codexRaw.alternativeModel as string).trim()) ||
562
583
  (typeof codexRaw.effort === "string" && (codexRaw.effort as string).trim()) ||
563
584
  codexRaw.fastMode === true,
564
- );
585
+ );
586
+ // 旧版 ccc 配置没有 enabled。只用 API Key 推断启用,避免 sample 中自带的
587
+ // 默认 Base URL / model 让升级用户在未配置凭证时意外启用 CCC Agent。
588
+ const cccNonEmpty = (): boolean =>
589
+ Boolean(typeof cccRaw.DEEPSEEK_API_KEY === "string" && cccRaw.DEEPSEEK_API_KEY.trim());
565
590
 
566
591
  const claudeEnabled = resolveEnabled(claude.enabled, claudeNonEmpty);
567
592
  const cursorEnabled = resolveEnabled(cursorRaw.enabled, cursorNonEmpty);
568
- const codexEnabled = resolveEnabled(codexRaw.enabled, codexNonEmpty);
593
+ const codexEnabled = resolveEnabled(codexRaw.enabled, codexNonEmpty);
594
+ const cccEnabled = resolveEnabled(cccRaw.enabled, cccNonEmpty);
569
595
  const chromeDevtoolsPort = Number(chromeDevtoolsRaw.port);
570
596
  const explicitDefaultTool: AgentTool | null =
571
597
  typeof claude.defaultAgent === "boolean" && claude.defaultAgent && claudeEnabled ? "claude" :
572
598
  typeof cursorRaw.defaultAgent === "boolean" && cursorRaw.defaultAgent && cursorEnabled ? "cursor" :
573
- typeof codexRaw.defaultAgent === "boolean" && codexRaw.defaultAgent && codexEnabled ? "codex" :
599
+ typeof codexRaw.defaultAgent === "boolean" && codexRaw.defaultAgent && codexEnabled ? "codex" :
600
+ typeof cccRaw.defaultAgent === "boolean" && cccRaw.defaultAgent && cccEnabled ? "ccc" :
574
601
  null;
575
602
  const fallbackDefaultTool: AgentTool =
576
603
  claudeEnabled ? "claude" :
577
604
  cursorEnabled ? "cursor" :
578
- codexEnabled ? "codex" :
605
+ codexEnabled ? "codex" :
606
+ cccEnabled ? "ccc" :
579
607
  "claude";
580
608
  const defaultTool = explicitDefaultTool ?? fallbackDefaultTool;
581
609
 
@@ -655,12 +683,15 @@ function loadConfig(): AppConfig {
655
683
  fastMode: codexRaw.fastMode === true,
656
684
  },
657
685
  ccc: {
686
+ enabled: cccEnabled,
687
+ defaultAgent: defaultTool === "ccc",
658
688
  DEEPSEEK_API_KEY: normalizeOptionalConfigField(cccRaw.DEEPSEEK_API_KEY, { label: "ccc.DEEPSEEK_API_KEY" }),
659
689
  DEEPSEEK_BASE_URL: normalizeOptionalConfigField(cccRaw.DEEPSEEK_BASE_URL, {
660
690
  label: "ccc.DEEPSEEK_BASE_URL",
661
691
  fallback: DEFAULT_CCC_DEEPSEEK_BASE_URL,
662
692
  }),
663
693
  model: normalizeOptionalConfigField(cccRaw.model, { label: "ccc.model", fallback: DEFAULT_CCC_MODEL }),
694
+ alternativeModel: normalizeOptionalConfigField(cccRaw.alternativeModel, { label: "ccc.alternativeModel" }),
664
695
  },
665
696
  };
666
697
  }
@@ -981,7 +1012,7 @@ export const CLAUDE_SESSION_PREFIX = "Claude Code Session:";
981
1012
  export const CURSOR_SESSION_PREFIX = "Cursor Session:";
982
1013
  /** 群描述中用于识别 Codex 会话的前缀 */
983
1014
  export const CODEX_SESSION_PREFIX = "Codex Session:";
984
- /** 群描述中用于识别 hidden ccc agent 会话的前缀 */
1015
+ /** 群描述中用于识别 CCC Agent 会话的前缀 */
985
1016
  export const CCC_SESSION_PREFIX = "CCC Session:";
986
1017
 
987
1018
  /** 根据 tool 名称返回对应的群描述前缀 */
package/src/index.ts CHANGED
@@ -333,6 +333,19 @@ async function processFeishuMessageEvent(data: Evt): Promise<void> {
333
333
  if (delayNotice) {
334
334
  const delayToken = await getTenantAccessToken();
335
335
  await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
336
+ // 延迟送达的旧消息只发提醒、不转发给 agent 执行:
337
+ // 断线重连或补推时,早已过期的消息不应未经确认就触发新任务。
338
+ // 用户如需执行可自行重发。
339
+ logTrace(traceId, "DONE", {
340
+ outcome: "skip_delayed_feishu_message",
341
+ chatId,
342
+ msgTimestamp,
343
+ delayMinutes: Math.floor((Date.now() - msgTimestamp) / 60000),
344
+ });
345
+ console.log(
346
+ `[${ts()}] [SKIP] Delayed Feishu message not forwarded to agent: messageId=${messageId ?? "(missing id)"} chatId=${chatId} createTime=${msgTimestamp}`,
347
+ );
348
+ return;
336
349
  }
337
350
 
338
351
  await handleCommand(