chatccc 0.2.279 → 0.2.280

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 CHANGED
@@ -430,7 +430,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
430
430
  | `/cd` | 查看或设置后续新建会话的默认工作目录,不改变当前会话;飞书私聊自身始终使用系统用户目录 |
431
431
  | `/sessions` | 查看所有会话状态 |
432
432
  | `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话;飞书私聊不支持切换 |
433
- | `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/7天窗口,Cursor 显示当前周期用量,CCC Agent 和 DSH 仅在官方 DeepSeek 端点时显示账户余额(其他兼容端点自动跳过) |
433
+ | `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/7天窗口,主动重置次数查询失败时会明确展示上次成功快照及其查询时间(缓存结果不提供重置按钮);Cursor 显示当前周期用量,CCC Agent 和 DSH 仅在官方 DeepSeek 端点时显示账户余额(其他兼容端点自动跳过) |
434
434
  | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
435
435
  | `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
436
436
  | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
@@ -0,0 +1,105 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { USER_DATA_DIR } from "./config.js";
5
+ const CACHE_FILE = join(USER_DATA_DIR, "state", "codex-reset-credits.json");
6
+ let mutationQueue = Promise.resolve();
7
+ export function codexResetCreditsAccountKey(accountId, accessToken) {
8
+ if (accountId?.trim())
9
+ return `account:${accountId.trim()}`;
10
+ const digest = createHash("sha256").update(accessToken).digest("hex").slice(0, 24);
11
+ return `token-sha256:${digest}`;
12
+ }
13
+ function emptyCache() {
14
+ return { version: 1, accounts: {} };
15
+ }
16
+ function normalizeSnapshot(raw) {
17
+ if (!raw || typeof raw !== "object")
18
+ return null;
19
+ const value = raw;
20
+ const availableCount = Number(value.availableCount);
21
+ if (!Number.isFinite(availableCount) || typeof value.queriedAt !== "string" || !value.queriedAt.trim())
22
+ return null;
23
+ const credits = Array.isArray(value.credits)
24
+ ? value.credits.flatMap((credit) => {
25
+ if (!credit || typeof credit !== "object")
26
+ return [];
27
+ const item = credit;
28
+ if (typeof item.expiresAt !== "string" || !item.expiresAt.trim())
29
+ return [];
30
+ return [{
31
+ grantedAt: typeof item.grantedAt === "string" && item.grantedAt.trim() ? item.grantedAt : null,
32
+ expiresAt: item.expiresAt,
33
+ }];
34
+ })
35
+ : [];
36
+ return {
37
+ availableCount: Math.max(0, Math.trunc(availableCount)),
38
+ credits,
39
+ queriedAt: value.queriedAt,
40
+ locallyAdjusted: value.locallyAdjusted === true,
41
+ };
42
+ }
43
+ async function readCache() {
44
+ try {
45
+ const parsed = JSON.parse(await readFile(CACHE_FILE, "utf-8"));
46
+ const accounts = {};
47
+ if (parsed.accounts && typeof parsed.accounts === "object") {
48
+ for (const [key, raw] of Object.entries(parsed.accounts)) {
49
+ const snapshot = normalizeSnapshot(raw);
50
+ if (snapshot)
51
+ accounts[key] = snapshot;
52
+ }
53
+ }
54
+ return { version: 1, accounts };
55
+ }
56
+ catch {
57
+ return emptyCache();
58
+ }
59
+ }
60
+ async function writeCache(cache) {
61
+ await mkdir(dirname(CACHE_FILE), { recursive: true });
62
+ const tempFile = `${CACHE_FILE}.${process.pid}.${randomUUID()}.tmp`;
63
+ await writeFile(tempFile, JSON.stringify(cache, null, 2), "utf-8");
64
+ try {
65
+ await rename(tempFile, CACHE_FILE);
66
+ }
67
+ catch {
68
+ await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8");
69
+ await rm(tempFile, { force: true }).catch(() => { });
70
+ }
71
+ }
72
+ async function mutateCache(mutate) {
73
+ const operation = mutationQueue.then(async () => {
74
+ const cache = await readCache();
75
+ mutate(cache);
76
+ await writeCache(cache);
77
+ });
78
+ mutationQueue = operation.catch(() => { });
79
+ return operation;
80
+ }
81
+ export async function readCodexResetCreditsSnapshot(accountKey) {
82
+ return (await readCache()).accounts[accountKey] ?? null;
83
+ }
84
+ export async function saveCodexResetCreditsSnapshot(accountKey, snapshot) {
85
+ await mutateCache((cache) => {
86
+ cache.accounts[accountKey] = {
87
+ ...snapshot,
88
+ availableCount: Math.max(0, Math.trunc(snapshot.availableCount)),
89
+ locallyAdjusted: false,
90
+ };
91
+ });
92
+ }
93
+ export async function decrementCachedCodexResetCredits(accountKey) {
94
+ await mutateCache((cache) => {
95
+ const current = cache.accounts[accountKey];
96
+ if (!current)
97
+ return;
98
+ cache.accounts[accountKey] = {
99
+ ...current,
100
+ availableCount: Math.max(0, current.availableCount - 1),
101
+ credits: current.credits.slice(1),
102
+ locallyAdjusted: true,
103
+ };
104
+ });
105
+ }
@@ -6,6 +6,7 @@ import sharp from "sharp";
6
6
  import { APP_ID, APP_SECRET, BASE_URL, CHAT_LOGS_DIR, PROJECT_ROOT, USER_DATA_DIR, CLAUDE_SESSION_PREFIX, CURSOR_SESSION_PREFIX, CODEX_SESSION_PREFIX, CCC_SESSION_PREFIX, DSH_SESSION_PREFIX, ts, resolveDefaultAgentTool, toolDisplayName, config, } from "./config.js";
