chatccc 0.2.144 → 0.2.146

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
@@ -287,7 +287,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
287
287
  | `claude.apiKey` / `claude.baseUrl` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
288
288
  | `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
289
289
 
290
- > 当前 ChatCCC 以 `bypassPermissions` 模式运行,会跳过 Agent 操作确认。请只在可信环境中使用。
290
+ > **权限控制**:普通消息以 `bypassPermissions` 模式运行,跳过 Agent 操作确认。使用 `/plan` 或 `/ask` 前缀时,ChatCCC 自动切换为只读模式:Claude SDK 仅放行 Read + stop-stuck-loop 网络请求,Codex 使用 `--sandbox read-only`,Cursor 使用 `--mode plan/ask`。请只在可信环境中使用。
291
291
 
292
292
  ### 5. 开始使用
293
293
 
@@ -310,6 +310,8 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
310
310
  | `/cd` | 查看或设置当前会话工作目录 |
311
311
  | `/sessions` | 查看所有会话状态 |
312
312
  | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
313
+ | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
314
+ | `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
313
315
  | `/restart` | 重启机器人进程 |
314
316
 
315
317
  > **模型切换**:`/model` 查看可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。当前仅 Claude Code 支持模型切换(需同时填写 `claude.model` 和 `claude.subagentModel`),Cursor / Codex 切换正在开发中。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.144",
3
+ "version": "0.2.146",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -33,9 +33,10 @@ describe("agent RPC body parsing", () => {
33
33
  await expect(readUtf8JsonBody(req, 1024)).rejects.toThrow("Unsupported charset");
34
34
  });
35
35
 
36
- it("rejects invalid UTF-8 bytes", async () => {
36
+ it("replaces invalid UTF-8 bytes instead of rejecting", async () => {
37
37
  const req = requestFrom(Buffer.from([0xff, 0xfe]), "application/json; charset=utf-8");
38
38
 
39
- await expect(readUtf8JsonBody(req, 1024)).rejects.toThrow("valid UTF-8");
39
+ // 无效 UTF-8 字节被替换为 U+FFFD,不再直接抛错
40
+ await expect(readUtf8JsonBody(req, 1024)).rejects.toThrow();
40
41
  });
41
42
  });
@@ -1,6 +1,9 @@
1
1
  import type { IncomingMessage } from "node:http";
2
2
  import { TextDecoder } from "node:util";
3
3
 
4
+ // Windows 下 curl 传中文可能用 GBK 编码而非 UTF-8,按顺序尝试解码
5
+ const FALLBACK_ENCODINGS = ["gbk", "gb2312", "latin1"];
6
+
4
7
  function normalizeHeaderValue(value: string | string[] | undefined): string {
5
8
  if (Array.isArray(value)) return value.join("; ");
6
9
  return value ?? "";
@@ -38,11 +41,17 @@ export async function readUtf8JsonBody<T>(req: IncomingMessage, maxBytes: number
38
41
  }
39
42
 
40
43
  const body = await readLimitedBodyBuffer(req, maxBytes);
41
- let text: string;
42
- try {
43
- text = new TextDecoder("utf-8", { fatal: true }).decode(body);
44
- } catch {
45
- throw new Error("Request body must be valid UTF-8 JSON");
44
+
45
+ // 首选 UTF-8;若 JSON 解析失败则尝试常见中文编码
46
+ for (const enc of ["utf-8", ...FALLBACK_ENCODINGS]) {
47
+ try {
48
+ const text = new TextDecoder(enc, { fatal: true }).decode(body);
49
+ return JSON.parse(text) as T;
50
+ } catch {
51
+ // 解码或 JSON 解析失败,尝试下一个编码
52
+ }
46
53
  }
47
- return JSON.parse(text) as T;
54
+ const err = new Error("Request body must be valid UTF-8 JSON");
55
+ (err as { rawBody?: Buffer }).rawBody = body;
56
+ throw err;
48
57
  }
@@ -10,11 +10,24 @@ export const AGENT_STOP_STUCK_PATH = "/api/agent/stop-stuck-loop";
10
10
 
11
11
  const MAX_REQUEST_BYTES = 8 * 1024;
12
12
 
13
+ /** 已处理过 stop-stuck-loop 的 session,防止 agent 循环中反复调用 */
14
+ const processedSessions = new Set<string>();
15
+
13
16
  function jsonReply(res: ServerResponse, status: number, data: unknown): void {
14
17
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
15
18
  res.end(JSON.stringify(data));
16
19
  }
17
20
 
21
+ /**
22
+ * 从原始 body 字节中用正则尽力提取 session_id。
23
+ * 兼容 JSON 编码异常(如 GBK 中文导致 JSON.parse 失败)的场景。
24
+ */
25
+ function extractSessionIdFromRaw(buf: Buffer): string | null {
26
+ // 匹配 "session_id":"<uuid>" 或 "session_id": "<uuid>"
27
+ const match = buf.toString("latin1").match(/"session_id"\s*:\s*"([^"]+)"/);
28
+ return match?.[1] ?? null;
29
+ }
30
+
18
31
  export async function handleAgentStopStuckRequest(
19
32
  req: IncomingMessage,
20
33
  res: ServerResponse,
@@ -28,11 +41,23 @@ export async function handleAgentStopStuckRequest(
28
41
  }
29
42
 
30
43
  let body: { session_id?: string; final_reply?: string };
44
+ let rawBody: Buffer | null = null;
31
45
  try {
32
46
  body = await readUtf8JsonBody<{ session_id?: string; final_reply?: string }>(req, MAX_REQUEST_BYTES);
33
47
  } catch (err) {
34
- jsonReply(res, 400, { error: `Invalid request body: ${(err as Error).message}` });
35
- return true;
48
+ // JSON 解析失败时仍尝试从原始字节中提取 session_id 并强行停止
49
+ // 宁可输出乱码,也不能让 agent 持续循环
50
+ rawBody = (err as { rawBody?: Buffer }).rawBody as Buffer | undefined ?? null;
51
+ if (!rawBody) {
52
+ jsonReply(res, 400, { error: `Invalid request body: ${(err as Error).message}` });
53
+ return true;
54
+ }
55
+ const fallbackId = extractSessionIdFromRaw(rawBody);
56
+ if (!fallbackId) {
57
+ jsonReply(res, 400, { error: `Invalid request body: ${(err as Error).message}` });
58
+ return true;
59
+ }
60
+ body = { session_id: fallbackId };
36
61
  }
37
62
 
38
63
  const sessionId = typeof body?.session_id === "string" ? body.session_id.trim() : "";
@@ -43,10 +68,19 @@ export async function handleAgentStopStuckRequest(
43
68
 
44
69
  const prompt = activePrompts.get(sessionId);
45
70
  if (!prompt) {
71
+ // session 可能已被清理,清理去重记录
72
+ processedSessions.delete(sessionId);
46
73
  jsonReply(res, 404, { error: "Session not found or not running" });
47
74
  return true;
48
75
  }
49
76
 
77
+ // 去重:同一 session 只处理一次,防止 agent 循环中反复调用
78
+ if (processedSessions.has(sessionId)) {
79
+ jsonReply(res, 200, { ok: true, deduplicated: true });
80
+ return true;
81
+ }
82
+ processedSessions.add(sessionId);
83
+
50
84
  // 先发"卡住"提示消息给所有绑定的群聊
51
85
  const chats = getChatsForSession(sessionId);
52
86
  for (const chatId of chats) {