chatccc 0.2.143 → 0.2.144

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.
@@ -26,4 +26,8 @@ Content-Type: application/json
26
26
 
27
27
  {"session_id": "{{session_id}}", "final_reply": "<你给用户的最终回复,总结已完成的工作和结果>"}
28
28
 
29
- 调用后停止所有工具调用,直接输出最终回复即可。
29
+ 调用后停止所有工具调用,直接输出最终回复即可。
30
+
31
+ ## 计划/问答模式权限规则
32
+
33
+ 如果用户消息以 `/plan` 或 `/ask` 开头,说明当前处于只读模式。**遇到任何需要用户同意的权限请求时,不要向用户申请权限,立即调用 stop-stuck-loop 接口提前结束本轮会话。**
@@ -0,0 +1,3 @@
1
+ ## 计划/问答模式权限规则
2
+
3
+ 如果用户消息以 `/plan` 或 `/ask` 开头,说明当前处于只读模式。**遇到任何需要用户同意的权限请求时,不要向用户申请权限,立即调用 stop-stuck-loop 接口提前结束本轮会话。**
@@ -0,0 +1,3 @@
1
+ ## 计划/问答模式权限规则
2
+
3
+ 如果用户消息以 `/plan` 或 `/ask` 开头,说明当前处于只读模式。**遇到任何需要用户同意的权限请求时,不要向用户申请权限,立即调用 stop-stuck-loop 接口提前结束本轮会话。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.143",
3
+ "version": "0.2.144",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -169,4 +169,25 @@ export interface ToolAdapter {
169
169
  * 对于流式结束后自动关闭的适配器(如 Claude SDK),此为 no-op。
170
170
  */
171
171
  closeSession(sessionId: string): Promise<void>;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // parseUserCommand — 从包装后的 userText 中提取原始用户消息并判断模式
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export interface UserCommand {
179
+ /** 原始用户消息(去除包装) */
180
+ original: string;
181
+ /** 检测到的模式:plan / ask / null(普通消息) */
182
+ mode: "plan" | "ask" | null;
183
+ }
184
+
185
+ /** 从 [User message]...[/User message] 包装中提取原始消息并识别 /plan /ask 前缀 */
186
+ export function parseUserCommand(userText: string): UserCommand {
187
+ const match = userText.match(/\[User message\]\n(.*?)\n\[\/User message\]/s);
188
+ if (!match) return { original: "", mode: null };
189
+ const original = match[1].trimStart();
190
+ if (original.startsWith("/plan")) return { original, mode: "plan" };
191
+ if (original.startsWith("/ask")) return { original, mode: "ask" };
192
+ return { original, mode: null };
172
193
  }
@@ -21,6 +21,7 @@ import type {
21
21
  UnifiedBlock,
22
22
  UnifiedStreamMessage,
23
23
  } from "./adapter-interface.ts";
24
+ import { parseUserCommand } from "./adapter-interface.ts";
24
25
  import { CHATCCC_PORT } from "../config.ts";
25
26
  import {
26
27
  defaultClaudeSessionMetaStore,
@@ -303,6 +304,7 @@ function buildSdkOptions(args: {
303
304
  baseUrl?: string;
304
305
  maxTurn: number;
305
306
  abortController?: AbortController;
307
+ userText: string;
306
308
  }): ClaudeSdkSessionOptions {
307
309
  const {
308
310
  cwd,
@@ -314,16 +316,30 @@ function buildSdkOptions(args: {
314
316
  baseUrl,
315
317
  maxTurn,
316
318
  abortController,
319
+ userText,
317
320
  } = args;
318
321
 
322
+ const cmd = parseUserCommand(userText);
323
+ const limited = cmd.mode !== null;
324
+
319
325
  const options: ClaudeSdkSessionOptions = {
320
326
  cwd,
321
327
  abortController,
322
328
  settingSources: ["user", "project", "local"] as SettingSource[],
323
- permissionMode: "bypassPermissions",
329
+ permissionMode: limited ? "default" : "bypassPermissions",
324
330
  autoCompactEnabled: true,
325
331
  maxTurns: maxTurn,
326
332
  skills: "all",
333
+ ...(limited ? {
334
+ settings: {
335
+ permissions: {
336
+ allow: [
337
+ "Read",
338
+ `Bash(curl -s -X POST http://127.0.0.1:${CHATCCC_PORT}/api/agent/stop-stuck-loop *)`,
339
+ ],
340
+ },
341
+ },
342
+ } : {}),
327
343
  stderr: (data) => {
328
344
  const trimmed = data.trim();
329
345
  if (!trimmed) return;
@@ -332,7 +348,10 @@ function buildSdkOptions(args: {
332
348
  },
333
349
  };
334
350
 
335
- options.allowDangerouslySkipPermissions = true;
351
+ if (!limited) {
352
+ options.allowDangerouslySkipPermissions = true;
353
+ }
354
+
336
355
  if (!isEmpty(model)) {
337
356
  options.model = model;
338
357
  }
@@ -417,6 +436,7 @@ class ClaudeAdapter implements ToolAdapter {
417
436
  baseUrl: this.baseUrl,
418
437
  maxTurn: this.maxTurn,
419
438
  abortController,
439
+ userText: "",
420
440
  })),
421
441
  );
422
442
 
@@ -466,6 +486,7 @@ class ClaudeAdapter implements ToolAdapter {
466
486
  baseUrl: this.baseUrl,
467
487
  maxTurn: this.maxTurn,
468
488
  abortController,
489
+ userText,
469
490
  })),
470
491
  );
471
492
 
@@ -22,6 +22,7 @@ import type {
22
22
  CreateSessionResult,
23
23
  SessionInfo,
24
24
  } from "./adapter-interface.ts";
25
+ import { parseUserCommand } from "./adapter-interface.ts";
25
26
  import {
26
27
  defaultCodexSessionMetaStore,
27
28
  type CodexSessionMetaStore,
@@ -287,9 +288,13 @@ class CodexAdapter implements ToolAdapter {
287
288
 
288
289
  // 首次 prompt: codex exec 创建新线程
289
290
  // 后续 prompt: codex exec resume 恢复已有线程(resume 不接受 -C,cwd 继承自原线程)
291
+ const cmd = parseUserCommand(userText);
292
+ const baseArgs = cmd.mode
293
+ ? ["exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check"]
294
+ : CODEX_BASE_ARGS;
290
295
  const args = isFirstPrompt
291
- ? [...CODEX_BASE_ARGS, "-C", cwd, "-"]
292
- : [...CODEX_BASE_ARGS, "resume", threadId, "-"];
296
+ ? [...baseArgs, "-C", cwd, "-"]
297
+ : [...baseArgs, "resume", threadId, "-"];
293
298
 
294
299
  const proc = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride);
295
300
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
@@ -19,6 +19,7 @@ import type {
19
19
  CreateSessionResult,
20
20
  SessionInfo,
21
21
  } from "./adapter-interface.ts";
22
+ import { parseUserCommand } from "./adapter-interface.ts";
22
23
  import { CURSOR_AGENT_COMMAND, CURSOR_AGENT_ARGS } from "../config.ts";
23
24
  import {
24
25
  defaultCursorSessionMetaStore,
@@ -298,8 +299,17 @@ function spawnAgent(
298
299
  cwd?: string,
299
300
  stdinText?: string,
300
301
  modelOverride?: string,
302
+ mode?: "plan" | "ask",
301
303
  ): ChildProcess {
302
- let allArgs = [...CURSOR_AGENT_ARGS, ...extraArgs];
304
+ let allArgs: string[];
305
+ if (mode) {
306
+ // plan/ask 模式:移除 --force/--yolo,添加 --mode plan/ask
307
+ allArgs = CURSOR_AGENT_ARGS.filter(a => a !== "--force" && a !== "--yolo");
308
+ allArgs.push("--mode", mode);
309
+ allArgs.push(...extraArgs);
310
+ } else {
311
+ allArgs = [...CURSOR_AGENT_ARGS, ...extraArgs];
312
+ }
303
313
  if (modelOverride) {
304
314
  // 替换全局 --model 为 per-session override
305
315
  const modelIdx = allArgs.findIndex((a, i) => a === "--model" && i + 1 < allArgs.length);
@@ -407,7 +417,14 @@ class CursorAdapter implements ToolAdapter {
407
417
  options?: ToolPromptOptions,
408
418
  ): AsyncIterable<UnifiedStreamMessage> {
409
419
  console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
410
- const proc = spawnAgent(["--resume", sessionId], cwd, buildCursorPromptText(userText), this.modelOverride);
420
+ const cmd = parseUserCommand(userText);
421
+ const proc = spawnAgent(
422
+ ["--resume", sessionId],
423
+ cwd,
424
+ buildCursorPromptText(userText),
425
+ this.modelOverride,
426
+ cmd.mode ?? undefined,
427
+ );
411
428
  this.activeProcs.add(proc);
412
429
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
413
430
 
package/src/feishu-api.ts CHANGED
@@ -737,7 +737,6 @@ export async function sendImageReply(
737
737
  function feishuFileType(ext: string): string {
738
738
  const map: Record<string, string> = {
739
739
  ".mp4": "mp4",
740
- ".mp3": "opus", ".wav": "opus", ".ogg": "opus", ".aac": "opus", ".m4a": "opus",
741
740
  ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
742
741
  ".xls": "xls", ".xlsx": "xls", ".csv": "xls",
743
742
  ".ppt": "ppt", ".pptx": "ppt",
@@ -825,10 +824,6 @@ export async function sendFileReply(
825
824
  try {
826
825
  const fileKey = await uploadMessageFile(token, filePath);
827
826
  const fileName = filePath.split(/[\\/]/).pop() || "file";
828
- const ext = extname(filePath).toLowerCase();
829
- // 视频/音频用 msg_type: "media",文档用 "file"
830
- const isMedia = [".mp3", ".wav", ".ogg", ".aac", ".m4a"].includes(ext);
831
- const msgType = isMedia ? "media" : "file";
832
827
  const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
833
828
  method: "POST",
834
829
  headers: {
@@ -837,16 +832,16 @@ export async function sendFileReply(
837
832
  },
838
833
  body: JSON.stringify({
839
834
  receive_id: chatId,
840
- msg_type: msgType,
835
+ msg_type: "file",
841
836
  content: JSON.stringify({ file_key: fileKey }),
842
837
  }),
843
838
  });
844
839
  const data = (await resp.json().catch(() => ({}))) as { code?: number; msg?: string; data?: { message_id?: string } };
845
840
  if (data.code !== 0) {
846
- console.error(`[${ts()}] [SEND] ${msgType} FAIL: chatId=${chatId} path=${filePath} code=${data.code} msg="${data.msg ?? ""}"`);
841
+ console.error(`[${ts()}] [SEND] file FAIL: chatId=${chatId} path=${filePath} code=${data.code} msg="${data.msg ?? ""}"`);
847
842
  throw new Error(`[${data.code}] ${data.msg ?? "send file failed"}`);
848
843
  }
849
- console.log(`[${ts()}] [SEND] ${msgType} OK: chatId=${chatId} name=${fileName} msgId=${data.data?.message_id ?? "N/A"}`);
844
+ console.log(`[${ts()}] [SEND] file OK: chatId=${chatId} name=${fileName} msgId=${data.data?.message_id ?? "N/A"}`);
850
845
  return true;
851
846
  } catch (err) {
852
847
  if (!(err instanceof Error && err.message.startsWith("["))) {
package/src/session.ts CHANGED
@@ -1165,11 +1165,22 @@ export async function runAgentSession(
1165
1165
  // 运行中并更新旧卡片,而不是新建卡片。
1166
1166
  const finalStatus = (wasAbnormalExit || wasResourceStuck) ? "error" : wasStopped ? "stopped" : "done";
1167
1167
  const finalReply = pickFinalReply(state).trim();
1168
+
1169
+ // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
1170
+ // stream state,finally 不应覆盖它。
1171
+ let finalReplyToWrite = finalReply;
1172
+ try {
1173
+ const existing = await readStreamState(sessionId);
1174
+ if (existing && existing.finalReply.length > finalReply.length) {
1175
+ finalReplyToWrite = existing.finalReply;
1176
+ }
1177
+ } catch {}
1178
+
1168
1179
  await writeStreamState({
1169
1180
  sessionId,
1170
1181
  status: finalStatus,
1171
1182
  accumulatedContent: state.accumulatedContent,
1172
- finalReply,
1183
+ finalReply: finalReplyToWrite,
1173
1184
  chunkCount: state.chunkCount,
1174
1185
  turnCount: nextTurnCount,
1175
1186
  contextTokens: existingInfo?.lastContextTokens ?? 0,