chatccc 0.2.218 → 0.2.219

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
@@ -338,6 +338,8 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
338
338
 
339
339
  **会话停滞保护:** 当 Agent 连续 3 分钟停在“正在启动 Agent”且没有任何事件,或停在“正在生成回复”且回复字符数没有变化,同时尚未报告权威终态时,ChatCCC 会结束旧 CLI,并优先补发一次“完成了吗?如果没完成继续”;恢复轮再次发生相同停滞时不再递归续跑。`/new claude` 和 `/new cursor` 在等待底层 init 事件时也使用 3 分钟超时并主动清理 SDK/CLI。思考、搜索和工具调用阶段不按回复字符数误判,由进程资源监控负责识别真正僵死。Codex 只有 `turn.completed` 才算权威终态,阶段性的 `agent_message` 不算;任一 Agent 报告权威终态后若输出流仍超过 10 秒未关闭,ChatCCC 会强制清理该 CLI 并按正常完成收尾,不会重复询问 Agent。
340
340
 
341
+ **CCC Agent 代码搜索:** `search_code` 使用项目自带的跨平台 ripgrep,不要求系统另行安装 `rg`。如果当前平台没有可用的 bundled/system ripgrep,会自动降级为内置 Node 搜索,并继续支持常用正则、glob、结果上限、中止和超时控制。
342
+
341
343
  ## 可用指令
342
344
 
343
345
  | 指令 | 作用 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.218",
3
+ "version": "0.2.219",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -51,6 +51,7 @@
51
51
  "@anthropic-ai/claude-agent-sdk": "0.2.133",
52
52
  "@larksuiteoapi/node-sdk": "^1.59.0",
53
53
  "@openilink/openilink-sdk-node": "^0.6.0",
54
+ "@vscode/ripgrep": "^1.18.0",
54
55
  "ai": "^6.0.184",
55
56
  "nodemailer": "^8.0.7",
56
57
  "qrcode-terminal": "^0.12.0",
@@ -1,9 +1,7 @@
1
- import { execFile } from "node:child_process";
2
1
  import { createHash } from "node:crypto";
3
- import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
4
3
  import { tmpdir } from "node:os";
5
4
  import { join } from "node:path";
6
- import { promisify } from "node:util";
7
5
 
8
6
  import { afterEach, describe, expect, it } from "vitest";
9
7
 
@@ -19,7 +17,6 @@ import {
19
17
  searchCodeForTool,
20
18
  } from "../builtin/file-tools.ts";
21
19
 
22
- const execFileAsync = promisify(execFile);
23
20
  const tempDirs: string[] = [];
24
21
 
25
22
  async function makeTempDir(): Promise<string> {
@@ -28,15 +25,6 @@ async function makeTempDir(): Promise<string> {
28
25
  return dir;
29
26
  }
30
27
 
31
- async function hasRg(): Promise<boolean> {
32
- try {
33
- await execFileAsync("rg", ["--version"]);
34
- return true;
35
- } catch {
36
- return false;
37
- }
38
- }
39
-
40
28
  function sha256(text: string): string {
41
29
  return createHash("sha256").update(text).digest("hex");
42
30
  }
@@ -75,20 +63,52 @@ describe("builtin file tools", () => {
75
63
  }));
76
64
  });
77
65
 
78
- it("searches code with rg without using a shell", async () => {
79
- if (!await hasRg()) return;
80
-
66
+ it("searches with the project-bundled ripgrep even when rg is absent from PATH", async () => {
81
67
  const dir = await makeTempDir();
82
68
  await writeFile(join(dir, "a.ts"), "const marker = 1;\n", "utf8");
69
+ const originalPath = process.env.PATH;
70
+ process.env.PATH = "";
71
+
72
+ try {
73
+ const result = await searchCodeForTool(dir, { query: "marker", glob: "*.ts" });
74
+
75
+ expect(result.matches).toEqual([
76
+ expect.objectContaining({
77
+ line: 1,
78
+ column: 7,
79
+ text: "const marker = 1;",
80
+ }),
81
+ ]);
82
+ } finally {
83
+ process.env.PATH = originalPath;
84
+ }
85
+ });
83
86
 
84
- const result = await searchCodeForTool(dir, { query: "marker", glob: "*.ts" });
85
-
86
- expect(result.matches).toEqual([
87
- expect.objectContaining({
88
- line: 1,
89
- text: "const marker = 1;",
90
- }),
91
- ]);
87
+ it("falls back to Node search when no ripgrep executable can be used", async () => {
88
+ const dir = await makeTempDir();
89
+ await writeFile(join(dir, "a.ts"), "const marker = 1;\n", "utf8");
90
+ await mkdir(join(dir, "nested"));
91
+ await writeFile(join(dir, "nested", "b.md"), "xx marker = 2\n", "utf8");
92
+ await writeFile(join(dir, "ignored.txt"), "marker = 3\n", "utf8");
93
+ const originalPath = process.env.PATH;
94
+ process.env.PATH = "";
95
+
96
+ try {
97
+ const result = await searchCodeForTool(
98
+ dir,
99
+ { query: "marker\\s*=\\s*\\d", glob: "**/*.{ts,md}", maxResults: 10 },
100
+ undefined,
101
+ { ripgrepCommands: [join(dir, "missing-rg")] },
102
+ );
103
+
104
+ expect(result.matches).toEqual([
105
+ expect.objectContaining({ path: join(dir, "a.ts"), line: 1, column: 7 }),
106
+ expect.objectContaining({ path: join(dir, "nested", "b.md"), line: 1, column: 4 }),
107
+ ]);
108
+ expect(result.truncated).toBe(false);
109
+ } finally {
110
+ process.env.PATH = originalPath;
111
+ }
92
112
  });
93
113
 
94
114
  it("runs non-interactive shell commands in the requested cwd", async () => {
@@ -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