chatccc 0.2.175 → 0.2.176

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/src/feishu-api.ts CHANGED
@@ -307,30 +307,30 @@ const AVATAR_SOURCES: Record<string, string> = {
307
307
  busy: resolvePath(AVATAR_DIR, "status_busy.png"),
308
308
  idle: resolvePath(AVATAR_DIR, "status_idle.png"),
309
309
  };
310
- const AVATAR_BADGES: Record<string, string> = {
311
- claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
312
- cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
313
- codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
314
- };
315
- const AVATAR_SIZE = 256;
316
- const AVATAR_BADGE_SIZE = 92;
317
- const AVATAR_BADGE_MARGIN = 10;
318
- const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-v5";
319
-
320
- export interface CodexUsageBalance {
321
- usedPercent: number;
322
- remainingPercent: number;
323
- resetAtEpochSeconds: number | null;
324
- resetAfterSeconds: number | null;
325
- }
326
-
327
- export interface CodexUsageSummary {
328
- fiveHour: CodexUsageBalance;
329
- weekly: CodexUsageBalance | null;
330
- }
331
-
332
- const avatarKeyCache = new Map<string, string>();
333
- let avatarKeyCacheLoaded = false;
310
+ const AVATAR_BADGES: Record<string, string> = {
311
+ claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
312
+ cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
313
+ codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
314
+ };
315
+ const AVATAR_SIZE = 256;
316
+ const AVATAR_BADGE_SIZE = 92;
317
+ const AVATAR_BADGE_MARGIN = 10;
318
+ const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-v5";
319
+
320
+ export interface CodexUsageBalance {
321
+ usedPercent: number;
322
+ remainingPercent: number;
323
+ resetAtEpochSeconds: number | null;
324
+ resetAfterSeconds: number | null;
325
+ }
326
+
327
+ export interface CodexUsageSummary {
328
+ fiveHour: CodexUsageBalance;
329
+ weekly: CodexUsageBalance | null;
330
+ }
331
+
332
+ const avatarKeyCache = new Map<string, string>();
333
+ let avatarKeyCacheLoaded = false;
334
334
 
