chatccc 0.2.88 → 0.2.90

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/package.json CHANGED
@@ -1,63 +1,63 @@
1
- {
2
- "name": "chatccc",
3
- "version": "0.2.88",
4
- "description": "Feishu bot bridge for Claude Code",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "main": "./src/index.ts",
8
- "bin": {
9
- "chatccc": "bin/chatccc.mjs"
10
- },
11
- "files": [
12
- "src/",
13
- "bin/",
14
- "scripts/postinstall-sharp-check.mjs",
15
- "demo/ilink_echo_probe.ts",
16
- "im-skills/",
17
- "images/img_readme_*.jpg",
18
- "images/img_readme_*.png",
19
- "images/avatars/status_*.png",
20
- "images/avatars/badges/",
21
- "package.json",
22
- "README.md",
23
- "config.sample.json"
24
- ],
25
- "scripts": {
26
- "dev": "tsx src/index.ts",
27
- "chatccc": "tsx src/index.ts",
28
- "start": "tsx src/index.ts",
29
- "demo:bot-test": "tsx demo/bot_test.ts",
30
- "demo:bot-test:local": "tsx demo/bot_test.ts --local",
31
- "demo:create-group": "tsx src/index.ts",
32
- "demo:create-group:local": "tsx src/index.ts --local",
33
- "demo:permission-check": "tsx demo/permission_check.ts",
34
- "demo:claude-hi": "tsx demo/claude_say_hi.ts",
35
- "demo:codex-hi": "tsx demo/codex_say_hi.ts",
36
- "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
37
- "test": "vitest run",
38
- "test:watch": "vitest",
39
- "postinstall": "node scripts/postinstall-sharp-check.mjs"
40
- },
41
- "dependencies": {
42
- "@ai-sdk/openai-compatible": "^2.0.47",
43
- "@anthropic-ai/claude-agent-sdk": "0.2.133",
44
- "@larksuiteoapi/node-sdk": "^1.59.0",
45
- "@openilink/openilink-sdk-node": "^0.6.0",
46
- "ai": "^6.0.184",
47
- "nodemailer": "^8.0.7",
48
- "qrcode-terminal": "^0.12.0",
49
- "sharp": "^0.34.5",
50
- "tsx": "^4.0.0",
51
- "ws": "^8.18.0"
52
- },
53
- "devDependencies": {
54
- "@types/node": "^20.0.0",
55
- "@types/qrcode-terminal": "^0.12.2",
56
- "@types/ws": "^8.18.1",
57
- "typescript": "^5.0.0",
58
- "vitest": "^3.2.4"
59
- },
60
- "engines": {
61
- "node": ">=20"
62
- }
63
- }
1
+ {
2
+ "name": "chatccc",
3
+ "version": "0.2.90",
4
+ "description": "Feishu bot bridge for Claude Code",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "bin": {
9
+ "chatccc": "bin/chatccc.mjs"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "bin/",
14
+ "scripts/postinstall-sharp-check.mjs",
15
+ "demo/ilink_echo_probe.ts",
16
+ "im-skills/",
17
+ "images/img_readme_*.jpg",
18
+ "images/img_readme_*.png",
19
+ "images/avatars/status_*.png",
20
+ "images/avatars/badges/",
21
+ "package.json",
22
+ "README.md",
23
+ "config.sample.json"
24
+ ],
25
+ "scripts": {
26
+ "dev": "tsx src/index.ts",
27
+ "chatccc": "tsx src/index.ts",
28
+ "start": "tsx src/index.ts",
29
+ "demo:bot-test": "tsx demo/bot_test.ts",
30
+ "demo:bot-test:local": "tsx demo/bot_test.ts --local",
31
+ "demo:create-group": "tsx src/index.ts",
32
+ "demo:create-group:local": "tsx src/index.ts --local",
33
+ "demo:permission-check": "tsx demo/permission_check.ts",
34
+ "demo:claude-hi": "tsx demo/claude_say_hi.ts",
35
+ "demo:codex-hi": "tsx demo/codex_say_hi.ts",
36
+ "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "postinstall": "node scripts/postinstall-sharp-check.mjs"
40
+ },
41
+ "dependencies": {
42
+ "@ai-sdk/openai-compatible": "^2.0.47",
43
+ "@anthropic-ai/claude-agent-sdk": "0.2.133",
44
+ "@larksuiteoapi/node-sdk": "^1.59.0",
45
+ "@openilink/openilink-sdk-node": "^0.6.0",
46
+ "ai": "^6.0.184",
47
+ "nodemailer": "^8.0.7",
48
+ "qrcode-terminal": "^0.12.0",
49
+ "sharp": "^0.34.5",
50
+ "tsx": "^4.0.0",
51
+ "ws": "^8.18.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.0.0",
55
+ "@types/qrcode-terminal": "^0.12.2",
56
+ "@types/ws": "^8.18.1",
57
+ "typescript": "^5.0.0",
58
+ "vitest": "^3.2.4"
59
+ },
60
+ "engines": {
61
+ "node": ">=20"
62
+ }
63
+ }
@@ -130,6 +130,8 @@ export interface DisplayCardState {
130
130
  cardCreatedAt: number;
131
131
  lastSentContent: string;
132
132
  streamErrorNotified: boolean;
133
+ /** 所属 turn */
134
+ turnCount: number;
133
135
  /** 卡片轮转时记录的基线,轮转后只展示增量 */
134
136
  rotationAccLen?: number;
135
137
  rotationFinalReply?: string;
@@ -137,8 +139,6 @@ export interface DisplayCardState {
137
139
  lastSentAccLen?: number;
138
140
  /** WeChat delta: 上次发送时的 finalReply */
139
141
  lastSentFinalReply?: string;
140
- /** 上次 loop 读取到的 turnCount,用于检测轮次切换 */
141
- lastTurnCount?: number;
142
142
  }
143
143
 
144
144
  export const displayCards = new Map<string, DisplayCardState>();
package/src/session.ts CHANGED
@@ -38,6 +38,7 @@ function compressWechatDisplayText(text: string): string {
38
38
  return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
39
39
  }
40
40
  import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
41
+ import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.ts";
41
42
  import {
42
43
  bindChatToSession,
43
44
  unbindChatFromSession,
@@ -791,10 +792,77 @@ export async function runAgentSession(
791
792
  running: true,
792
793
  });
793
794
 
795
+ // 在覆盖 stream state 前,先终结上一轮的展示卡片并发送最终回复。
796
+ // 竞态根因:上一轮 finally 写入 "done" 状态后,200ms setTimeout 即启动
797
+ // 新一轮 runAgentSession,立即覆盖为 "running" 状态并 kill 旧 display
798
+ // loop。旧 loop 的 3s 间隔 tick 只有约 6.7% 概率在 200ms 窗口内命中,
799
+ // 导致 finalReply 丢失、完成卡片空白。此处主动读取上一轮终端状态完成
800
+ // 卡片终结和回复发送,不依赖 display loop 时序,保证"先发完上一个回答
801
+ // 再开始缓存问题对应的任务"。
802
+ const prevState = await readStreamState(sessionId);
803
+ if (prevState && prevState.status !== "running") {
804
+ const displayChatId = pickDisplayChat(sessionId);
805
+ if (displayChatId) {
806
+ const pp = platformForChat(displayChatId);
807
+ const display = displayCards.get(displayChatId);
808
+ const oldLoopExisted = displayLoops.has(sessionId);
809
+
810
+ if (display && pp) {
811
+ // 旧 display loop 被 kill 前来不及终结卡片 → 现在终结
812
+ while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
813
+ const nextSeq = display.sequence + 1;
814
+ const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
815
+ const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
816
+ const cardContent = prevState.accumulatedContent || " ";
817
+ const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
818
+ await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
819
+ displayCards.delete(displayChatId);
820
+
821
+ // 持久化:标记上一轮所有卡片为终态
822
+ const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
823
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
824
+
825
+ if (prevState.finalReply) {
826
+ await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
827
+ }
828
+ pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
829
+ } else if (oldLoopExisted && pp && prevState.finalReply) {
830
+ // display loop 存在但尚未创建卡片(极快轮次),至少发送 finalReply
831
+ await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
832
+ }
833
+ // else: display loop 已自行终结(displayCards 无记录)→ 无需处理
834
+ }
835
+ }
836
+
794
837
  // 初始化 stream-state.json
795
838
  const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
796
839
  await writeStreamState(initialState);
797
840
 
841
+ // 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
842
+ // 放在 ensureDisplayLoop 之前,确保卡片立即可见(不等 3s 首次 tick)。
843
+ const displayChatIdForNew = pickDisplayChat(sessionId);
844
+ if (displayChatIdForNew) {
845
+ const ppNew = platformForChat(displayChatIdForNew);
846
+ if (ppNew && ppNew.kind !== "wechat") {
847
+ const cardId = await ppNew.cardCreate(
848
+ buildProgressCard("", { showStop: true, headerTitle: "生成中..." }),
849
+ ).catch(() => null);
850
+ if (cardId) {
851
+ await ppNew.cardSend(displayChatIdForNew, cardId).catch(() => null);
852
+ displayCards.set(displayChatIdForNew, {
853
+ cardId,
854
+ sequence: 1,
855
+ cardBusy: false,
856
+ cardCreatedAt: Date.now(),
857
+ lastSentContent: "",
858
+ streamErrorNotified: false,
859
+ turnCount: nextTurnCount,
860
+ });
861
+ addCardToTurn(sessionId, nextTurnCount, cardId).catch(() => {});
862
+ }
863
+ }
864
+ }
865
+
798
866
  // 启动 display loop
799
867
  ensureDisplayLoop(sessionId);
800
868
 
@@ -1040,6 +1108,8 @@ export function ensureDisplayLoop(sessionId: string): void {
1040
1108
  const cardContent = state.accumulatedContent || " ";
1041
1109
  const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1042
1110
  await p.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
1111
+ const finalSt = state.status === "stopped" ? "stopped" : "done";
1112
+ finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
1043
1113
  displayCards.delete(chatId);
1044
1114
  }
1045
1115
  if (state.finalReply) {
@@ -1106,6 +1176,7 @@ export function ensureDisplayLoop(sessionId: string): void {
1106
1176
  } else {
1107
1177
  // 非 WeChat: 卡片流程
1108
1178
  if (!display) {
1179
+ // 兜底:runAgentSession 应已创建卡片,若没有则补建
1109
1180
  const cardId = await p.cardCreate(buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
1110
1181
  if (cardId) {
1111
1182
  await p.cardSend(chatId, cardId).catch(() => null);
@@ -1116,57 +1187,38 @@ export function ensureDisplayLoop(sessionId: string): void {
1116
1187
  cardCreatedAt: Date.now(),
1117
1188
  lastSentContent: "",
1118
1189
  streamErrorNotified: false,
1119
- lastTurnCount: state.turnCount,
1190
+ turnCount: state.turnCount,
1120
1191
  });
1192
+ addCardToTurn(sessionId, state.turnCount, cardId).catch(() => {});
1121
1193
  }
1122
1194
  } else {
1123
1195
  if (display.cardBusy) return;
1124
1196
 
1125
- // 检测轮次切换:turnCount 变化说明上一轮已完成、本 tick 是新轮,
1126
- // 但上一轮的 display 卡片因 setImmediate/setTimeout 时序问题
1127
- // 还没被终结分支清除。此处主动终结旧卡、创建新卡。
1128
- if (display.lastTurnCount !== undefined && display.lastTurnCount !== state.turnCount) {
1129
- display.cardBusy = true;
1130
- try {
1131
- const doneSeq = display.sequence + 1;
1132
- const doneCard = buildProgressCard("", { showStop: false, headerTitle: "完成" });
1133
- await p.cardUpdate(display.cardId, doneCard, doneSeq).catch(() => {});
1134
- displayCards.delete(chatId);
1135
- } catch (_) { /* ignore */ }
1136
- display.cardBusy = false;
1137
- // 新建卡片
1138
- const cardId = await p.cardCreate(buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
1139
- if (cardId) {
1140
- await p.cardSend(chatId, cardId).catch(() => null);
1141
- displayCards.set(chatId, {
1142
- cardId,
1143
- sequence: 1,
1144
- cardBusy: false,
1145
- cardCreatedAt: Date.now(),
1146
- lastSentContent: "",
1147
- streamErrorNotified: false,
1148
- lastTurnCount: state.turnCount,
1149
- });
1150
- }
1197
+ // 守卫:display 归属的 turn 与当前 stream state 不一致。
1198
+ // 正常流程 runAgentSession 会终结旧 turn 并建新卡,
1199
+ // 此处仅兜底处理异常情况(如进程重启后 display 残留)。
1200
+ if (display.turnCount !== state.turnCount) {
1201
+ console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
1202
+ displayCards.delete(chatId);
1151
1203
  return;
1152
1204
  }
1153
1205
 
1154
- // 更新当前 turn 的 lastTurnCount
1155
- display.lastTurnCount = state.turnCount;
1156
-
1157
1206
  // 卡片轮转
1158
1207
  if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
1159
1208
  display.cardBusy = true;
1160
1209
  try {
1161
1210
  const oldSeqBase = display.sequence;
1162
- const oldCard = buildProgressCard("", { showStop: false, headerTitle: "完成" });
1211
+ const oldContent = state.accumulatedContent + state.finalReply;
1212
+ const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "完成" });
1163
1213
  await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
1214
+ markCardDone(sessionId, display.turnCount, display.cardId).catch(() => {});
1164
1215
  const newCardId = await p.cardCreate(buildProgressCard(
1165
1216
  "",
1166
1217
  { showStop: true, headerTitle: "生成中..." },
1167
1218
  ));
1168
1219
  if (newCardId) {
1169
1220
  await p.cardSend(chatId, newCardId);
1221
+ addCardToTurn(sessionId, display.turnCount, newCardId).catch(() => {});
1170
1222
  display.cardId = newCardId;
1171
1223
  display.sequence = 1;
1172
1224
  display.cardCreatedAt = Date.now();
@@ -0,0 +1,118 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import { USER_DATA_DIR, ts } from "./config.ts";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // turn-cards.json — 每个 session 的卡片归属持久化
8
+ // 记录每个 turn 有哪些卡片(轮转会产生多张),以及每张卡片的状态
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export const TURNS_DIR = join(USER_DATA_DIR, "state", "turns");
12
+
13
+ export interface TurnCardEntry {
14
+ cardId: string;
15
+ status: "active" | "done" | "stopped";
16
+ createdAt: number;
17
+ }
18
+
19
+ export interface TurnCardsFile {
20
+ sessionId: string;
21
+ turns: { turnCount: number; cards: TurnCardEntry[] }[];
22
+ }
23
+
24
+ function getTurnCardsPath(sessionId: string): string {
25
+ return join(TURNS_DIR, `${sessionId}.json`);
26
+ }
27
+
28
+ export function createEmptyTurnCards(sessionId: string): TurnCardsFile {
29
+ return { sessionId, turns: [] };
30
+ }
31
+
32
+ export async function readTurnCards(sessionId: string): Promise<TurnCardsFile | null> {
33
+ try {
34
+ const raw = await readFile(getTurnCardsPath(sessionId), "utf-8");
35
+ const parsed = JSON.parse(raw) as TurnCardsFile;
36
+ if (parsed && typeof parsed.sessionId === "string" && Array.isArray(parsed.turns)) {
37
+ return parsed;
38
+ }
39
+ return null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export async function writeTurnCards(sessionId: string, data: TurnCardsFile): Promise<void> {
46
+ try {
47
+ const filePath = getTurnCardsPath(sessionId);
48
+ await mkdir(dirname(filePath), { recursive: true });
49
+ await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
50
+ } catch (err) {
51
+ console.error(`[${ts()}] Failed to write turn-cards for ${sessionId}: ${(err as Error).message}`);
52
+ }
53
+ }
54
+
55
+ /** 向指定 turn 追加一张卡片并持久化。若 turn 不存在则创建。 */
56
+ export async function addCardToTurn(
57
+ sessionId: string,
58
+ turnCount: number,
59
+ cardId: string,
60
+ ): Promise<void> {
61
+ const data = (await readTurnCards(sessionId)) ?? createEmptyTurnCards(sessionId);
62
+ let turn = data.turns.find(t => t.turnCount === turnCount);
63
+ if (!turn) {
64
+ turn = { turnCount, cards: [] };
65
+ data.turns.push(turn);
66
+ }
67
+ turn.cards.push({ cardId, status: "active", createdAt: Date.now() });
68
+ await writeTurnCards(sessionId, data);
69
+ }
70
+
71
+ /** 将指定 turn 的所有 active 卡片标记为终态并持久化。返回被标记的 cardId 列表。 */
72
+ export async function finalizeTurnCards(
73
+ sessionId: string,
74
+ turnCount: number,
75
+ finalStatus: "done" | "stopped",
76
+ ): Promise<string[]> {
77
+ const data = await readTurnCards(sessionId);
78
+ if (!data) return [];
79
+ const turn = data.turns.find(t => t.turnCount === turnCount);
80
+ if (!turn) return [];
81
+ const finalized: string[] = [];
82
+ for (const card of turn.cards) {
83
+ if (card.status === "active") {
84
+ card.status = finalStatus;
85
+ finalized.push(card.cardId);
86
+ }
87
+ }
88
+ if (finalized.length > 0) {
89
+ await writeTurnCards(sessionId, data);
90
+ }
91
+ return finalized;
92
+ }
93
+
94
+ /** 将指定 turn 中的一张 active 卡片标记为 done(轮转时用),并持久化。 */
95
+ export async function markCardDone(
96
+ sessionId: string,
97
+ turnCount: number,
98
+ cardId: string,
99
+ ): Promise<void> {
100
+ const data = await readTurnCards(sessionId);
101
+ if (!data) return;
102
+ const turn = data.turns.find(t => t.turnCount === turnCount);
103
+ if (!turn) return;
104
+ const card = turn.cards.find(c => c.cardId === cardId && c.status === "active");
105
+ if (!card) return;
106
+ card.status = "done";
107
+ await writeTurnCards(sessionId, data);
108
+ }
109
+
110
+ /** 清理 session 的 turn-cards 文件 */
111
+ export async function removeTurnCards(sessionId: string): Promise<void> {
112
+ try {
113
+ const { unlink } = await import("node:fs/promises");
114
+ await unlink(getTurnCardsPath(sessionId));
115
+ } catch {
116
+ // 文件不存在,忽略
117
+ }
118
+ }