chatccc 0.2.281 → 0.2.283
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 +5 -1
- package/deepccc-agent/README.md +2 -0
- package/deepccc-agent/package.json +1 -1
- package/dist/deepccc-agent/src/index.js +17 -0
- package/dist/src/adapters/ccc-adapter.js +9 -0
- package/dist/src/adapters/claude-adapter.js +21 -1
- package/dist/src/adapters/codex-adapter.js +35 -7
- package/dist/src/adapters/cursor-adapter.js +28 -2
- package/dist/src/adapters/dsh-adapter.js +7 -11
- package/dist/src/adapters/turn-completion.js +25 -0
- package/dist/src/agent-activity.js +6 -1
- package/dist/src/cardkit.js +25 -1
- package/dist/src/feishu-connection.js +144 -0
- package/dist/src/index.js +26 -9
- package/dist/src/session.js +4 -1
- package/dist/src/terminal-error.js +3 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -448,8 +448,12 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
448
448
|
`/restart safe`(短别名 `/restartsf`)与 `/update safe`(短别名 `/updatesf`)会先建立全局准入门禁:指令到达前已经运行或进入单会话缓存队列的工作会继续完成,之后到达的新普通任务会被提示在维护完成后重发。维护任务持久化到 `~/.chatccc/state/safe-maintenance.json`,进程意外退出后可继续排空;依赖安装、会话收尾、自动恢复和 Agent Teams 执行也计入等待条件。内存缓存随重启自然重建,磁盘会话、看板、图片等持久数据不会被清理。
|
|
449
449
|
|
|
450
450
|
ChatCCC 的内部重启和更新使用跨平台父子进程握手:替代进程完成启动预检后通知父进程退出,再等待旧监听端口实际释放并接管 PID;替代进程未就绪或握手超时时,父进程会保留并继续服务。
|
|
451
|
+
|
|
452
|
+
飞书接收长连接启用 15 秒握手超时和 30 秒心跳应答超时;首次启动最多等待 45 秒真实连接确认后才提示就绪。运行期间连接持续异常 90 秒时,会关闭并重新建立接收连接,保留会话、消息去重和正在执行的任务。没有用户消息不会触发重连。连接状态与恢复记录写入运行日志和 `startup-trace.log`(`FEISHU-CONNECTION` / `feishu-connection`)。卡片更新遇到网络失败或序号冲突时不会当成送达成功,错误通知保留文本兜底;TLS 断线会明确显示为网络连接失败,已中断的 Agent 任务不会因此自动重放。
|
|
451
453
|
|
|
452
|
-
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex、CCC Agent 和 DSH 使用各自的 `model` / `alternativeModel`。
|
|
454
|
+
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex、CCC Agent 和 DSH 使用各自的 `model` / `alternativeModel`。
|
|
455
|
+
|
|
456
|
+
Agent 的初始化、输入回显与重连通知不代表任务成功。Cursor、Codex 和 Claude 缺少成功完成事件,或任一 Agent 没有产生有效回复时,会明确提示异常;已生成的部分回复会保留并标为可能不完整。Cursor 重连过程显示在状态区,上游的 `resource_exhausted` / `unavailable` 分别提示请求受限 / 服务暂不可用,不推断为余额耗尽。执行失败不会自动重放整条任务。
|
|
453
457
|
|
|
454
458
|
> **Codex Fast 模式**:Web UI 中的“Fast 模式”设置新 Codex 会话的全局默认值,默认关闭。进入 Codex 会话后,`/fast` 查询当前状态,`/fast on` 和 `/fast off` 只覆盖当前会话并从下一条消息生效。ChatCCC 会显式向 Codex CLI 传入 `service_tier="fast"` 或 `service_tier="default"`,因此关闭时不会继承用户 `config.toml` 中可能开启的 Fast。
|
|
455
459
|
|
package/deepccc-agent/README.md
CHANGED
|
@@ -478,16 +478,25 @@ export class ChatSession {
|
|
|
478
478
|
messages: attemptMessages,
|
|
479
479
|
};
|
|
480
480
|
let stream;
|
|
481
|
+
let requiresFinish = false;
|
|
482
|
+
let receivedFinish = false;
|
|
483
|
+
let finishReason;
|
|
481
484
|
if (this.streaming) {
|
|
482
485
|
const result = streamText(generationOptions);
|
|
486
|
+
requiresFinish = result.fullStream != null;
|
|
483
487
|
stream = result.fullStream ?? textStreamToFullStream(result.textStream);
|
|
484
488
|
}
|
|
485
489
|
else {
|
|
486
490
|
const result = await generateText(generationOptions);
|
|
491
|
+
finishReason = result.finishReason;
|
|
487
492
|
stream = generateResultToFullStream(result);
|
|
488
493
|
}
|
|
489
494
|
for await (const part of stream) {
|
|
490
495
|
rawLog?.writeLine(safeRawStreamJson(part));
|
|
496
|
+
if (part.type === "finish") {
|
|
497
|
+
receivedFinish = true;
|
|
498
|
+
finishReason = part.finishReason;
|
|
499
|
+
}
|
|
491
500
|
if (part.type === "reasoning-start" || part.type === "reasoning-delta") {
|
|
492
501
|
// Reasoning content remains private. A throttled heartbeat is enough
|
|
493
502
|
// for ChatCCC to distinguish active inference from a stalled stream.
|
|
@@ -561,6 +570,14 @@ export class ChatSession {
|
|
|
561
570
|
throw new Error(message);
|
|
562
571
|
}
|
|
563
572
|
}
|
|
573
|
+
if (!signal?.aborted) {
|
|
574
|
+
if (requiresFinish && !receivedFinish)
|
|
575
|
+
throw new Error("DeepCCC 输出流中断:未收到模型完成事件,回复可能不完整");
|
|
576
|
+
if (finishReason === "error" || finishReason === "length")
|
|
577
|
+
throw new Error(`DeepCCC 未正常完成:finishReason=${finishReason},回复可能不完整`);
|
|
578
|
+
if (!fullText.trim() && toolCallOrder.length === 0)
|
|
579
|
+
throw new Error("DeepCCC 本轮未产生有效回复");
|
|
580
|
+
}
|
|
564
581
|
if (hasMalformedToolProtocolText(fullText)) {
|
|
565
582
|
console.warn(`[DeepCCC] malformed tool protocol text detected for ${this.context.sessionId} `
|
|
566
583
|
+ `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
|
|
@@ -2,6 +2,7 @@ import { ChatSession } from "../../deepccc-agent/src/index.js";
|
|
|
2
2
|
import { config as deepCccConfig } from "../../deepccc-agent/src/config.js";
|
|
3
3
|
import { getBuiltinContextSession, newBuiltinSessionId, normalizeBuiltinSessionId, } from "../../deepccc-agent/src/context.js";
|
|
4
4
|
import { config, CCC_SESSION_PREFIX } from "../config.js";
|
|
5
|
+
import { createTurnCompletion } from "./turn-completion.js";
|
|
5
6
|
function toChatSessionOptions(sessionId, cwd, options) {
|
|
6
7
|
return {
|
|
7
8
|
cwd,
|
|
@@ -50,7 +51,12 @@ export function createCccAdapter(options = {}) {
|
|
|
50
51
|
async *prompt(sessionId, userText, cwd, signal, _promptOptions) {
|
|
51
52
|
const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
|
|
52
53
|
const session = new ChatSession(chatConfig, toChatSessionOptions(normalizedSessionId, cwd, options));
|
|
54
|
+
const completion = createTurnCompletion("CCC Agent");
|
|
53
55
|
for await (const event of session.chat(userText, signal)) {
|
|
56
|
+
if (event.type === "text")
|
|
57
|
+
completion.observe({ type: "assistant", blocks: [{ type: "text", text: event.text }] });
|
|
58
|
+
if (event.type === "text_reset")
|
|
59
|
+
completion.observe({ type: "assistant", blocks: [{ type: "text_reset" }] });
|
|
54
60
|
if (event.type === "status") {
|
|
55
61
|
yield {
|
|
56
62
|
type: "assistant",
|
|
@@ -101,6 +107,7 @@ export function createCccAdapter(options = {}) {
|
|
|
101
107
|
};
|
|
102
108
|
}
|
|
103
109
|
else if (event.type === "done" && !signal?.aborted) {
|
|
110
|
+
completion.complete();
|
|
104
111
|
yield {
|
|
105
112
|
type: "assistant",
|
|
106
113
|
blocks: [],
|
|
@@ -111,6 +118,8 @@ export function createCccAdapter(options = {}) {
|
|
|
111
118
|
throw new Error(event.message);
|
|
112
119
|
}
|
|
113
120
|
}
|
|
121
|
+
if (!signal?.aborted)
|
|
122
|
+
completion.assertComplete();
|
|
114
123
|
},
|
|
115
124
|
async getSessionInfo(sessionId) {
|
|
116
125
|
const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
|
|
@@ -11,6 +11,8 @@ import { homedir } from "node:os";
|
|
|
11
11
|
import { delimiter, join } from "node:path";
|
|
12
12
|
import { pathToFileURL } from "node:url";
|
|
13
13
|
import { parseUserCommand } from "./adapter-interface.js";
|
|
14
|
+
import { createTurnCompletion } from "./turn-completion.js";
|
|
15
|
+
import { sanitizeTerminalErrorDetail } from "../terminal-error.js";
|
|
14
16
|
import { CHATCCC_PORT, config, PROJECT_ROOT, RAW_STREAM_LOGS_DIR } from "../config.js";
|
|
15
17
|
import { defaultClaudeSessionMetaStore, } from "./claude-session-meta-store.js";
|
|
16
18
|
import { createRawStreamLog, } from "./raw-stream-log.js";
|
|
@@ -127,6 +129,9 @@ function logMcpConfig() {
|
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
131
|
export function normalizeSdkMessage(msg) {
|
|
132
|
+
if (msg.type === "result" && (msg.subtype !== "success" || msg.is_error === true)) {
|
|
133
|
+
throw new Error(`Claude 执行失败:${sanitizeTerminalErrorDetail(JSON.stringify(msg.errors ?? msg.result ?? msg.subtype ?? "未提供错误详情"))}`);
|
|
134
|
+
}
|
|
130
135
|
// SDK result/success 是 Claude 对本轮完整结束的权威确认。文本已经由之前的
|
|
131
136
|
// assistant 消息累计,因此这里只发送终态信号,避免重复追加 result 文本。
|
|
132
137
|
if (msg.type === "result" && msg.subtype === "success") {
|
|
@@ -399,6 +404,7 @@ class ClaudeAdapter {
|
|
|
399
404
|
return;
|
|
400
405
|
let aborted = false;
|
|
401
406
|
let completed = false;
|
|
407
|
+
const completion = createTurnCompletion("Claude");
|
|
402
408
|
const rawLogConfig = config.rawStreamLogs.claude;
|
|
403
409
|
let rawLog = null;
|
|
404
410
|
const sdk = await loadClaudeSdkModule();
|
|
@@ -449,10 +455,24 @@ class ClaudeAdapter {
|
|
|
449
455
|
}
|
|
450
456
|
}
|
|
451
457
|
const normalized = normalizeSdkMessage(msg);
|
|
458
|
+
completion.observe(normalized);
|
|
459
|
+
if (msg.type === "result") {
|
|
460
|
+
// Some SDK modes only put the final reply in result.result.
|
|
461
|
+
if (msg.result?.trim()) {
|
|
462
|
+
const final = { type: "assistant", blocks: [{ type: "text_final", text: msg.result }] };
|
|
463
|
+
completion.observe(final);
|
|
464
|
+
yield final;
|
|
465
|
+
}
|
|
466
|
+
completion.complete();
|
|
467
|
+
completed = true;
|
|
468
|
+
}
|
|
452
469
|
if (normalized)
|
|
453
470
|
yield normalized;
|
|
471
|
+
if (completed)
|
|
472
|
+
break;
|
|
454
473
|
}
|
|
455
|
-
|
|
474
|
+
if (!aborted && !abortController.signal.aborted)
|
|
475
|
+
completion.assertComplete();
|
|
456
476
|
}
|
|
457
477
|
finally {
|
|
458
478
|
removeAbortListener?.();
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// - getSessionInfo: 从持久化映射读取 cwd / threadId
|
|
8
8
|
// =============================================================================
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
+
import { createTurnCompletion } from "./turn-completion.js";
|
|
10
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import { randomUUID } from "node:crypto";
|
|
@@ -71,8 +72,8 @@ function resolveCodexEffort() {
|
|
|
71
72
|
// normalizeCodexMessage — Codex 事件 → UnifiedStreamMessage | null
|
|
72
73
|
// ---------------------------------------------------------------------------
|
|
73
74
|
export function normalizeCodexMessage(msg) {
|
|
74
|
-
if (msg.type === "error"
|
|
75
|
-
throw new Error(`Codex turn failed: ${msg.message
|
|
75
|
+
if (msg.type === "error") {
|
|
76
|
+
throw new Error(`Codex turn failed: ${msg.message?.trim() || "未提供错误详情"}`);
|
|
76
77
|
}
|
|
77
78
|
if (msg.type === "turn.failed") {
|
|
78
79
|
const detail = typeof msg.error === "string" ? msg.error : msg.error?.message;
|
|
@@ -160,10 +161,18 @@ function spawnCodex(args, cwd, stdinText, modelOverride, effortOverride, fastMod
|
|
|
160
161
|
shell: true,
|
|
161
162
|
});
|
|
162
163
|
let stderr = "";
|
|
164
|
+
let exitCode = null;
|
|
165
|
+
let signal = null;
|
|
166
|
+
let settleClose = () => { };
|
|
167
|
+
const closed = new Promise(resolve => { settleClose = resolve; });
|
|
168
|
+
proc.once("error", (error) => { stderr += `\n${error.message}`; settleClose(); });
|
|
163
169
|
proc.stderr.on("data", (chunk) => {
|
|
164
170
|
stderr += chunk.toString();
|
|
165
171
|
});
|
|
166
|
-
proc.on("close", (code) => {
|
|
172
|
+
proc.on("close", (code, exitSignal) => {
|
|
173
|
+
exitCode = code;
|
|
174
|
+
signal = exitSignal;
|
|
175
|
+
settleClose();
|
|
167
176
|
if (code !== 0 && stderr.trim()) {
|
|
168
177
|
console.error(`[Codex stderr] exit=${code}: ${stderr.trim().slice(0, 2000)}`);
|
|
169
178
|
}
|
|
@@ -172,7 +181,17 @@ function spawnCodex(args, cwd, stdinText, modelOverride, effortOverride, fastMod
|
|
|
172
181
|
proc.stdin.write(stdinText);
|
|
173
182
|
proc.stdin.end();
|
|
174
183
|
}
|
|
175
|
-
return proc
|
|
184
|
+
return { proc, async failureDetail() {
|
|
185
|
+
let timer;
|
|
186
|
+
try {
|
|
187
|
+
await Promise.race([closed, new Promise(resolve => { timer = setTimeout(resolve, 2_000); })]);
|
|
188
|
+
return `exit=${exitCode ?? "unknown"}; signal=${signal ?? "none"}; ${stderr.trim() || "无 stderr 详情"}`;
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
if (timer)
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
}
|
|
194
|
+
} };
|
|
176
195
|
}
|
|
177
196
|
async function* readJsonLines(proc, signal, rawLog) {
|
|
178
197
|
yield* readJsonLinesWithBadJsonIdleWatchdog({
|
|
@@ -219,7 +238,8 @@ class CodexAdapter {
|
|
|
219
238
|
const args = isFirstPrompt
|
|
220
239
|
? [...baseArgs, "-C", cwd, "-"]
|
|
221
240
|
: [...baseArgs, "resume", threadId, "-"];
|
|
222
|
-
const
|
|
241
|
+
const handle = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride, this.effortOverride, this.fastModeOverride);
|
|
242
|
+
const proc = handle.proc;
|
|
223
243
|
if (proc.pid !== undefined)
|
|
224
244
|
options?.onProcessStart?.({ pid: proc.pid });
|
|
225
245
|
const rawLogConfig = config.rawStreamLogs.codex;
|
|
@@ -245,12 +265,11 @@ class CodexAdapter {
|
|
|
245
265
|
const onAbort = () => { void killProcessTree(proc.pid); };
|
|
246
266
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
247
267
|
let completed = false;
|
|
268
|
+
const completion = createTurnCompletion("Codex");
|
|
248
269
|
try {
|
|
249
270
|
for await (const raw of readJsonLines(proc, signal, rawLog)) {
|
|
250
271
|
if (signal?.aborted)
|
|
251
272
|
break;
|
|
252
|
-
if (raw.type === "turn.completed")
|
|
253
|
-
completed = true;
|
|
254
273
|
if (isFirstPrompt &&
|
|
255
274
|
raw.type === "thread.started" &&
|
|
256
275
|
raw.thread_id) {
|
|
@@ -259,9 +278,18 @@ class CodexAdapter {
|
|
|
259
278
|
.catch(() => { });
|
|
260
279
|
}
|
|
261
280
|
const normalized = normalizeCodexMessage(raw);
|
|
281
|
+
completion.observe(normalized);
|
|
282
|
+
if (raw.type === "turn.completed") {
|
|
283
|
+
completion.complete();
|
|
284
|
+
completed = true;
|
|
285
|
+
}
|
|
262
286
|
if (normalized)
|
|
263
287
|
yield normalized;
|
|
288
|
+
if (completed)
|
|
289
|
+
break;
|
|
264
290
|
}
|
|
291
|
+
if (!signal?.aborted && !completed)
|
|
292
|
+
completion.assertComplete(await handle.failureDetail());
|
|
265
293
|
}
|
|
266
294
|
finally {
|
|
267
295
|
signal?.removeEventListener("abort", onAbort);
|
|
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { parseUserCommand } from "./adapter-interface.js";
|
|
11
|
+
import { createTurnCompletion } from "./turn-completion.js";
|
|
11
12
|
import { config, CURSOR_AGENT_COMMAND, CURSOR_AGENT_ARGS, PROJECT_ROOT, RAW_STREAM_LOGS_DIR } from "../config.js";
|
|
12
13
|
import { defaultCursorSessionMetaStore, } from "./cursor-session-meta-store.js";
|
|
13
14
|
import { killProcessTree } from "./proc-tree-kill.js";
|
|
@@ -106,6 +107,15 @@ function mapToolCallKey(key) {
|
|
|
106
107
|
return KEY_MAP[key] ?? key;
|
|
107
108
|
}
|
|
108
109
|
export function normalizeCursorMessage(msg) {
|
|
110
|
+
if (msg.type === "error" || (msg.type === "result" && (msg.is_error === true || msg.subtype?.startsWith("error")))) {
|
|
111
|
+
throw new Error(`Cursor 执行失败:${formatCursorVisibleStderr(JSON.stringify(msg.errors ?? msg.error ?? msg.result ?? msg.subtype ?? "未提供错误详情"))}`);
|
|
112
|
+
}
|
|
113
|
+
if (msg.type === "connection" || msg.type === "retry") {
|
|
114
|
+
return { type: "system", blocks: [{ type: "agent_status",
|
|
115
|
+
status: msg.subtype === "reconnected" ? "responding" : "reconnecting",
|
|
116
|
+
...(Number.isInteger(msg.attempt) && msg.attempt > 0 ? { attempt: msg.attempt } : {}),
|
|
117
|
+
}] };
|
|
118
|
+
}
|
|
109
119
|
if (msg.type === "assistant" && msg.message?.content) {
|
|
110
120
|
// 按 cursor 官方 stream-json 规范区分三类 assistant 事件,避免 text 重复累加:
|
|
111
121
|
// ┌────────────────┬───────────────┬─────────────────┐
|
|
@@ -312,8 +322,17 @@ function spawnAgent(extraArgs, cwd, stdinText, modelOverride, mode, spawnImpl =
|
|
|
312
322
|
waitForClose: async () => {
|
|
313
323
|
if (closeInfo)
|
|
314
324
|
return { ...closeInfo, stderr };
|
|
315
|
-
|
|
316
|
-
|
|
325
|
+
let timer;
|
|
326
|
+
try {
|
|
327
|
+
const info = await Promise.race([closePromise, new Promise(resolve => {
|
|
328
|
+
timer = setTimeout(() => resolve({ code: null, signal: null, stderr }), 2_000);
|
|
329
|
+
})]);
|
|
330
|
+
return { ...info, stderr };
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
if (timer)
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
}
|
|
317
336
|
},
|
|
318
337
|
};
|
|
319
338
|
}
|
|
@@ -425,6 +444,7 @@ class CursorAdapter {
|
|
|
425
444
|
const onAbort = () => { void killProcessTree(proc.pid); };
|
|
426
445
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
427
446
|
let sawResult = false;
|
|
447
|
+
const completion = createTurnCompletion("Cursor");
|
|
428
448
|
const stats = createCursorStreamStats();
|
|
429
449
|
try {
|
|
430
450
|
for await (const raw of readJsonLines(proc, signal, sessionId, rawLog, stats, this.badJsonIdleTimeoutMs)) {
|
|
@@ -439,10 +459,15 @@ class CursorAdapter {
|
|
|
439
459
|
.catch(() => { });
|
|
440
460
|
}
|
|
441
461
|
const normalized = normalizeCursorMessage(raw);
|
|
462
|
+
completion.observe(normalized);
|
|
463
|
+
if (raw.type === "result")
|
|
464
|
+
completion.complete();
|
|
442
465
|
if (normalized)
|
|
443
466
|
yield normalized;
|
|
444
467
|
// result 是流末事件,收到后立即结束进程,防止 CLI 僵死导致 readline 挂起。
|
|
445
468
|
if (raw.type === "result") {
|
|
469
|
+
if (!normalized?.isFinalResponse)
|
|
470
|
+
yield { type: "assistant", blocks: [], isFinalResponse: true };
|
|
446
471
|
sawResult = true;
|
|
447
472
|
void killProcessTree(proc.pid);
|
|
448
473
|
break;
|
|
@@ -462,6 +487,7 @@ class CursorAdapter {
|
|
|
462
487
|
};
|
|
463
488
|
throw createCursorAgentFailureError(closeInfo);
|
|
464
489
|
}
|
|
490
|
+
completion.assertComplete(`exit=${closeInfo.code ?? "unknown"}; signal=${closeInfo.signal ?? "none"}; ${formatCursorVisibleStderr(closeInfo.stderr) || "无 stderr 详情"}`);
|
|
465
491
|
}
|
|
466
492
|
}
|
|
467
493
|
finally {
|
|
@@ -125,22 +125,18 @@ export function createDshAdapter(options = {}) {
|
|
|
125
125
|
queue.push(message);
|
|
126
126
|
},
|
|
127
127
|
});
|
|
128
|
-
if (
|
|
128
|
+
if (turnError) {
|
|
129
|
+
if (result.finalResponse?.trim())
|
|
130
|
+
queue.push({ type: "assistant", blocks: [{ type: "text_final", text: result.finalResponse }] });
|
|
131
|
+
queue.fail(turnError);
|
|
132
|
+
}
|
|
133
|
+
else if (result.finalResponse?.trim()) {
|
|
129
134
|
rawLog?.writeLine(JSON.stringify({ type: "run.result", result }));
|
|
130
135
|
queue.push({ type: "assistant", blocks: [{ type: "text_final", text: result.finalResponse }], isFinalResponse: true });
|
|
131
136
|
completed = true;
|
|
132
137
|
}
|
|
133
|
-
else if (turnError) {
|
|
134
|
-
queue.fail(turnError);
|
|
135
|
-
}
|
|
136
138
|
else {
|
|
137
|
-
|
|
138
|
-
queue.push({
|
|
139
|
-
type: "assistant",
|
|
140
|
-
blocks: [{ type: "text_final", text: "DeepSeek Harness 本轮未产生任何回复(引擎未报告错误)。" }],
|
|
141
|
-
isFinalResponse: true,
|
|
142
|
-
});
|
|
143
|
-
completed = true;
|
|
139
|
+
queue.fail(new Error("DeepSeek Harness 本轮未产生有效回复(引擎未报告错误)。"));
|
|
144
140
|
}
|
|
145
141
|
if (completed) {
|
|
146
142
|
knownSessions.set(sessionId, { sessionId, cwd, lastModified: Date.now(), model: options.model ?? "deepseek-v4-flash" });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { sanitizeTerminalErrorDetail } from "../terminal-error.js";
|
|
2
|
+
/** Protocol output (init, echo, heartbeat, tools) is not a successful reply. */
|
|
3
|
+
export function createTurnCompletion(tool) {
|
|
4
|
+
let hasReply = false;
|
|
5
|
+
let completed = false;
|
|
6
|
+
return {
|
|
7
|
+
observe(message) {
|
|
8
|
+
for (const block of message?.blocks ?? []) {
|
|
9
|
+
if (block.type === "text_reset")
|
|
10
|
+
hasReply = false;
|
|
11
|
+
if (message?.type === "assistant" && (block.type === "text" || block.type === "text_final") && block.text.trim())
|
|
12
|
+
hasReply = true;
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
complete() {
|
|
16
|
+
if (!hasReply)
|
|
17
|
+
throw new Error(`${tool} 本轮未产生有效回复(收到结束事件,但回复为空)。`);
|
|
18
|
+
completed = true;
|
|
19
|
+
},
|
|
20
|
+
assertComplete(detail = "") {
|
|
21
|
+
if (!completed)
|
|
22
|
+
throw new Error(`${tool} 未正常完成:输出流结束但未收到成功完成事件${detail ? `;${sanitizeTerminalErrorDetail(detail)}` : ""}。`);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -7,6 +7,7 @@ export function createAgentActivityTracker(now = Date.now()) {
|
|
|
7
7
|
}
|
|
8
8
|
function sameVisibleActivity(left, right) {
|
|
9
9
|
return left.kind === right.kind
|
|
10
|
+
&& left.attempt === right.attempt
|
|
10
11
|
&& left.toolName === right.toolName
|
|
11
12
|
&& left.toolCount === right.toolCount;
|
|
12
13
|
}
|
|
@@ -73,8 +74,9 @@ export function updateAgentActivity(tracker, block, now = Date.now()) {
|
|
|
73
74
|
return setActivity(tracker, { kind: "responding", startedAt: now });
|
|
74
75
|
case "agent_status":
|
|
75
76
|
return setActivity(tracker, {
|
|
76
|
-
kind: block.status
|
|
77
|
+
kind: block.status,
|
|
77
78
|
startedAt: now,
|
|
79
|
+
...(block.attempt ? { attempt: block.attempt } : {}),
|
|
78
80
|
});
|
|
79
81
|
case "thinking":
|
|
80
82
|
case "redacted_thinking":
|
|
@@ -126,6 +128,9 @@ export function formatAgentActivityTitle(activity, now = Date.now()) {
|
|
|
126
128
|
case "responding":
|
|
127
129
|
label = "正在生成回复";
|
|
128
130
|
break;
|
|
131
|
+
case "reconnecting":
|
|
132
|
+
label = `正在重新连接模型服务${activity.attempt ? `(第 ${activity.attempt} 次)` : ""}`;
|
|
133
|
+
break;
|
|
129
134
|
case "searching":
|
|
130
135
|
label = "正在处理搜索结果";
|
|
131
136
|
break;
|
package/dist/src/cardkit.js
CHANGED
|
@@ -103,7 +103,31 @@ export async function streamCardKitElement(token, cardId, elementId, content, se
|
|
|
103
103
|
// success log is intentionally sparse — uncomment to debug streaming throughput
|
|
104
104
|
// console.log(`[${ts()}] [CARDIKT] streamElement OK cardId=${cardId} seq=${sequence}`);
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
// Keep bounded per-card sequence history; never reuse an attempted sequence
|
|
107
|
+
// because a failed fetch can mean the response (not the update) was lost.
|
|
108
|
+
const cardUpdateQueues = new Map();
|
|
109
|
+
export function updateCardKitCard(token, cardId, cardJson, sequence) {
|
|
110
|
+
let state = cardUpdateQueues.get(cardId);
|
|
111
|
+
if (!state) {
|
|
112
|
+
for (const [key, candidate] of cardUpdateQueues) {
|
|
113
|
+
if (cardUpdateQueues.size < 512)
|
|
114
|
+
break;
|
|
115
|
+
if (candidate.pending === 0)
|
|
116
|
+
cardUpdateQueues.delete(key);
|
|
117
|
+
}
|
|
118
|
+
state = { sequence: 0, tail: Promise.resolve(), pending: 0 };
|
|
119
|
+
cardUpdateQueues.set(cardId, state);
|
|
120
|
+
}
|
|
121
|
+
const queue = state;
|
|
122
|
+
queue.pending++;
|
|
123
|
+
const result = queue.tail.then(async () => {
|
|
124
|
+
queue.sequence = Math.max(sequence, queue.sequence + 1);
|
|
125
|
+
await performCardKitUpdate(token, cardId, cardJson, queue.sequence);
|
|
126
|
+
});
|
|
127
|
+
queue.tail = result.catch(() => { }).finally(() => { queue.pending--; });
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
async function performCardKitUpdate(token, cardId, cardJson, sequence) {
|
|
107
131
|
const { resp, respText } = await fetchCardKit(`${BASE_URL}/cardkit/v1/cards/${cardId}`, {
|
|
108
132
|
method: "PUT",
|
|
109
133
|
headers: {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { sanitizeTerminalErrorDetail } from "./terminal-error.js";
|
|
2
|
+
/** Owns only the receiving transport. It never resets sessions or replays tasks. */
|
|
3
|
+
export function createFeishuConnectionSupervisor(options) {
|
|
4
|
+
const stalledAfterMs = options.stalledAfterMs ?? 90_000;
|
|
5
|
+
let stopped = false;
|
|
6
|
+
let client;
|
|
7
|
+
let timer;
|
|
8
|
+
let startupTimer;
|
|
9
|
+
let startup;
|
|
10
|
+
let resolveStartup;
|
|
11
|
+
let rejectStartup;
|
|
12
|
+
let unhealthySince;
|
|
13
|
+
let lastState = "idle";
|
|
14
|
+
let connected = false;
|
|
15
|
+
let launching = false;
|
|
16
|
+
let lastAttempt = 0;
|
|
17
|
+
const log = (event, detail) => {
|
|
18
|
+
try {
|
|
19
|
+
options.log(event, detail);
|
|
20
|
+
}
|
|
21
|
+
catch { /* diagnostics cannot break recovery */ }
|
|
22
|
+
};
|
|
23
|
+
const reportError = (error) => {
|
|
24
|
+
if (stopped)
|
|
25
|
+
return;
|
|
26
|
+
connected = false;
|
|
27
|
+
unhealthySince ??= Date.now();
|
|
28
|
+
log("connection error", { reason: sanitizeTerminalErrorDetail(error instanceof Error ? error.message : String(error)) });
|
|
29
|
+
};
|
|
30
|
+
const becameConnected = () => {
|
|
31
|
+
if (stopped) {
|
|
32
|
+
// A handshake already in flight can finish after close(); never resurrect
|
|
33
|
+
// a transport belonging to a stopped setup attempt or shutting-down app.
|
|
34
|
+
try {
|
|
35
|
+
client?.close({ force: true });
|
|
36
|
+
}
|
|
37
|
+
catch { /* best effort */ }
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (connected)
|
|
41
|
+
return;
|
|
42
|
+
connected = true;
|
|
43
|
+
unhealthySince = undefined;
|
|
44
|
+
lastState = "connected";
|
|
45
|
+
log("connected");
|
|
46
|
+
if (startupTimer)
|
|
47
|
+
clearTimeout(startupTimer);
|
|
48
|
+
resolveStartup?.();
|
|
49
|
+
resolveStartup = undefined;
|
|
50
|
+
rejectStartup = undefined;
|
|
51
|
+
void Promise.resolve().then(() => stopped ? undefined : options.onConnected()).catch((error) => {
|
|
52
|
+
log("binding recovery failed", { reason: sanitizeTerminalErrorDetail(String(error)) });
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const stop = () => {
|
|
56
|
+
if (stopped)
|
|
57
|
+
return;
|
|
58
|
+
stopped = true;
|
|
59
|
+
if (timer)
|
|
60
|
+
clearInterval(timer);
|
|
61
|
+
if (startupTimer)
|
|
62
|
+
clearTimeout(startupTimer);
|
|
63
|
+
try {
|
|
64
|
+
client?.close({ force: true });
|
|
65
|
+
}
|
|
66
|
+
catch { /* best effort shutdown */ }
|
|
67
|
+
rejectStartup?.(new Error("飞书长连接在就绪前已停止"));
|
|
68
|
+
resolveStartup = undefined;
|
|
69
|
+
rejectStartup = undefined;
|
|
70
|
+
log("stopped");
|
|
71
|
+
};
|
|
72
|
+
const launch = () => {
|
|
73
|
+
if (stopped || launching || !client)
|
|
74
|
+
return;
|
|
75
|
+
launching = true;
|
|
76
|
+
lastAttempt = Date.now();
|
|
77
|
+
unhealthySince = lastAttempt;
|
|
78
|
+
void Promise.resolve().then(() => stopped ? undefined : client.start())
|
|
79
|
+
.catch(reportError).finally(() => { launching = false; });
|
|
80
|
+
};
|
|
81
|
+
const check = () => {
|
|
82
|
+
if (stopped || !client)
|
|
83
|
+
return;
|
|
84
|
+
try {
|
|
85
|
+
const status = client.getConnectionStatus();
|
|
86
|
+
if (status.state !== lastState) {
|
|
87
|
+
lastState = status.state;
|
|
88
|
+
log("state", { ...status });
|
|
89
|
+
}
|
|
90
|
+
if (status.state === "connected") {
|
|
91
|
+
becameConnected();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
connected = false;
|
|
95
|
+
unhealthySince ??= Date.now();
|
|
96
|
+
// SDK gets a bounded chance to recover. Silence from users is irrelevant.
|
|
97
|
+
if (launching || Date.now() - unhealthySince < stalledAfterMs
|
|
98
|
+
|| Date.now() - lastAttempt < stalledAfterMs)
|
|
99
|
+
return;
|
|
100
|
+
log("restarting receiving connection", { ...status, disconnectedForMs: Date.now() - unhealthySince });
|
|
101
|
+
client.close({ force: true });
|
|
102
|
+
launch();
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
reportError(error);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const start = () => {
|
|
109
|
+
if (startup)
|
|
110
|
+
return startup;
|
|
111
|
+
if (stopped)
|
|
112
|
+
return Promise.reject(new Error("飞书长连接已停止"));
|
|
113
|
+
startup = new Promise((resolve, reject) => { resolveStartup = resolve; rejectStartup = reject; });
|
|
114
|
+
startupTimer = setTimeout(() => {
|
|
115
|
+
rejectStartup?.(new Error("飞书长连接握手超时,尚未收到连接成功确认"));
|
|
116
|
+
rejectStartup = undefined;
|
|
117
|
+
stop();
|
|
118
|
+
}, options.startupTimeoutMs ?? 45_000);
|
|
119
|
+
try {
|
|
120
|
+
client = options.createClient({
|
|
121
|
+
onReady: becameConnected,
|
|
122
|
+
onReconnected: becameConnected,
|
|
123
|
+
onReconnecting() {
|
|
124
|
+
if (stopped)
|
|
125
|
+
return;
|
|
126
|
+
connected = false;
|
|
127
|
+
unhealthySince ??= Date.now();
|
|
128
|
+
log("reconnecting");
|
|
129
|
+
},
|
|
130
|
+
onError: reportError,
|
|
131
|
+
});
|
|
132
|
+
timer = setInterval(check, options.checkIntervalMs ?? 10_000);
|
|
133
|
+
timer.unref();
|
|
134
|
+
launch();
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
rejectStartup?.(error instanceof Error ? error : new Error(String(error)));
|
|
138
|
+
rejectStartup = undefined;
|
|
139
|
+
stop();
|
|
140
|
+
}
|
|
141
|
+
return startup;
|
|
142
|
+
};
|
|
143
|
+
return { start, stop };
|
|
144
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { createServer } from "node:http";
|
|
25
25
|
import { WSClient, EventDispatcher, Domain } from "@larksuiteoapi/node-sdk";
|
|
26
|
+
import { createFeishuConnectionSupervisor } from "./feishu-connection.js";
|
|
26
27
|
import WebSocket from "ws";
|
|
27
28
|
import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, installEpipeGuard, waitForPortFree } from "./shared.js";
|
|
28
29
|
import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.js";
|
|
@@ -256,6 +257,7 @@ async function startBotService(opts) {
|
|
|
256
257
|
throw err;
|
|
257
258
|
}
|
|
258
259
|
}
|
|
260
|
+
let feishuConnection;
|
|
259
261
|
async function startBotServiceCore() {
|
|
260
262
|
const modeTag = USE_LOCAL ? " (local relay mode)" : "";
|
|
261
263
|
console.log(`${"=".repeat(60)}`);
|
|
@@ -420,20 +422,33 @@ async function startBotServiceCore() {
|
|
|
420
422
|
// - 已处理消息的去重 set 必须保留,避免 SDK 重推老消息时 prompt 跑两遍
|
|
421
423
|
// 历史 bug:此处曾误调 resetState() 导致重连即让所有后台任务变孤儿,
|
|
422
424
|
// 同一 session 还可能双开 prompt(详见 session.ts::resetState 注释)。
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
425
|
+
feishuConnection?.stop();
|
|
426
|
+
feishuConnection = createFeishuConnectionSupervisor({
|
|
427
|
+
createClient(callbacks) {
|
|
428
|
+
const client = new WSClient({
|
|
429
|
+
appId: APP_ID,
|
|
430
|
+
appSecret: APP_SECRET,
|
|
431
|
+
domain: FEISHU_PLATFORM_TYPE === "lark" ? Domain.Lark : Domain.Feishu,
|
|
432
|
+
autoReconnect: true,
|
|
433
|
+
handshakeTimeoutMs: 15_000,
|
|
434
|
+
wsConfig: { pingTimeout: 30 },
|
|
435
|
+
...callbacks,
|
|
436
|
+
});
|
|
437
|
+
return {
|
|
438
|
+
start: () => client.start({ eventDispatcher }),
|
|
439
|
+
close: (options) => client.close(options),
|
|
440
|
+
getConnectionStatus: () => client.getConnectionStatus(),
|
|
441
|
+
};
|
|
429
442
|
},
|
|
430
|
-
|
|
431
|
-
|
|
443
|
+
onConnected: rebuildBindingsFromRegistry,
|
|
444
|
+
log(event, detail) {
|
|
445
|
+
appendStartupTrace(`feishu-connection: ${event}`, detail);
|
|
446
|
+
console.log(`[${ts()}] [FEISHU-CONNECTION] ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`);
|
|
432
447
|
},
|
|
433
448
|
});
|
|
434
449
|
console.log(`\n[启动 6/7] 飞书长连接:正在通过 SDK 建立 WebSocket …`);
|
|
435
450
|
try {
|
|
436
|
-
await
|
|
451
|
+
await feishuConnection.start();
|
|
437
452
|
}
|
|
438
453
|
catch (err) {
|
|
439
454
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -790,6 +805,7 @@ function installShutdownHandlers(httpServer, serviceLifecycle) {
|
|
|
790
805
|
process.on("SIGINT", () => {
|
|
791
806
|
console.log("\nShutting down...");
|
|
792
807
|
serviceLifecycle.beginShutdown("SIGINT");
|
|
808
|
+
feishuConnection?.stop();
|
|
793
809
|
wechatSignal.stopped = true;
|
|
794
810
|
stopChromeDevtoolsGuard();
|
|
795
811
|
httpServer.close();
|
|
@@ -797,6 +813,7 @@ function installShutdownHandlers(httpServer, serviceLifecycle) {
|
|
|
797
813
|
});
|
|
798
814
|
process.on("SIGTERM", () => {
|
|
799
815
|
serviceLifecycle.beginShutdown("SIGTERM");
|
|
816
|
+
feishuConnection?.stop();
|
|
800
817
|
wechatSignal.stopped = true;
|
|
801
818
|
stopChromeDevtoolsGuard();
|
|
802
819
|
httpServer.close();
|
package/dist/src/session.js
CHANGED
|
@@ -1785,7 +1785,6 @@ export function startUnifiedDisplayLoop() {
|
|
|
1785
1785
|
console.error(`[${ts()}] [DISPLAY] terminal cardUpdate failed: ${err.message}`);
|
|
1786
1786
|
if (isCardKitSequenceConflict(err)) {
|
|
1787
1787
|
display.sequence = nextSeq;
|
|
1788
|
-
terminalCardUpdateAccepted = true;
|
|
1789
1788
|
}
|
|
1790
1789
|
});
|
|
1791
1790
|
if (terminalCardUpdateAccepted) {
|
|
@@ -1943,6 +1942,8 @@ export function startUnifiedDisplayLoop() {
|
|
|
1943
1942
|
catch (err) {
|
|
1944
1943
|
const errMsg = err.message;
|
|
1945
1944
|
console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${errMsg}`);
|
|
1945
|
+
display.lastSentContent = "";
|
|
1946
|
+
display.lastSentHeaderTitle = "";
|
|
1946
1947
|
if (errMsg.includes("300317")) {
|
|
1947
1948
|
display.sequence = mySeq;
|
|
1948
1949
|
}
|
|
@@ -1977,6 +1978,8 @@ export function startUnifiedDisplayLoop() {
|
|
|
1977
1978
|
catch (err) {
|
|
1978
1979
|
const errMsg = err.message;
|
|
1979
1980
|
console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${errMsg}`);
|
|
1981
|
+
display.lastSentContent = "";
|
|
1982
|
+
display.lastSentHeaderTitle = "";
|
|
1980
1983
|
if (errMsg.includes("300317")) {
|
|
1981
1984
|
display.sequence = mySeq;
|
|
1982
1985
|
}
|
|
@@ -37,7 +37,7 @@ export function classifyTerminalError(error, occurredAt = Date.now()) {
|
|
|
37
37
|
const lower = raw.toLowerCase();
|
|
38
38
|
const attempts = parsePositiveInt(raw, /\bafter\s+(\d+)\s+attempts?\b/i);
|
|
39
39
|
const timeoutMs = parsePositiveInt(raw, /\btimeout\s*:\s*(\d+)\s*ms\b/i);
|
|
40
|
-
if (/\b429\b|rate[ _-]?limit|too many requests/.test(lower)) {
|
|
40
|
+
if (/\b429\b|rate[ _-]?limit|too many requests|resource_exhausted/.test(lower)) {
|
|
41
41
|
return {
|
|
42
42
|
kind: "rate_limit",
|
|
43
43
|
title: "请求受到限流",
|
|
@@ -64,7 +64,7 @@ export function classifyTerminalError(error, occurredAt = Date.now()) {
|
|
|
64
64
|
occurredAt,
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
|
-
if (/econnrefused|econnreset|enotfound|eai_again|socket hang up|network error|cannot connect/.test(lower)) {
|
|
67
|
+
if (/econnrefused|econnreset|enotfound|eai_again|socket hang up|network error|cannot connect|tls handshake|before secure tls connection|stream disconnected before completion|websocket closed/.test(lower)) {
|
|
68
68
|
return {
|
|
69
69
|
kind: "network",
|
|
70
70
|
title: "无法连接模型服务",
|
|
@@ -73,7 +73,7 @@ export function classifyTerminalError(error, occurredAt = Date.now()) {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
const httpStatus = raw.match(/\b(?:HTTP\s*)?(5\d\d)\b/i)?.[1];
|
|
76
|
-
if (httpStatus || /service unavailable|bad gateway|gateway timeout|provider error/.test(lower)) {
|
|
76
|
+
if (httpStatus || /service unavailable|\[unavailable\]|bad gateway|gateway timeout|provider error/.test(lower)) {
|
|
77
77
|
return {
|
|
78
78
|
kind: "provider",
|
|
79
79
|
title: "模型服务暂时不可用",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.283",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@ai-sdk/anthropic": "^3.0.105",
|
|
55
55
|
"@ai-sdk/openai-compatible": "^2.0.47",
|
|
56
|
-
"@larksuiteoapi/node-sdk": "^1.
|
|
56
|
+
"@larksuiteoapi/node-sdk": "^1.66.1",
|
|
57
57
|
"@openilink/openilink-sdk-node": "^0.6.0",
|
|
58
58
|
"@vscode/ripgrep": "^1.18.0",
|
|
59
59
|
"ai": "^6.0.184",
|