chatccc 0.2.216 → 0.2.217

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,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.216",
3
+ "version": "0.2.217",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -2,7 +2,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import sharp from "sharp";
6
7
 
7
8
  const mockConfig = {
8
9
  cursor: {
@@ -68,14 +69,18 @@ function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void
68
69
  }));
69
70
  }
70
71
 
71
- function mockAvatarUploadOnlyFetch(uploadedNames: string[]): ReturnType<typeof vi.fn> {
72
- const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
73
- const urlText = String(url);
74
- if (urlText === "https://open.feishu.test/im/v1/images") {
75
- const form = init?.body as FormData;
76
- const image = form.get("image") as File;
77
- uploadedNames.push(image.name);
78
- return new Response(JSON.stringify({ code: 0, data: { image_key: "img_test" } }), { status: 200 });
72
+ function mockAvatarUploadOnlyFetch(
73
+ uploadedNames: string[],
74
+ uploadedImages: Buffer[] = [],
75
+ ): ReturnType<typeof vi.fn> {
76
+ const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
77
+ const urlText = String(url);
78
+ if (urlText === "https://open.feishu.test/im/v1/images") {
79
+ const form = init?.body as FormData;
80
+ const image = form.get("image") as File;
81
+ uploadedNames.push(image.name);
82
+ uploadedImages.push(Buffer.from(await image.arrayBuffer()));
83
+ return new Response(JSON.stringify({ code: 0, data: { image_key: "img_test" } }), { status: 200 });
79
84
  }
80
85
  if (urlText === "https://open.feishu.test/im/v1/chats/chat_1") {
81
86
  return new Response(JSON.stringify({ code: 0 }), { status: 200 });
@@ -197,7 +202,7 @@ describe("Codex avatar usage battery", () => {
197
202
  }
198
203
  });
199
204
 
200
- it("uses provided Codex usage without fetching usage again", async () => {
205
+ it("uses provided Codex usage without fetching usage again", async () => {
201
206
  const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
202
207
  const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
203
208
  const uploadedNames: string[] = [];
@@ -220,7 +225,65 @@ describe("Codex avatar usage battery", () => {
220
225
  await rm(homeDir, { recursive: true, force: true });
221
226
  await rm(userDataDir, { recursive: true, force: true });
222
227
  }
223
- });
228
+ });
229
+
230
+ it("isolates Fast and standard Codex avatars in the upload cache", async () => {
231
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
232
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
233
+ const uploadedNames: string[] = [];
234
+ mockAvatarUploadOnlyFetch(uploadedNames);
235
+
236
+ try {
237
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
238
+ await setChatAvatar("tenant-token", "chat_1", "codex", "idle", {
239
+ codexUsage: null,
240
+ fastMode: false,
241
+ });
242
+ await setChatAvatar("tenant-token", "chat_1", "codex", "idle", {
243
+ codexUsage: null,
244
+ fastMode: true,
245
+ });
246
+
247
+ expect(uploadedNames).toEqual([
248
+ "avatar_codex_idle.jpg",
249
+ "avatar_codex_idle_fast.jpg",
250
+ ]);
251
+ const cacheRaw = await readFile(join(userDataDir, "state", "avatar-image-keys.json"), "utf-8");
252
+ const cache = JSON.parse(cacheRaw) as Record<string, string>;
253
+ expect(cache["codex:idle:plain"]).toBe("img_test");
254
+ expect(cache["codex:idle:plain:fast-champagne-frame-v1"]).toBe("img_test");
255
+ } finally {
256
+ await rm(homeDir, { recursive: true, force: true });
257
+ await rm(userDataDir, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ it("renders a thick champagne frame above the Codex badge in Fast mode", async () => {
262
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
263
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
264
+ const uploadedNames: string[] = [];
265
+ const uploadedImages: Buffer[] = [];
266
+ mockAvatarUploadOnlyFetch(uploadedNames, uploadedImages);
267
+
268
+ try {
269
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
270
+ await setChatAvatar("tenant-token", "chat_1", "codex", "idle", {
271
+ codexUsage: null,
272
+ fastMode: true,
273
+ });
274
+
275
+ expect(uploadedNames).toEqual(["avatar_codex_idle_fast.jpg"]);
276
+ const { data, info } = await sharp(uploadedImages[0]).raw().toBuffer({ resolveWithObject: true });
277
+ const pixelOffset = (149 * info.width + 200) * info.channels;
278
+ const [red, green, blue] = data.subarray(pixelOffset, pixelOffset + 3);
279
+ expect(red).toBeGreaterThan(220);
280
+ expect(green).toBeGreaterThan(150);
281
+ expect(blue).toBeLessThan(180);
282
+ } finally {
283
+ await rm(homeDir, { recursive: true, force: true });
284
+ await rm(userDataDir, { recursive: true, force: true });
285
+ }
286
+ });
224
287
 
225
288
  it("returns both usage windows and available reset credit expiries", async () => {
226
289
  const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
@@ -681,9 +681,21 @@ describe("handleCommand WeChat processing ack", () => {
681
681
 
682
682
  await handleCommand(platform, "/fast on", "feishu-codex", "ou-user", Date.now(), "p2p");
683
683
  expect(getEffectiveFastModeForTool("codex", "sid-codex-fast")).toBe(true);
684
+ expect(platform.setChatAvatar).toHaveBeenLastCalledWith(
685
+ "feishu-codex",
686
+ "codex",
687
+ "idle",
688
+ { fastMode: true },
689
+ );
684
690
 
685
691
  await handleCommand(platform, "/fast off", "feishu-codex", "ou-user", Date.now(), "p2p");
686
692
  expect(getEffectiveFastModeForTool("codex", "sid-codex-fast")).toBe(false);
693
+ expect(platform.setChatAvatar).toHaveBeenLastCalledWith(
694
+ "feishu-codex",
695
+ "codex",
696
+ "idle",
697
+ { fastMode: false },
698
+ );
687
699
  card = JSON.parse(
688
700
  vi.mocked(platform.sendRawCard).mock.calls.at(-1)?.[1] ?? "{}",
689
701
  ) as { elements?: Array<{ tag: string; text?: { content: string } }> };
@@ -3,7 +3,13 @@ import { resolve } from "node:path";
3
3
  import { sessionPrefixForTool, toolDisplayName, ts } from "./config.ts";
4
4
  import { setDefaultCwd } from "./config.ts";
5
5
  import type { PlatformAdapter } from "./platform-adapter.ts";
6
- import { initClaudeSession, recordSessionRegistry, resumeAndPrompt, saveSessionTool } from "./session.ts";
6
+ import {
7
+ getEffectiveFastModeForTool,
8
+ initClaudeSession,
9
+ recordSessionRegistry,
10
+ resumeAndPrompt,
11
+ saveSessionTool,
12
+ } from "./session.ts";
7
13
  import { bindChatToSession } from "./session-chat-binding.ts";
8
14
  import { sessionChatName } from "./session-name.ts";
9
15
 
@@ -64,7 +70,11 @@ export async function delegateAgentTask(input: DelegateAgentTaskInput): Promise<
64
70
  `下面会自动把任务作为第一句话发送给 ${toolLabel}。`,
65
71
  "green",
66
72
  ).catch(() => {});
67
- input.platform.setChatAvatar(chatId, input.tool, "new").catch(() => {});
73
+ const fastMode = getEffectiveFastModeForTool(input.tool, sessionId);
74
+ const avatarUpdate = fastMode
75
+ ? input.platform.setChatAvatar(chatId, input.tool, "new", { fastMode: true })
76
+ : input.platform.setChatAvatar(chatId, input.tool, "new");
77
+ avatarUpdate.catch(() => {});
68
78
 
69
79
  await resumeAndPrompt(
70
80
  sessionId,
package/src/feishu-api.ts CHANGED
@@ -324,6 +324,7 @@ const AVATAR_BADGE_SIZE = 92;
324
324
  const AVATAR_BADGE_MARGIN = 10;
325
325
  const PLAIN_AVATAR_TOOL = "plain";
326
326
  const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-window-aware-v14";
327
+ const CODEX_FAST_FRAME_STYLE_VERSION = "fast-champagne-frame-v1";
327
328
  const CURSOR_AVATAR_USAGE_STYLE_VERSION = "usage-battery-v1";
328
329
 
329
330
  export interface CodexUsageBalance {
@@ -371,18 +372,20 @@ function avatarCombinationPath(tool: string, status: string): string {
371
372
  return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
372
373
  }
373
374
 
374
- function avatarCacheKey(
375
- tool: string,
376
- status: string,
377
- codexUsage: CodexUsageSummary | null = null,
378
- cursorBatteryPercent: number | null = null,
379
- ): string {
375
+ function avatarCacheKey(
376
+ tool: string,
377
+ status: string,
378
+ codexUsage: CodexUsageSummary | null = null,
379
+ cursorBatteryPercent: number | null = null,
380
+ fastMode = false,
381
+ ): string {
380
382
  const normalizedTool = normalizeAvatarTool(tool);
381
383
  const normalizedStatus = normalizeAvatarStatus(status);
382
384
  if (normalizedTool === "codex") {
383
- if (!codexUsage?.weekly) return `${normalizedTool}:${normalizedStatus}:plain`;
385
+ const fastKey = fastMode ? `:${CODEX_FAST_FRAME_STYLE_VERSION}` : "";
386
+ if (!codexUsage?.weekly) return `${normalizedTool}:${normalizedStatus}:plain${fastKey}`;
384
387
  const ringKey = codexUsage.fiveHour ? `:5h-ring:${codexUsage.fiveHour.remainingPercent}` : "";
385
- return `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:7d-battery:${codexUsage.weekly.remainingPercent}${ringKey}`;
388
+ return `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:7d-battery:${codexUsage.weekly.remainingPercent}${ringKey}${fastKey}`;
386
389
  }
387
390
  if (normalizedTool === "cursor") {
388
391
  return cursorBatteryPercent !== null
@@ -757,7 +760,7 @@ function buildCodexUsageRingSvg(remainingPercent: number): Buffer {
757
760
  </svg>`);
758
761
  }
759
762
 
760
- async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
763
+ async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
761
764
  const badge = await sharp(AVATAR_BADGES[tool])
762
765
  .resize(AVATAR_BADGE_SIZE, AVATAR_BADGE_SIZE, {
763
766
  fit: "contain",
@@ -770,24 +773,50 @@ async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOption
770
773
  input: badge,
771
774
  left: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
772
775
  top: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
773
- };
774
- }
775
-
776
- async function renderAvatar(
777
- tool: string,
778
- status: string,
779
- codexUsage: CodexUsageSummary | null = null,
780
- cursorBatteryPercent: number | null = null,
781
- ): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
776
+ };
777
+ }
778
+
779
+ function buildCodexFastFrameOverlay(): sharp.OverlayOptions {
780
+ const size = AVATAR_BADGE_SIZE + 16;
781
+ const innerOffset = 7;
782
+ const innerSize = size - innerOffset * 2;
783
+ const frame = Buffer.from(`
784
+ <svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
785
+ <defs>
786
+ <linearGradient id="champagne" x1="0" y1="0" x2="${size}" y2="${size}" gradientUnits="userSpaceOnUse">
787
+ <stop offset="0" stop-color="#FFF0BE"/>
788
+ <stop offset="0.55" stop-color="#FFD56A"/>
789
+ <stop offset="1" stop-color="#EFA923"/>
790
+ </linearGradient>
791
+ </defs>
792
+ <rect x="0.5" y="0.5" width="${size - 1}" height="${size - 1}" rx="25" fill="url(#champagne)"/>
793
+ <rect x="${innerOffset}" y="${innerOffset}" width="${innerSize}" height="${innerSize}" rx="21" fill="#fffdf4"/>
794
+ </svg>`);
795
+ return {
796
+ input: frame,
797
+ left: AVATAR_SIZE - size - 2,
798
+ top: AVATAR_SIZE - size - 2,
799
+ };
800
+ }
801
+
802
+ async function renderAvatar(
803
+ tool: string,
804
+ status: string,
805
+ codexUsage: CodexUsageSummary | null = null,
806
+ cursorBatteryPercent: number | null = null,
807
+ fastMode = false,
808
+ ): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
782
809
  const normalizedTool = normalizeAvatarTool(tool);
783
810
  const normalizedStatus = normalizeAvatarStatus(status);
784
811
  const composites: sharp.OverlayOptions[] = [];
785
812
  const hasAgentBadge = normalizedTool !== PLAIN_AVATAR_TOOL;
813
+ const useFastCodexAvatar = normalizedTool === "codex" && fastMode;
786
814
 
787
815
  const codexWeeklyUsage = normalizedTool === "codex" ? codexUsage?.weekly ?? null : null;
788
816
  const useDynamicCodexAvatar = normalizedTool === "codex" && codexUsage !== null && codexWeeklyUsage !== null;
789
817
  const useDynamicCursorAvatar = normalizedTool === "cursor" && cursorBatteryPercent !== null;
790
- const basePath = useDynamicCodexAvatar || useDynamicCursorAvatar
818
+ const useDynamicBadgeAvatar = useDynamicCodexAvatar || useDynamicCursorAvatar || useFastCodexAvatar;
819
+ const basePath = useDynamicBadgeAvatar
791
820
  ? AVATAR_SOURCES[normalizedStatus]
792
821
  : hasAgentBadge
793
822
  ? avatarCombinationPath(normalizedTool, normalizedStatus)
@@ -797,16 +826,15 @@ async function renderAvatar(
797
826
  if (codexUsage.fiveHour) {
798
827
  composites.push({ input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 });
799
828
  }
800
- composites.push(
801
- { input: buildCodexUsageBatterySvg(codexWeeklyUsage.remainingPercent), left: 0, top: 0 },
802
- await buildAgentBadgeOverlay(normalizedTool),
803
- );
804
- } else if (useDynamicCursorAvatar) {
805
- composites.push(
806
- { input: buildCodexUsageBatterySvg(cursorBatteryPercent), left: 0, top: 0 },
807
- await buildAgentBadgeOverlay(normalizedTool),
808
- );
809
- }
829
+ composites.push({ input: buildCodexUsageBatterySvg(codexWeeklyUsage.remainingPercent), left: 0, top: 0 });
830
+ } else if (useDynamicCursorAvatar) {
831
+ composites.push({ input: buildCodexUsageBatterySvg(cursorBatteryPercent), left: 0, top: 0 });
832
+ }
833
+
834
+ if (useDynamicBadgeAvatar) {
835
+ if (useFastCodexAvatar) composites.push(buildCodexFastFrameOverlay());
836
+ composites.push(await buildAgentBadgeOverlay(normalizedTool));
837
+ }
810
838
 
811
839
  let pipeline = sharp(await readFile(basePath))
812
840
  .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
@@ -824,21 +852,22 @@ async function renderAvatar(
824
852
  buffer: jpeg,
825
853
  contentType: "image/jpeg",
826
854
  filename: normalizedTool === "codex" && codexUsage?.weekly
827
- ? `avatar_${normalizedTool}_${normalizedStatus}_7d_${codexUsage.weekly.remainingPercent}${codexUsage.fiveHour ? `_5h_${codexUsage.fiveHour.remainingPercent}` : ""}.jpg`
855
+ ? `avatar_${normalizedTool}_${normalizedStatus}${useFastCodexAvatar ? "_fast" : ""}_7d_${codexUsage.weekly.remainingPercent}${codexUsage.fiveHour ? `_5h_${codexUsage.fiveHour.remainingPercent}` : ""}.jpg`
828
856
  : normalizedTool === "cursor" && cursorBatteryPercent !== null
829
857
  ? `avatar_${normalizedTool}_${normalizedStatus}_battery_${cursorBatteryPercent}.jpg`
830
- : `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
858
+ : `avatar_${normalizedTool}_${normalizedStatus}${useFastCodexAvatar ? "_fast" : ""}.jpg`,
831
859
  };
832
860
  }
833
861
 
834
- async function uploadImage(
835
- token: string,
836
- tool: string,
837
- status: string,
838
- codexUsage: CodexUsageSummary | null = null,
839
- cursorBatteryPercent: number | null = null,
840
- ): Promise<string> {
841
- const image = await renderAvatar(tool, status, codexUsage, cursorBatteryPercent);
862
+ async function uploadImage(
863
+ token: string,
864
+ tool: string,
865
+ status: string,
866
+ codexUsage: CodexUsageSummary | null = null,
867
+ cursorBatteryPercent: number | null = null,
868
+ fastMode = false,
869
+ ): Promise<string> {
870
+ const image = await renderAvatar(tool, status, codexUsage, cursorBatteryPercent, fastMode);
842
871
  const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
843
872
  const form = new FormData();
844
873
  form.append("image_type", "avatar");
@@ -869,13 +898,14 @@ async function getOrUploadAvatarKey(
869
898
  const normalizedTool = normalizeAvatarTool(tool);
870
899
  const normalizedStatus = normalizeAvatarStatus(status);
871
900
  const codexUsage = normalizedTool === "codex" ? await resolveCodexAvatarUsage(usageHints.codexUsage) : null;
872
- const cursorBatteryPercent = normalizedTool === "cursor"
873
- ? await resolveCursorAvatarBatteryPercent(usageHints.cursorUsage)
874
- : null;
875
- const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent);
876
- const cached = avatarKeyCache.get(keyName);
877
- if (cached) return cached;
878
- const key = await uploadImage(token, normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent);
901
+ const cursorBatteryPercent = normalizedTool === "cursor"
902
+ ? await resolveCursorAvatarBatteryPercent(usageHints.cursorUsage)
903
+ : null;
904
+ const fastMode = normalizedTool === "codex" && usageHints.fastMode === true;
905
+ const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent, fastMode);
906
+ const cached = avatarKeyCache.get(keyName);
907
+ if (cached) return cached;
908
+ const key = await uploadImage(token, normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent, fastMode);
879
909
  avatarKeyCache.set(keyName, key);
880
910
  await persistAvatarKeyCache().catch((err) => {
881
911
  console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
@@ -340,6 +340,22 @@ function fastHelpAfterModel(tool: string): string {
340
340
  : "";
341
341
  }
342
342
 
343
+ function setChatAvatarForSession(
344
+ platform: PlatformAdapter,
345
+ chatId: string,
346
+ tool: string,
347
+ status: string,
348
+ sessionId?: string,
349
+ usageHints?: ChatAvatarUsageHints,
350
+ ): Promise<void> {
351
+ const fastMode = getEffectiveFastModeForTool(tool, sessionId);
352
+ if (!usageHints && !fastMode) return platform.setChatAvatar(chatId, tool, status);
353
+ return platform.setChatAvatar(chatId, tool, status, {
354
+ ...usageHints,
355
+ ...(fastMode ? { fastMode: true } : {}),
356
+ });
357
+ }
358
+
343
359
  async function sendFastModeStatus(
344
360
  platform: PlatformAdapter,
345
361
  chatId: string,
@@ -369,24 +385,26 @@ async function resolveUsageTarget(chatId: string): Promise<{ tool: "codex" | "cu
369
385
  }
370
386
  }
371
387
 
372
- function refreshUsageAvatar(
373
- platform: PlatformAdapter,
374
- chatId: string,
375
- tool: "codex" | "cursor",
376
- status: "busy" | "idle",
377
- usageHints: ChatAvatarUsageHints,
378
- ): void {
379
- platform.setChatAvatar(chatId, tool, status, usageHints).catch((err) => {
388
+ function refreshUsageAvatar(
389
+ platform: PlatformAdapter,
390
+ chatId: string,
391
+ tool: "codex" | "cursor",
392
+ status: "busy" | "idle",
393
+ usageHints: ChatAvatarUsageHints,
394
+ sessionId?: string,
395
+ ): void {
396
+ setChatAvatarForSession(platform, chatId, tool, status, sessionId, usageHints).catch((err) => {
380
397
  console.warn(`[${ts()}] [AVATAR] usage refresh failed: chatId=${chatId} tool=${tool} ${(err as Error).message}`);
381
398
  });
382
399
  }
383
400
 
384
- async function sendUsageSummary(
385
- platform: PlatformAdapter,
386
- chatId: string,
387
- tool: "codex" | "cursor",
388
- avatarStatus: "busy" | "idle" = "idle",
389
- ): Promise<void> {
401
+ async function sendUsageSummary(
402
+ platform: PlatformAdapter,
403
+ chatId: string,
404
+ tool: "codex" | "cursor",
405
+ avatarStatus: "busy" | "idle" = "idle",
406
+ sessionId?: string,
407
+ ): Promise<void> {
390
408
  if (tool === "cursor") {
391
409
  const usage = await getCursorUsageSummary();
392
410
  const content = formatCursorUsageSummary(usage);
@@ -395,7 +413,7 @@ async function sendUsageSummary(
395
413
  } else {
396
414
  await platform.sendCard(chatId, "Cursor Usage", content, "blue");
397
415
  }
398
- refreshUsageAvatar(platform, chatId, tool, avatarStatus, { cursorUsage: usage });
416
+ refreshUsageAvatar(platform, chatId, tool, avatarStatus, { cursorUsage: usage }, sessionId);
399
417
  return;
400
418
  }
401
419
 
@@ -411,7 +429,7 @@ async function sendUsageSummary(
411
429
  } else {
412
430
  await platform.sendCard(chatId, "Codex Usage", content, "blue");
413
431
  }
414
- refreshUsageAvatar(platform, chatId, tool, avatarStatus, { codexUsage: usage });
432
+ refreshUsageAvatar(platform, chatId, tool, avatarStatus, { codexUsage: usage }, sessionId);
415
433
  }
416
434
 
417
435
  async function sendUsageError(platform: PlatformAdapter, chatId: string, tool: "codex" | "cursor", err: unknown): Promise<void> {
@@ -585,7 +603,7 @@ async function resolveFeishuP2pAgent(
585
603
  `检测到默认 Agent 已变化:**${previousLabel} → ${desiredLabel}**。\n\n已创建新的空白 ${desiredLabel} 私聊会话,并从本条消息开始使用。`,
586
604
  "green",
587
605
  ).catch(() => {});
588
- platform.setChatAvatar(chatId, desiredTool, "new").catch(() => {});
606
+ setChatAvatarForSession(platform, chatId, desiredTool, "new", init.sessionId).catch(() => {});
589
607
  return { kind: "ready", sessionId: init.sessionId, tool: desiredTool };
590
608
  } catch (err) {
591
609
  return {
@@ -817,7 +835,7 @@ export async function handleCommand(
817
835
  const avatarStatus = usageTarget.sessionId && isSessionRunning(usageTarget.sessionId) ? "busy" : "idle";
818
836
  logTrace(tid, "BRANCH", { cmd: "/usage", tool: usageTool });
819
837
  try {
820
- await sendUsageSummary(platform, chatId, usageTool, avatarStatus);
838
+ await sendUsageSummary(platform, chatId, usageTool, avatarStatus, usageTarget.sessionId);
821
839
  logTrace(tid, "DONE", { outcome: "usage", tool: usageTool });
822
840
  } catch (err) {
823
841
  await sendUsageError(platform, chatId, usageTool, err);
@@ -1149,7 +1167,7 @@ export async function handleCommand(
1149
1167
  sessionId,
1150
1168
  tool,
1151
1169
  });
1152
- platform.setChatAvatar(newChatId, tool, "new").catch(() => {});
1170
+ setChatAvatarForSession(platform, newChatId, tool, "new", sessionId).catch(() => {});
1153
1171
  console.log(`${"=".repeat(60)}`);
1154
1172
  return;
1155
1173
  }
@@ -1544,9 +1562,7 @@ export async function handleCommand(
1544
1562
  );
1545
1563
  }
1546
1564
 
1547
- platform
1548
- .setChatAvatar(chatId, descriptionTool, "new")
1549
- .catch(() => {});
1565
+ setChatAvatarForSession(platform, chatId, descriptionTool, "new", newSessionId).catch(() => {});
1550
1566
 
1551
1567
  await platform.sendCard(
1552
1568
  chatId,
@@ -1716,7 +1732,7 @@ export async function handleCommand(
1716
1732
  );
1717
1733
  }
1718
1734
 
1719
- platform.setChatAvatar(chatId, target.tool, "new").catch(() => {});
1735
+ setChatAvatarForSession(platform, chatId, target.tool, "new", target.sessionId).catch(() => {});
1720
1736
 
1721
1737
  const targetToolLabel = toolDisplayName(target.tool);
1722
1738
  const busyNote = isSessionRunning(target.sessionId)
@@ -1772,6 +1788,12 @@ export async function handleCommand(
1772
1788
  }
1773
1789
  const enabled = getEffectiveFastModeForTool("codex", sessionId);
1774
1790
  await sendFastModeStatus(platform, chatId, enabled).catch(() => {});
1791
+ if (fastArg) {
1792
+ const avatarStatus = isSessionRunning(sessionId) ? "busy" : "idle";
1793
+ await platform.setChatAvatar(chatId, "codex", avatarStatus, { fastMode: enabled }).catch((err) => {
1794
+ console.warn(`[${ts()}] [AVATAR] Fast mode refresh failed: chatId=${chatId} ${(err as Error).message}`);
1795
+ });
1796
+ }
1775
1797
  logTrace(tid, "DONE", {
1776
1798
  outcome: fastArg ? "fast_switched" : "fast_query",
1777
1799
  enabled,
@@ -8,10 +8,11 @@
8
8
  import type { CursorUsageSummary } from "./cursor-usage.ts";
9
9
  import type { CodexUsageSummary } from "./feishu-api.ts";
10
10
 
11
- export interface ChatAvatarUsageHints {
12
- codexUsage?: CodexUsageSummary | null;
13
- cursorUsage?: CursorUsageSummary | null;
14
- }
11
+ export interface ChatAvatarUsageHints {
12
+ codexUsage?: CodexUsageSummary | null;
13
+ cursorUsage?: CursorUsageSummary | null;
14
+ fastMode?: boolean;
15
+ }
15
16
 
16
17
  export interface PlatformAdapter {
17
18
  /** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
@@ -66,4 +67,4 @@ export interface PlatformAdapter {
66
67
 
67
68
  /** 更新已发送的进度展示,sequence 保证有序 */
68
69
  cardUpdate(cardId: string, cardJson: string, sequence: number): Promise<void>;
69
- }
70
+ }
package/src/session.ts CHANGED
@@ -538,6 +538,18 @@ export function getEffectiveFastModeForTool(tool: string, sessionId?: string): b
538
538
  }
539
539
  return config.codex.fastMode;
540
540
  }
541
+
542
+ function setSessionChatAvatar(
543
+ platform: PlatformAdapter,
544
+ chatId: string,
545
+ tool: string,
546
+ status: string,
547
+ sessionId: string,
548
+ ): Promise<void> {
549
+ return getEffectiveFastModeForTool(tool, sessionId)
550
+ ? platform.setChatAvatar(chatId, tool, status, { fastMode: true })
551
+ : platform.setChatAvatar(chatId, tool, status);
552
+ }
541
553
 
542
554
  /** 为指定 session 设置模型覆盖(/model <name>) */
543
555
  export function setSessionModelOverride(sessionId: string, model: string): void {
@@ -1290,7 +1302,7 @@ export async function runAgentSession(
1290
1302
  if (displayCards.get(displayChatId) !== display) {
1291
1303
  const finalStatus = turnFinalStatus(prevState.status);
1292
1304
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
1293
- pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
1305
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1294
1306
  } else {
1295
1307
  const nextSeq = display.sequence + 1;
1296
1308
  const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status);
@@ -1310,7 +1322,7 @@ export async function runAgentSession(
1310
1322
  if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
1311
1323
  await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1312
1324
  }
1313
- pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
1325
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1314
1326
  }
1315
1327
  } else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
1316
1328
  // 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
@@ -1375,7 +1387,7 @@ export async function runAgentSession(
1375
1387
  // 设置最后活跃群头像为 busy
1376
1388
  const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1377
1389
  if (activeCid) {
1378
- platform.setChatAvatar(activeCid, tool, "busy").catch(() => {});
1390
+ setSessionChatAvatar(platform, activeCid, tool, "busy", sessionId).catch(() => {});
1379
1391
  }
1380
1392
 
1381
1393
  const state: AccumulatorState = {
@@ -1665,7 +1677,7 @@ export async function runAgentSession(
1665
1677
  const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1666
1678
  if (active1) {
1667
1679
  await platform.sendText(active1, "会话已停止。").catch(() => {});
1668
- platform.setChatAvatar(active1, tool, "idle").catch(() => {});
1680
+ setSessionChatAvatar(platform, active1, tool, "idle", sessionId).catch(() => {});
1669
1681
  }
1670
1682
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1671
1683
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
@@ -1695,7 +1707,7 @@ export async function runAgentSession(
1695
1707
  formatAutoEndedReply(finalReplyToWrite),
1696
1708
  );
1697
1709
  }
1698
- pp.setChatAvatar(activeAutoEnded, tool, "idle").catch(() => {});
1710
+ setSessionChatAvatar(pp, activeAutoEnded, tool, "idle", sessionId).catch(() => {});
1699
1711
 
1700
1712
  if (wasAutoRecovery) {
1701
1713
  // 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
@@ -1730,7 +1742,7 @@ export async function runAgentSession(
1730
1742
  });
1731
1743
  }
1732
1744
  const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1733
- if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
1745
+ if (activeErr) setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => {});
1734
1746
  console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1735
1747
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1736
1748
  } else {
@@ -1753,7 +1765,7 @@ export async function runAgentSession(
1753
1765
  const pp = platformForChat(active2) ?? platform;
1754
1766
  await sendFinalReplyTextOnce(pp, active2, sessionId, nextTurnCount, finalReply);
1755
1767
  }
1756
- platform.setChatAvatar(active2, tool, "idle").catch(() => {});
1768
+ setSessionChatAvatar(platform, active2, tool, "idle", sessionId).catch(() => {});
1757
1769
  }
1758
1770
  console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
1759
1771
  if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
@@ -1996,7 +2008,7 @@ export function startUnifiedDisplayLoop(): void {
1996
2008
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
1997
2009
  displayCards.delete(chatId);
1998
2010
  }
1999
- p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
2011
+ setSessionChatAvatar(p, chatId, state.tool, "idle", sessionId).catch(() => {});
2000
2012
  console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
2001
2013
  } else {
2002
2014
  // running: 创建或更新展示