7
7
  import { getCursorUsageSummary } from "./cursor-usage.js";
8
8
  import { applyPrivacy } from "./privacy.js";
9
+ import { codexResetCreditsAccountKey, decrementCachedCodexResetCredits, readCodexResetCreditsSnapshot, saveCodexResetCreditsSnapshot, } from "./codex-reset-credits-cache.js";
9
10
  import { buildHelpCard } from "./cards.js";
10
11
  // ---------------------------------------------------------------------------
11
12
  // Auth
@@ -366,9 +367,6 @@ async function getCodexAuth() {
366
367
  return null;
367
368
  }
368
369
  }
369
- async function getCodexAccessToken() {
370
- return (await getCodexAuth())?.accessToken ?? null;
371
- }
372
370
  function codexAuthHeaders(auth) {
373
371
  const headers = {
374
372
  Authorization: `Bearer ${auth.accessToken}`,
@@ -399,26 +397,42 @@ function parseCodexResetCreditDetails(data) {
399
397
  }),
400
398
  };
401
399
  }
400
+ const codexResetCreditsInFlight = new Map();
402
401
  async function fetchCodexRateLimitResetCredits(auth) {
403
- try {
404
- const resp = await fetch(CODEX_RESET_CREDITS_URL, {
405
- headers: codexAuthHeaders(auth),
406
- });
407
- const text = await resp.text();
408
- if (!resp.ok)
409
- throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
410
- return parseCodexResetCreditDetails(JSON.parse(text));
411
- }
412
- catch (err) {
413
- console.warn(`[Codex] reset credits lookup failed: ${err.message}`);
414
- return null;
415
- }
402
+ const accountKey = codexResetCreditsAccountKey(auth.accountId, auth.accessToken);
403
+ const existing = codexResetCreditsInFlight.get(accountKey);
404
+ if (existing)
405
+ return existing;
406
+ const lookup = (async () => {
407
+ try {
408
+ const resp = await fetch(CODEX_RESET_CREDITS_URL, {
409
+ headers: codexAuthHeaders(auth),
410
+ });
411
+ const text = await resp.text();
412
+ if (!resp.ok)
413
+ throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
414
+ return {
415
+ details: parseCodexResetCreditDetails(JSON.parse(text)),
416
+ error: null,
417
+ };
418
+ }
419
+ catch (err) {
420
+ const error = err.message;
421
+ console.warn(`[Codex] reset credits lookup failed: ${error}`);
422
+ return { details: null, error };
423
+ }
424
+ })().finally(() => {
425
+ codexResetCreditsInFlight.delete(accountKey);
426
+ });
427
+ codexResetCreditsInFlight.set(accountKey, lookup);
428
+ return lookup;
416
429
  }