335
335
  function normalizeAvatarTool(tool: string): string {
336
336
  return AVATAR_BADGES[tool] ? tool : "claude";
@@ -344,16 +344,16 @@ function avatarCombinationPath(tool: string, status: string): string {
344
344
  return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
345
345
  }
346
346
 
347
- function avatarCacheKey(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): string {
348
- const normalizedTool = normalizeAvatarTool(tool);
349
- const normalizedStatus = normalizeAvatarStatus(status);
350
- if (normalizedTool === "codex") {
351
- return codexUsage
352
- ? `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:week-battery:${codexUsage.weekly?.remainingPercent}:5h-ring:${codexUsage.fiveHour.remainingPercent}`
353
- : `${normalizedTool}:${normalizedStatus}:plain`;
354
- }
355
- return `${normalizedTool}:${normalizedStatus}`;
356
- }
347
+ function avatarCacheKey(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): string {
348
+ const normalizedTool = normalizeAvatarTool(tool);
349
+ const normalizedStatus = normalizeAvatarStatus(status);
350
+ if (normalizedTool === "codex") {
351
+ return codexUsage
352
+ ? `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:week-battery:${codexUsage.weekly?.remainingPercent}:5h-ring:${codexUsage.fiveHour.remainingPercent}`
353
+ : `${normalizedTool}:${normalizedStatus}:plain`;
354
+ }
355
+ return `${normalizedTool}:${normalizedStatus}`;
356
+ }
357
357
 
358
358
  async function loadAvatarKeyCache(): Promise<void> {
359
359
  if (avatarKeyCacheLoaded) return;
@@ -378,37 +378,37 @@ async function persistAvatarKeyCache(): Promise<void> {
378
378
  );
379
379
  }
380
380
 
381
- function clampPercent(value: number): number {
382
- if (!Number.isFinite(value)) return 0;
383
- return Math.max(0, Math.min(100, Math.round(value)));
384
- }
385
-
386
- function usageBalanceFromWindow(raw: Record<string, unknown>, fieldName: string): CodexUsageBalance {
387
- const value = raw.used_percent;
388
- const usedPercent = Number(value);
389
- if (!Number.isFinite(usedPercent)) throw new Error(`missing ${fieldName}.used_percent`);
390
- const used = clampPercent(usedPercent);
391
- const resetAt = Number(raw.reset_at);
392
- const resetAfter = Number(raw.reset_after_seconds);
393
- return {
394
- usedPercent: used,
395
- remainingPercent: clampPercent(100 - used),
396
- resetAtEpochSeconds: Number.isFinite(resetAt) ? resetAt : null,
397
- resetAfterSeconds: Number.isFinite(resetAfter) ? resetAfter : null,
398
- };
399
- }
400
-
401
- function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
402
- for (const key of keys) {
403
- const raw = rateLimit[key];
404
- if (!raw || typeof raw !== "object") continue;
405
- if ((raw as { used_percent?: unknown }).used_percent === undefined) continue;
406
- return usageBalanceFromWindow(raw as Record<string, unknown>, `rate_limit.${key}`);
407
- }
408
- return null;
409
- }
410
-
411
- async function getCodexAccessToken(): Promise<string | null> {
381
+ function clampPercent(value: number): number {
382
+ if (!Number.isFinite(value)) return 0;
383
+ return Math.max(0, Math.min(100, Math.round(value)));
384
+ }
385
+
386
+ function usageBalanceFromWindow(raw: Record<string, unknown>, fieldName: string): CodexUsageBalance {
387
+ const value = raw.used_percent;
388
+ const usedPercent = Number(value);
389
+ if (!Number.isFinite(usedPercent)) throw new Error(`missing ${fieldName}.used_percent`);
390
+ const used = clampPercent(usedPercent);
391
+ const resetAt = Number(raw.reset_at);
392
+ const resetAfter = Number(raw.reset_after_seconds);
393
+ return {
394
+ usedPercent: used,
395
+ remainingPercent: clampPercent(100 - used),
396
+ resetAtEpochSeconds: Number.isFinite(resetAt) ? resetAt : null,
397
+ resetAfterSeconds: Number.isFinite(resetAfter) ? resetAfter : null,
398
+ };
399
+ }
400
+
401
+ function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
402
+ for (const key of keys) {
403
+ const raw = rateLimit[key];
404
+ if (!raw || typeof raw !== "object") continue;
405
+ if ((raw as { used_percent?: unknown }).used_percent === undefined) continue;
406
+ return usageBalanceFromWindow(raw as Record<string, unknown>, `rate_limit.${key}`);
407
+ }
408
+ return null;
409
+ }
410
+
411
+ async function getCodexAccessToken(): Promise<string | null> {
412
412
  try {
413
413
  const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
414
414
  const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown } };
