chatccc 0.2.142 → 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.
- package/agent-prompts/claude_specific.md +6 -2
- package/agent-prompts/codex_specific.md +3 -0
- package/agent-prompts/cursor_specific.md +3 -0
- package/package.json +1 -1
- package/src/adapters/adapter-interface.ts +21 -0
- package/src/adapters/claude-adapter.ts +23 -2
- package/src/adapters/codex-adapter.ts +7 -2
- package/src/adapters/cursor-adapter.ts +19 -2
- package/src/agent-stop-stuck.ts +6 -3
- package/src/feishu-api.ts +3 -8
- package/src/session.ts +12 -1
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
POST {{stop_stuck_url}}
|
|
25
25
|
Content-Type: application/json
|
|
26
26
|
|
|
27
|
-
{"session_id": "{{session_id}}"}
|
|
27
|
+
{"session_id": "{{session_id}}", "final_reply": "<你给用户的最终回复,总结已完成的工作和结果>"}
|
|
28
28
|
|
|
29
|
-
调用后停止所有工具调用,直接输出最终回复即可。
|
|
29
|
+
调用后停止所有工具调用,直接输出最终回复即可。
|
|
30
|
+
|
|
31
|
+
## 计划/问答模式权限规则
|
|
32
|
+
|
|
33
|
+
如果用户消息以 `/plan` 或 `/ask` 开头,说明当前处于只读模式。**遇到任何需要用户同意的权限请求时,不要向用户申请权限,立即调用 stop-stuck-loop 接口提前结束本轮会话。**
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
? [...
|
|
292
|
-
: [...
|
|
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
|
|
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
|
|
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/agent-stop-stuck.ts
CHANGED
|
@@ -27,9 +27,9 @@ export async function handleAgentStopStuckRequest(
|
|
|
27
27
|
return true;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
let body: { session_id?: string };
|
|
30
|
+
let body: { session_id?: string; final_reply?: string };
|
|
31
31
|
try {
|
|
32
|
-
body = await readUtf8JsonBody<{ session_id?: string }>(req, MAX_REQUEST_BYTES);
|
|
32
|
+
body = await readUtf8JsonBody<{ session_id?: string; final_reply?: string }>(req, MAX_REQUEST_BYTES);
|
|
33
33
|
} catch (err) {
|
|
34
34
|
jsonReply(res, 400, { error: `Invalid request body: ${(err as Error).message}` });
|
|
35
35
|
return true;
|
|
@@ -59,6 +59,8 @@ export async function handleAgentStopStuckRequest(
|
|
|
59
59
|
// 丢弃缓存队列中的消息
|
|
60
60
|
cancelQueuedMessage(sessionId);
|
|
61
61
|
|
|
62
|
+
const finalReply = typeof body?.final_reply === "string" ? body.final_reply.trim() : "";
|
|
63
|
+
|
|
62
64
|
// fire-and-forget:立即把 stream-state 标为 done(而非 stopped),
|
|
63
65
|
// 让 display loop 以"正常完成"而非"已停止"来渲染卡片和最终回复。
|
|
64
66
|
// 不设 prompt.stopped = true,这样 runAgentSession 的 finally 也会写 "done"。
|
|
@@ -70,6 +72,7 @@ export async function handleAgentStopStuckRequest(
|
|
|
70
72
|
await writeStreamState({
|
|
71
73
|
...current,
|
|
72
74
|
status: "done",
|
|
75
|
+
finalReply: finalReply ? current.finalReply + "\n\n" + finalReply : current.finalReply,
|
|
73
76
|
updatedAt: Date.now(),
|
|
74
77
|
});
|
|
75
78
|
} catch (err) {
|
|
@@ -82,7 +85,7 @@ export async function handleAgentStopStuckRequest(
|
|
|
82
85
|
// 不设 stopped 标记 → finally block 写 "done" → 卡片正常结束
|
|
83
86
|
prompt.controller.abort();
|
|
84
87
|
|
|
85
|
-
console.log(`[${ts()}] [STUCK-LOOP] Session ${sessionId} aborted as done (agent detected stuck loop)`);
|
|
88
|
+
console.log(`[${ts()}] [STUCK-LOOP] Session ${sessionId} aborted as done (agent detected stuck loop, final_reply=${finalReply ? "yes" : "no"})`);
|
|
86
89
|
|
|
87
90
|
jsonReply(res, 200, { ok: true });
|
|
88
91
|
return true;
|
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:
|
|
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]
|
|
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]
|
|
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,
|