417
- export async function getCodexUsageSummary() {
430
+ export async function getCodexUsageSummary(options = {}) {
418
431
  const auth = await getCodexAuth();
419
432
  if (!auth)
420
433
  throw new Error("missing ~/.codex/auth.json access token");
421
- const resetCreditsPromise = fetchCodexRateLimitResetCredits(auth);
434
+ const includeResetCredits = options.includeResetCredits !== false;
435
+ const resetCreditsPromise = includeResetCredits ? fetchCodexRateLimitResetCredits(auth) : null;
422
436
  const resp = await fetch(CODEX_USAGE_URL, {
423
437
  headers: { Authorization: `Bearer ${auth.accessToken}` },
424
438
  });
@@ -443,24 +457,77 @@ export async function getCodexUsageSummary() {
443
457
  ?? (primaryWindow?.limitWindowSeconds === undefined ? primaryWindow : null);
444
458
  const weekly = windows.find((window) => isUsageWindowDuration(window, SEVEN_DAY_WINDOW_SECONDS))
445
459
  ?? (secondaryWindow?.limitWindowSeconds === undefined ? secondaryWindow : null);
446
- const resetCredits = await resetCreditsPromise;
460
+ if (!includeResetCredits) {
461
+ return {
462
+ fiveHour,
463
+ weekly,
464
+ rateLimitResetCreditsAvailable: null,
465
+ rateLimitResetCredits: null,
466
+ rateLimitResetCreditsSource: "not_requested",
467
+ rateLimitResetCreditsQueriedAt: null,
468
+ rateLimitResetCreditsLocallyAdjusted: false,
469
+ rateLimitResetCreditsError: null,
470
+ };
471
+ }
472
+ const accountKey = codexResetCreditsAccountKey(auth.accountId, auth.accessToken);
473
+ const resetLookup = await resetCreditsPromise;
474
+ const embeddedAvailableCount = parseRateLimitResetCredits(data);
475
+ const liveAvailableCount = resetLookup.details?.availableCount ?? embeddedAvailableCount;
476
+ if (liveAvailableCount !== null) {
477
+ const queriedAt = new Date().toISOString();
478
+ const liveCredits = resetLookup.details?.availableCredits ?? [];
479
+ await saveCodexResetCreditsSnapshot(accountKey, {
480
+ availableCount: liveAvailableCount,
481
+ credits: liveCredits,
482
+ queriedAt,
483
+ }).catch((err) => {
484
+ console.warn(`[Codex] reset credits snapshot write failed: ${err.message}`);
485
+ });
486
+ return {
487
+ fiveHour,
488
+ weekly,
489
+ rateLimitResetCreditsAvailable: liveAvailableCount,
490
+ rateLimitResetCredits: liveCredits,
491
+ rateLimitResetCreditsSource: "live",
492
+ rateLimitResetCreditsQueriedAt: queriedAt,
493
+ rateLimitResetCreditsLocallyAdjusted: false,
494
+ rateLimitResetCreditsError: null,
495
+ };
496
+ }
497
+ const cached = await readCodexResetCreditsSnapshot(accountKey);
498
+ if (cached) {
499
+ return {
500
+ fiveHour,
501
+ weekly,
502
+ rateLimitResetCreditsAvailable: cached.availableCount,
503
+ rateLimitResetCredits: cached.credits,
504
+ rateLimitResetCreditsSource: "cache",
505
+ rateLimitResetCreditsQueriedAt: cached.queriedAt,
506
+ rateLimitResetCreditsLocallyAdjusted: cached.locallyAdjusted,
507
+ rateLimitResetCreditsError: resetLookup.error ?? "OpenAI returned no reset-credit data",
508
+ };
509
+ }
447
510
  return {
448
511
  fiveHour,
449
512
  weekly,
450
- rateLimitResetCreditsAvailable: resetCredits?.availableCount ?? parseRateLimitResetCredits(data),
451
- rateLimitResetCredits: resetCredits?.availableCredits ?? null,
513
+ rateLimitResetCreditsAvailable: null,
514
+ rateLimitResetCredits: null,
515
+ rateLimitResetCreditsSource: "unavailable",
516
+ rateLimitResetCreditsQueriedAt: null,
517
+ rateLimitResetCreditsLocallyAdjusted: false,
518
+ rateLimitResetCreditsError: resetLookup.error ?? "OpenAI returned no reset-credit data",
452
519
  };
453
520
  }
454
521
  export async function consumeCodexRateLimitResetCredit(redeemRequestId) {
455
522
  if (!redeemRequestId.trim())
456
523
  throw new Error("missing redeem_request_id");
457
- const token = await getCodexAccessToken();
458
- if (!token)
524
+ const auth = await getCodexAuth();
525
+ if (!auth)
459
526
  throw new Error("missing ~/.codex/auth.json access token");
460
527
  const resp = await fetch(CODEX_RESET_CONSUME_URL, {
461
528
  method: "POST",
462
529
  headers: {
463
- Authorization: `Bearer ${token}`,
530
+ Authorization: `Bearer ${auth.accessToken}`,
464
531
  "Content-Type": "application/json",
465
532
  },
466
533
  body: JSON.stringify({ redeem_request_id: redeemRequestId }),
@@ -477,10 +544,17 @@ export async function consumeCodexRateLimitResetCredit(redeemRequestId) {
477
544
  throw new Error("missing or unknown reset result code");
478
545
  }
479
546
  const windowsReset = Number(data.windows_reset);
480
- return {
547
+ const result = {
481
548
  code,
482
549
  windowsReset: Number.isFinite(windowsReset) ? Math.max(0, Math.trunc(windowsReset)) : 0,
483
550
  };
551
+ if (result.code === "reset") {
552
+ const accountKey = codexResetCreditsAccountKey(auth.accountId, auth.accessToken);
553
+ await decrementCachedCodexResetCredits(accountKey).catch((err) => {
554
+ console.warn(`[Codex] reset credits snapshot update failed: ${err.message}`);
555
+ });
556
+ }
557
+ return result;
484
558
  }
485
559
  async function resolveCodexAvatarUsage(usageHint) {
486
560
  if (usageHint !== undefined) {
@@ -489,7 +563,7 @@ async function resolveCodexAvatarUsage(usageHint) {
489
563
  return usageHint;
490
564
  }
491
565
  try {
492
- const summary = await getCodexUsageSummary();
566
+ const summary = await getCodexUsageSummary({ includeResetCredits: false });
493
567
  if (!summary.weekly)
494
568
  throw new Error("missing weekly usage window");
495
569
  return summary;
@@ -100,33 +100,69 @@ function formatCodexUsageSummary(usage, chatGptSubscription = null) {
100
100
  ].join("\n");
101
101
  };
102
102
  const formatResetCredits = () => {
103
+ const formatDateTime = (value) => {
104
+ const date = new Date(value);
105
+ if (!Number.isFinite(date.getTime()))
106
+ return value;
107
+ const pad = (part) => String(part).padStart(2, "0");
108
+ return [
109
+ date.getFullYear(),
110
+ "-",
111
+ pad(date.getMonth() + 1),
112
+ "-",
113
+ pad(date.getDate()),
114
+ " ",
115
+ pad(date.getHours()),
116
+ ":",
117
+ pad(date.getMinutes()),
118
+ ":",
119
+ pad(date.getSeconds()),
120
+ ].join("");
121
+ };
122
+ const failureReason = () => {
123
+ const raw = usage.rateLimitResetCreditsError?.replace(/\s+/g, " ").trim();
124
+ if (!raw)
125
+ return "OpenAI 未返回主动重置数据";
126
+ if (/HTTP 429/i.test(raw))
127
+ return "OpenAI 返回 HTTP 429(请求受限)";
128
+ if (/HTTP 404/i.test(raw))
129
+ return "OpenAI 返回 HTTP 404(接口暂不可用)";
130
+ return raw.length > 180 ? `${raw.slice(0, 180)}...` : raw;
131
+ };
132
+ const source = usage.rateLimitResetCreditsSource ?? "live";
133
+ if (source === "unavailable") {
134
+ return [
135
+ `**主动重置:** 本次查询失败(${failureReason()})`,
136
+ "- 没有可用的历史缓存结果",
137
+ ].join("\n");
138
+ }
139
+ if (source === "cache") {
140
+ const lines = [
141
+ `**主动重置:** 本次查询失败(${failureReason()})`,
142
+ `**上次缓存结果:** 剩余 ${usage.rateLimitResetCreditsAvailable ?? 0} 次`,
143
+ ];
144
+ if (usage.rateLimitResetCreditsQueriedAt) {
145
+ lines.push(`- 查询时间: ${formatDateTime(usage.rateLimitResetCreditsQueriedAt)}`);
146
+ }
147
+ if (usage.rateLimitResetCreditsLocallyAdjusted) {
148
+ lines.push("- 状态: 本地推算,待下次查询确认");
149
+ }
150
+ const credits = usage.rateLimitResetCredits ?? [];
151
+ if (credits.length > 0) {
152
+ lines.push("**缓存中的过期时间:**");
153
+ for (const credit of credits)
154
+ lines.push(`- ${formatDateTime(credit.expiresAt)}`);
155
+ }
156
+ return lines.join("\n");
157
+ }
103
158
  if (usage.rateLimitResetCreditsAvailable === null)
104
159
  return "**主动重置:** 暂无数据";
105
160
  const lines = [`**主动重置:** 剩余 ${usage.rateLimitResetCreditsAvailable} 次`];
106
161
  const credits = usage.rateLimitResetCredits ?? [];
107
162
  if (credits.length > 0) {
108
- const pad = (value) => String(value).padStart(2, "0");
109
- const formatExpiresAt = (value) => {
110
- const date = new Date(value);
111
- if (!Number.isFinite(date.getTime()))
112
- return value;
113
- return [
114
- date.getFullYear(),
115
- "-",
116
- pad(date.getMonth() + 1),
117
- "-",
118
- pad(date.getDate()),
119
- " ",
120
- pad(date.getHours()),
121
- ":",
122
- pad(date.getMinutes()),
123
- ":",
124
- pad(date.getSeconds()),
125
- ].join("");
126
- };
127
163
  lines.push("**过期时间:**");
128
164
  for (const credit of credits) {
129
- lines.push(`- ${formatExpiresAt(credit.expiresAt)}`);
165
+ lines.push(`- ${formatDateTime(credit.expiresAt)}`);
130
166
  }
131
167
  }
132
168
  return lines.join("\n");
@@ -397,7 +433,11 @@ async function sendUsageSummary(platform, chatId, tool, avatarStatus = "idle", s
397
433
  await platform.sendText(chatId, content).catch(() => { });
398
434
  }
399
435
  else if (platform.kind === "feishu") {
400
- await platform.sendRawCard(chatId, buildCodexUsageCard(content, usage.rateLimitResetCreditsAvailable));
436
+ const liveResetCredits = usage.rateLimitResetCreditsSource === undefined
437
+ || usage.rateLimitResetCreditsSource === "live"
438
+ ? usage.rateLimitResetCreditsAvailable
439
+ : null;
440
+ await platform.sendRawCard(chatId, buildCodexUsageCard(content, liveResetCredits));
401
441
  }
402
442
  else {
403
443
  await platform.sendCard(chatId, "Codex Usage", content, "blue");
@@ -105,6 +105,10 @@ export const SimulatedPlatform = {
105
105
  weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
106
106
  rateLimitResetCreditsAvailable: null,
107
107
  rateLimitResetCredits: null,
108
+ rateLimitResetCreditsSource: "unavailable",
109
+ rateLimitResetCreditsQueriedAt: null,
110
+ rateLimitResetCreditsLocallyAdjusted: false,
111
+ rateLimitResetCreditsError: "Simulated platform has no reset-credit data",
108
112
  };
109
113
  },
110
114
  async consumeCodexRateLimitResetCredit(_redeemRequestId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.279",
3
+ "version": "0.2.280",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",