@@ -416,49 +416,49 @@ async function getCodexAccessToken(): Promise<string | null> {
416
416
  return typeof token === "string" && token.trim() ? token : null;
417
417
  } catch {
418
418
  return null;
419
- }
420
- }
421
-
422
- export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
423
- const token = await getCodexAccessToken();
424
- if (!token) throw new Error("missing ~/.codex/auth.json access token");
425
-
426
- const resp = await fetch(CODEX_USAGE_URL, {
427
- headers: { Authorization: `Bearer ${token}` },
428
- });
429
- const text = await resp.text();
430
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
431
-
432
- const data = JSON.parse(text) as {
433
- rate_limit?: Record<string, unknown>;
434
- };
435
- const rateLimit = data.rate_limit;
436
- if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
437
-
438
- const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
439
- if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
440
-
441
- return {
442
- fiveHour,
443
- weekly: parseOptionalUsageWindow(rateLimit, [
444
- "secondary_window",
445
- "weekly_window",
446
- "week_window",
447
- "long_window",
448
- ]),
449
- };
450
- }
451
-
452
- async function fetchCodexAvatarUsage(): Promise<CodexUsageSummary | null> {
453
- try {
454
- const summary = await getCodexUsageSummary();
455
- if (!summary.weekly) throw new Error("missing weekly usage window");
456
- return summary;
457
- } catch (err) {
458
- console.warn(`[${ts()}] [AVATAR] Codex usage unavailable, using plain avatar: ${(err as Error).message}`);
459
- return null;
460
- }
461
- }
419
+ }
420
+ }
421
+
422
+ export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
423
+ const token = await getCodexAccessToken();
424
+ if (!token) throw new Error("missing ~/.codex/auth.json access token");
425
+
426
+ const resp = await fetch(CODEX_USAGE_URL, {
427
+ headers: { Authorization: `Bearer ${token}` },
428
+ });
429
+ const text = await resp.text();
430
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
431
+
432
+ const data = JSON.parse(text) as {
433
+ rate_limit?: Record<string, unknown>;
434
+ };
435
+ const rateLimit = data.rate_limit;
436
+ if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
437
+
438
+ const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
439
+ if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
440
+
441
+ return {
442
+ fiveHour,
443
+ weekly: parseOptionalUsageWindow(rateLimit, [
444
+ "secondary_window",
445
+ "weekly_window",
446
+ "week_window",
447
+ "long_window",
448
+ ]),
449
+ };
450
+ }
451
+
452
+ async function fetchCodexAvatarUsage(): Promise<CodexUsageSummary | null> {
453
+ try {
454
+ const summary = await getCodexUsageSummary();
455
+ if (!summary.weekly) throw new Error("missing weekly usage window");
456
+ return summary;
457
+ } catch (err) {
458
+ console.warn(`[${ts()}] [AVATAR] Codex usage unavailable, using plain avatar: ${(err as Error).message}`);
459
+ return null;
460
+ }
461
+ }
462
462
 
463
463
  function codexUsagePalette(remainingPercent: number): { start: string; end: string; glow: string } {
464
464
  if (remainingPercent <= 25) return { start: "#ef4444", end: "#fb923c", glow: "#fed7aa" };
@@ -466,26 +466,26 @@ function codexUsagePalette(remainingPercent: number): { start: string; end: stri
466
466
  return { start: "#16a34a", end: "#34d399", glow: "#bbf7d0" };
467
467
  }
468
468
 
469
- function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
470
- const remaining = clampPercent(remainingPercent);
471
- const label = String(remaining);
472
- const palette = codexUsagePalette(remaining);
473
-
474
- const bodyX = 28;
475
- const bodyY = 38;
476
- const bodyW = 109;
477
- const bodyH = 56;
478
- const capW = 12;
479
- const capH = 22;
480
- const capX = bodyX + bodyW;
481
- const capY = bodyY + Math.round((bodyH - capH) / 2);
482
- const pad = 6;
483
- const fillMaxW = bodyW - pad * 2;
484
- const fillWidth = Math.round((fillMaxW * remaining) / 100);
485
- const labelFontSize = label.length >= 3 ? 49 : 51;
486
-
487
- return Buffer.from(`
488
- <svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
469
+ function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
470
+ const remaining = clampPercent(remainingPercent);
471
+ const label = String(remaining);
472
+ const palette = codexUsagePalette(remaining);
473
+
474
+ const bodyX = 28;
475
+ const bodyY = 38;
476
+ const bodyW = 109;
477
+ const bodyH = 56;
478
+ const capW = 12;
479
+ const capH = 22;
480
+ const capX = bodyX + bodyW;
481
+ const capY = bodyY + Math.round((bodyH - capH) / 2);
482
+ const pad = 6;
483
+ const fillMaxW = bodyW - pad * 2;
484
+ const fillWidth = Math.round((fillMaxW * remaining) / 100);
485
+ const labelFontSize = label.length >= 3 ? 49 : 51;
486
+
487
+ return Buffer.from(`
488
+ <svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
489
489
  <defs>
490
490
  <filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
491
491
  <feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4a2712" flood-opacity="0.30"/>
@@ -501,100 +501,100 @@ function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
501
501
  <clipPath id="batteryInnerClip">
502
502
  <rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillMaxW}" height="${bodyH - pad * 2}" rx="10"/>
503
503
  </clipPath>
504
- </defs>
505
- <g filter="url(#shadow)">
506
- <rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="#0f172a"/>
507
- <rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillMaxW}" height="${bodyH - pad * 2}" rx="12" fill="url(#well)"/>
508
- <rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillWidth}" height="${bodyH - pad * 2}" fill="url(#fill)" clip-path="url(#batteryInnerClip)"/>
509
- <rect x="${bodyX + pad + 4}" y="${bodyY + pad + 5}" width="${Math.max(0, fillWidth - 8)}" height="8" rx="4" fill="${palette.glow}" fill-opacity="0.42" clip-path="url(#batteryInnerClip)"/>
510
- <rect x="${capX}" y="${capY}" width="${capW}" height="${capH}" rx="6" fill="#0f172a"/>
511
- <rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="none" stroke="#f8fafc" stroke-opacity="0.82" stroke-width="3.8"/>
512
- <text x="${bodyX + bodyW / 2}" y="${bodyY + bodyH / 2 + 3}" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" font-family="Arial, Helvetica, sans-serif" font-size="${labelFontSize}" font-weight="700" letter-spacing="0" stroke="#0b1220" stroke-width="2.6" paint-order="stroke" stroke-linejoin="round" fill="#ffffff">${label}</text>
513
- </g>
514
- </svg>`);
515
- }
516
-
517
- function buildCodexUsageRingSvg(remainingPercent: number): Buffer {
518
- const remaining = clampPercent(remainingPercent);
519
- const palette = codexUsagePalette(remaining);
520
- const cx = 128;
521
- const cy = 128;
522
- const r = 118;
523
- const strokeWidth = 13;
524
- const used = clampPercent(100 - remaining);
525
- const polar = (angleDegrees: number) => {
526
- const angle = (angleDegrees * Math.PI) / 180;
527
- return {
528
- x: cx + r * Math.cos(angle),
529
- y: cy + r * Math.sin(angle),
530
- };
531
- };
532
- const startAngle = -90 + (used / 100) * 360;
533
- const sweepAngle = (remaining / 100) * 360;
534
- const start = polar(startAngle);
535
- const end = polar(startAngle + Math.min(sweepAngle, 359.99));
536
- const largeArcFlag = sweepAngle > 180 ? 1 : 0;
537
- const progressPath = remaining >= 100
538
- ? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`
539
- : remaining <= 0
540
- ? ""
541
- : `<path d="M ${start.x.toFixed(3)} ${start.y.toFixed(3)} A ${r} ${r} 0 ${largeArcFlag} 1 ${end.x.toFixed(3)} ${end.y.toFixed(3)}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`;
542
-
543
- return Buffer.from(`
544
- <svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
545
- <defs>
546
- <linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
547
- <stop offset="0" stop-color="${palette.start}"/>
548
- <stop offset="1" stop-color="${palette.end}"/>
549
- </linearGradient>
550
- <filter id="ringShadow" x="-10%" y="-10%" width="120%" height="120%">
551
- <feDropShadow dx="0" dy="2" stdDeviation="2.4" flood-color="#0f172a" flood-opacity="0.25"/>
552
- </filter>
553
- </defs>
554
- <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#cbd5e1" stroke-width="${strokeWidth}"/>
555
- ${progressPath}
556
- </svg>`);
557
- }
558
-
559
- async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
560
- const badge = await sharp(AVATAR_BADGES[tool])
561
- .resize(AVATAR_BADGE_SIZE, AVATAR_BADGE_SIZE, {
562
- fit: "contain",
563
- kernel: sharp.kernel.lanczos3,
564
- background: { r: 0, g: 0, b: 0, alpha: 0 },
565
- })
566
- .png()
567
- .toBuffer();
568
- return {
569
- input: badge,
570
- left: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
571
- top: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
572
- };
573
- }
574
-
575
- async function renderAvatar(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
576
- const normalizedTool = normalizeAvatarTool(tool);
577
- const normalizedStatus = normalizeAvatarStatus(status);
578
- const composites: sharp.OverlayOptions[] = [];
579
-
580
- const useDynamicCodexAvatar = normalizedTool === "codex" && codexUsage?.weekly;
581
- const basePath = useDynamicCodexAvatar
582
- ? AVATAR_SOURCES[normalizedStatus]
583
- : avatarCombinationPath(normalizedTool, normalizedStatus);
584
-
585
- if (useDynamicCodexAvatar) {
586
- composites.push(
587
- { input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 },
588
- { input: buildCodexUsageBatterySvg(codexUsage.weekly.remainingPercent), left: 0, top: 0 },
589
- await buildAgentBadgeOverlay(normalizedTool),
590
- );
591
- }
592
-
593
- let pipeline = sharp(await readFile(basePath))
594
- .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
595
- if (composites.length > 0) {
596
- pipeline = pipeline.composite(composites);
597
- }
504
+ </defs>
505
+ <g filter="url(#shadow)">
506
+ <rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="#0f172a"/>
507
+ <rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillMaxW}" height="${bodyH - pad * 2}" rx="12" fill="url(#well)"/>
508
+ <rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillWidth}" height="${bodyH - pad * 2}" fill="url(#fill)" clip-path="url(#batteryInnerClip)"/>
509
+ <rect x="${bodyX + pad + 4}" y="${bodyY + pad + 5}" width="${Math.max(0, fillWidth - 8)}" height="8" rx="4" fill="${palette.glow}" fill-opacity="0.42" clip-path="url(#batteryInnerClip)"/>
510
+ <rect x="${capX}" y="${capY}" width="${capW}" height="${capH}" rx="6" fill="#0f172a"/>
511
+ <rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="none" stroke="#f8fafc" stroke-opacity="0.82" stroke-width="3.8"/>
512
+ <text x="${bodyX + bodyW / 2}" y="${bodyY + bodyH / 2 + 3}" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" font-family="Arial, Helvetica, sans-serif" font-size="${labelFontSize}" font-weight="700" letter-spacing="0" stroke="#0b1220" stroke-width="2.6" paint-order="stroke" stroke-linejoin="round" fill="#ffffff">${label}</text>
513
+ </g>
514
+ </svg>`);
515
+ }
516
+
517
+ function buildCodexUsageRingSvg(remainingPercent: number): Buffer {
518
+ const remaining = clampPercent(remainingPercent);
519
+ const palette = codexUsagePalette(remaining);
520
+ const cx = 128;
521
+ const cy = 128;
522
+ const r = 118;
523
+ const strokeWidth = 13;
524
+ const used = clampPercent(100 - remaining);
525
+ const polar = (angleDegrees: number) => {
526
+ const angle = (angleDegrees * Math.PI) / 180;
527
+ return {
528
+ x: cx + r * Math.cos(angle),
529
+ y: cy + r * Math.sin(angle),
530
+ };
531
+ };
532
+ const startAngle = -90 + (used / 100) * 360;
533
+ const sweepAngle = (remaining / 100) * 360;
534
+ const start = polar(startAngle);
535
+ const end = polar(startAngle + Math.min(sweepAngle, 359.99));
536
+ const largeArcFlag = sweepAngle > 180 ? 1 : 0;
537
+ const progressPath = remaining >= 100
538
+ ? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`
539
+ : remaining <= 0
540
+ ? ""
541
+ : `<path d="M ${start.x.toFixed(3)} ${start.y.toFixed(3)} A ${r} ${r} 0 ${largeArcFlag} 1 ${end.x.toFixed(3)} ${end.y.toFixed(3)}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`;
542
+
543
+ return Buffer.from(`
544
+ <svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
545
+ <defs>
546
+ <linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
547
+ <stop offset="0" stop-color="${palette.start}"/>
548
+ <stop offset="1" stop-color="${palette.end}"/>
549
+ </linearGradient>
550
+ <filter id="ringShadow" x="-10%" y="-10%" width="120%" height="120%">
551
+ <feDropShadow dx="0" dy="2" stdDeviation="2.4" flood-color="#0f172a" flood-opacity="0.25"/>
552
+ </filter>
553
+ </defs>
554
+ <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#cbd5e1" stroke-width="${strokeWidth}"/>
555
+ ${progressPath}
556
+ </svg>`);
557
+ }
558
+
559
+ async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
560
+ const badge = await sharp(AVATAR_BADGES[tool])
561
+ .resize(AVATAR_BADGE_SIZE, AVATAR_BADGE_SIZE, {
562
+ fit: "contain",
563
+ kernel: sharp.kernel.lanczos3,
564
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
565
+ })
566
+ .png()
567
+ .toBuffer();
568
+ return {
569
+ input: badge,
570
+ left: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
571
+ top: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
572
+ };
573
+ }
574
+
575
+ async function renderAvatar(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
576
+ const normalizedTool = normalizeAvatarTool(tool);
577
+ const normalizedStatus = normalizeAvatarStatus(status);
578
+ const composites: sharp.OverlayOptions[] = [];
579
+
580
+ const useDynamicCodexAvatar = normalizedTool === "codex" && codexUsage?.weekly;
581
+ const basePath = useDynamicCodexAvatar
582
+ ? AVATAR_SOURCES[normalizedStatus]
583
+ : avatarCombinationPath(normalizedTool, normalizedStatus);
584
+
585
+ if (useDynamicCodexAvatar) {
586
+ composites.push(
587
+ { input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 },
588
+ { input: buildCodexUsageBatterySvg(codexUsage.weekly.remainingPercent), left: 0, top: 0 },
589
+ await buildAgentBadgeOverlay(normalizedTool),
590
+ );
591
+ }
592
+
593
+ let pipeline = sharp(await readFile(basePath))
594
+ .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
595
+ if (composites.length > 0) {
596
+ pipeline = pipeline.composite(composites);
597
+ }
598
598
 
599
599
  const jpeg = await pipeline
600
600
  .flatten({ background: "#ffffff" })
@@ -602,16 +602,16 @@ async function renderAvatar(tool: string, status: string, codexUsage: CodexUsage
602
602
  .jpeg({ quality: 95, progressive: false })
603
603
  .toBuffer();
604
604
 
605
- return {
606
- buffer: jpeg,
607
- contentType: "image/jpeg",
608
- filename: codexUsage?.weekly
609
- ? `avatar_${normalizedTool}_${normalizedStatus}_week_${codexUsage.weekly.remainingPercent}_5h_${codexUsage.fiveHour.remainingPercent}.jpg`
610
- : `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
611
- };
612
- }
613
-
614
- async function uploadImage(token: string, tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<string> {
605
+ return {
606
+ buffer: jpeg,
607
+ contentType: "image/jpeg",
608
+ filename: codexUsage?.weekly
609
+ ? `avatar_${normalizedTool}_${normalizedStatus}_week_${codexUsage.weekly.remainingPercent}_5h_${codexUsage.fiveHour.remainingPercent}.jpg`
610
+ : `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
611
+ };
612
+ }
613
+
614
+ async function uploadImage(token: string, tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<string> {
615
615
  const image = await renderAvatar(tool, status, codexUsage);
616
616
  const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
617
617
  const form = new FormData();
@@ -634,10 +634,10 @@ async function uploadImage(token: string, tool: string, status: string, codexUsa
634
634
  }
635
635
 
636
636
  async function getOrUploadAvatarKey(token: string, tool: string, status: string): Promise<string> {
637
- await loadAvatarKeyCache();
638
- const normalizedTool = normalizeAvatarTool(tool);
639
- const normalizedStatus = normalizeAvatarStatus(status);
640
- const codexUsage = normalizedTool === "codex" ? await fetchCodexAvatarUsage() : null;
637
+ await loadAvatarKeyCache();
638
+ const normalizedTool = normalizeAvatarTool(tool);
639
+ const normalizedStatus = normalizeAvatarStatus(status);
640
+ const codexUsage = normalizedTool === "codex" ? await fetchCodexAvatarUsage() : null;
641
641
  const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage);
642
642
  const cached = avatarKeyCache.get(keyName);
643
643
  if (cached) return cached;
@@ -29,9 +29,9 @@ export interface FeishuPlatform {
29
29
  updateChatInfo: typeof realApi.updateChatInfo;
30
30
  getChatInfo: typeof realApi.getChatInfo;
31
31
  disbandChat: typeof realApi.disbandChat;
32
- setChatAvatar: typeof realApi.setChatAvatar;
33
- getCodexUsageSummary: typeof realApi.getCodexUsageSummary;
34
- getOrDownloadImage: typeof realApi.getOrDownloadImage;
32
+ setChatAvatar: typeof realApi.setChatAvatar;
33
+ getCodexUsageSummary: typeof realApi.getCodexUsageSummary;
34
+ getOrDownloadImage: typeof realApi.getOrDownloadImage;
35
35
  verifyAllPermissions: typeof realApi.verifyAllPermissions;
36
36
  reportPermissionResults: typeof realApi.reportPermissionResults;
37
37
  extractSessionInfo: typeof realApi.extractSessionInfo;
@@ -109,17 +109,17 @@ export function disbandChat(...args: Parameters<typeof realApi.disbandChat>): Re
109
109
  return _impl.disbandChat(...args);
110
110
  }
111
111
 
112
- export function setChatAvatar(...args: Parameters<typeof realApi.setChatAvatar>): ReturnType<typeof realApi.setChatAvatar> {
113
- return _impl.setChatAvatar(...args);
114
- }
115
-
116
- export function getCodexUsageSummary(...args: Parameters<typeof realApi.getCodexUsageSummary>): ReturnType<typeof realApi.getCodexUsageSummary> {
117
- return _impl.getCodexUsageSummary(...args);
118
- }
119
-
120
- export function getOrDownloadImage(...args: Parameters<typeof realApi.getOrDownloadImage>): ReturnType<typeof realApi.getOrDownloadImage> {
121
- return _impl.getOrDownloadImage(...args);
122
- }
112
+ export function setChatAvatar(...args: Parameters<typeof realApi.setChatAvatar>): ReturnType<typeof realApi.setChatAvatar> {
113
+ return _impl.setChatAvatar(...args);
114
+ }
115
+
116
+ export function getCodexUsageSummary(...args: Parameters<typeof realApi.getCodexUsageSummary>): ReturnType<typeof realApi.getCodexUsageSummary> {
117
+ return _impl.getCodexUsageSummary(...args);
118
+ }
119
+
120
+ export function getOrDownloadImage(...args: Parameters<typeof realApi.getOrDownloadImage>): ReturnType<typeof realApi.getOrDownloadImage> {
121
+ return _impl.getOrDownloadImage(...args);
122
+ }
123
123
 
124
124
  export function verifyAllPermissions(...args: Parameters<typeof realApi.verifyAllPermissions>): ReturnType<typeof realApi.verifyAllPermissions> {
125
125
  return _impl.verifyAllPermissions(...args);
@@ -151,4 +151,4 @@ export function sendRestartCard(...args: Parameters<typeof realApi.sendRestartCa
151
151
 
152
152
  export function getMergeForwardMessages(...args: Parameters<typeof realApi.getMergeForwardMessages>): ReturnType<typeof realApi.getMergeForwardMessages> {
153
153
  return _impl.getMergeForwardMessages(...args);
154
- }
154
+ }