chatccc 0.2.179 → 0.2.181

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.
@@ -77,6 +77,7 @@ import {
77
77
  resetState,
78
78
  } from "../session.ts";
79
79
  import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
80
+ import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
80
81
 
81
82
  function mockPlatform(kind: "wechat" | "feishu" = "wechat"): PlatformAdapter {
82
83
  return {
@@ -131,6 +132,8 @@ describe("handleCommand WeChat processing ack", () => {
131
132
  mockGetCodexUsageSummary.mockResolvedValue({
132
133
  fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
133
134
  weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
135
+ rateLimitResetCreditsAvailable: null,
136
+ rateLimitResetCredits: null,
134
137
  });
135
138
  mockGetCursorUsageSummary.mockResolvedValue({
136
139
  billingCycleStart: "1779357999000",
@@ -212,6 +215,41 @@ describe("handleCommand WeChat processing ack", () => {
212
215
  expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
213
216
  });
214
217
 
218
+ it("treats /abd as a shared prompt prefix in an existing session", async () => {
219
+ const platform = mockPlatform();
220
+ const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
221
+ yield {
222
+ type: "assistant" as const,
223
+ blocks: [{ type: "text" as const, text: "done" }],
224
+ };
225
+ });
226
+ _setAdapterForToolForTest("claude", {
227
+ displayName: "Claude",
228
+ sessionDescPrefix: "Claude Session:",
229
+ createSession: vi.fn(async () => ({ sessionId: "sid-wechat" })),
230
+ prompt,
231
+ getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
232
+ sessionId,
233
+ cwd: "F:\\repo",
234
+ }),
235
+ closeSession: async () => {},
236
+ });
237
+ await recordSessionRegistry({
238
+ chatId: "wx-chat",
239
+ sessionId: "sid-wechat",
240
+ tool: "claude",
241
+ chatName: "ready-session",
242
+ running: false,
243
+ });
244
+
245
+ await handleCommand(platform, "/abd帮我分析", "wx-chat", "wx-user", Date.now(), "p2p");
246
+
247
+ expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
248
+ const userText = prompt.mock.calls[0][1];
249
+ expect(userText).toContain(`[User message]\n帮我分析\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
250
+ expect(userText).not.toContain("/abd");
251
+ });
252
+
215
253
  it("does not send the stopped success text until the running prompt really exits", async () => {
216
254
  const platform = mockPlatform();
217
255
  await recordSessionRegistry({
@@ -280,6 +318,38 @@ describe("handleCommand WeChat processing ack", () => {
280
318
  expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
281
319
  });
282
320
 
321
+ it("auto-creates a Feishu group for /abd private messages and sends the transformed prompt", async () => {
322
+ const platform = mockPlatform("feishu");
323
+ const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
324
+ yield {
325
+ type: "assistant" as const,
326
+ blocks: [{ type: "text" as const, text: "done" }],
327
+ };
328
+ });
329
+ _setAdapterForToolForTest("claude", {
330
+ displayName: "Claude",
331
+ sessionDescPrefix: "Claude Session:",
332
+ createSession: vi.fn(async () => ({ sessionId: "sid-feishu-abd" })),
333
+ prompt,
334
+ getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
335
+ sessionId,
336
+ cwd: "F:\\repo",
337
+ }),
338
+ closeSession: async () => {},
339
+ });
340
+
341
+ await handleCommand(platform, "/abd帮我看一下日志", "feishu-p2p", "ou-user", Date.now(), "p2p");
342
+
343
+ expect(platform.createGroup).toHaveBeenCalledWith(expect.stringContaining("帮我看一下日志"), ["ou-user"]);
344
+ expect(platform.createGroup).not.toHaveBeenCalledWith(expect.stringContaining("---"), expect.anything());
345
+ const updateCall = vi.mocked(platform.updateChatInfo).mock.calls[0];
346
+ expect(updateCall[1]).not.toContain("---");
347
+ expect(updateCall[1]).not.toContain(ABD_APPEND_PROMPT);
348
+ const userText = prompt.mock.calls[0][1];
349
+ expect(userText).toContain(`[User message]\n帮我看一下日志\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
350
+ expect(userText).not.toContain("/abd");
351
+ });
352
+
283
353
  it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
284
354
  const platform = mockPlatform("feishu");
285
355
  await recordSessionRegistry({
@@ -303,47 +373,31 @@ describe("handleCommand WeChat processing ack", () => {
303
373
  mockGetCodexUsageSummary.mockResolvedValue({
304
374
  fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
305
375
  weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
376
+ rateLimitResetCreditsAvailable: 2,
377
+ rateLimitResetCredits: [
378
+ { grantedAt: "2026-06-12T04:01:47.770016Z", expiresAt: "2026-07-12T04:01:47.770016Z" },
379
+ { grantedAt: "2026-06-18T00:44:23.904386Z", expiresAt: "2026-07-18T00:44:23.904386Z" },
380
+ ],
306
381
  });
307
382
 
308
383
  await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
309
384
 
310
385
  expect(platform.createGroup).not.toHaveBeenCalled();
311
- expect(platform.sendCard).toHaveBeenCalledWith(
312
- "feishu-p2p",
313
- "Codex Usage",
314
- expect.stringContaining("**5h:** 已用 37%,剩余 63%,重置:"),
315
- "blue",
316
- );
317
- expect(platform.sendCard).toHaveBeenCalledWith(
318
- "feishu-p2p",
319
- "Codex Usage",
320
- expect.stringContaining("约 2小时52分钟后"),
321
- "blue",
322
- );
323
- expect(platform.sendCard).toHaveBeenCalledWith(
324
- "feishu-p2p",
325
- "Codex Usage",
326
- expect.stringContaining("[███████░░░░░░░░░░░░░]"),
327
- "blue",
328
- );
329
- expect(platform.sendCard).toHaveBeenCalledWith(
330
- "feishu-p2p",
331
- "Codex Usage",
332
- expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
333
- "blue",
334
- );
335
- expect(platform.sendCard).toHaveBeenCalledWith(
336
- "feishu-p2p",
337
- "Codex Usage",
338
- expect.stringContaining("约 3天18小时17分钟后"),
339
- "blue",
340
- );
341
- expect(platform.sendCard).toHaveBeenCalledWith(
342
- "feishu-p2p",
343
- "Codex Usage",
344
- expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
345
- "blue",
346
- );
386
+ expect(platform.sendCard).not.toHaveBeenCalled();
387
+ expect(platform.sendRawCard).toHaveBeenCalledTimes(1);
388
+ const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
389
+ expect(card.header.title.content).toBe("Codex Usage");
390
+ expect(card.elements[0].text.content).toContain("2026-07-12");
391
+ expect(card.elements[0].text.content).toContain("2026-07-18");
392
+ expect(card.elements[0].text.content).toContain("**主动重置:** 剩余 2 次");
393
+ expect(card.elements[0].text.content).toContain("**5h:** 已用 37%,剩余 63%,重置:");
394
+ expect(card.elements[0].text.content).toContain(" 2小时52分钟后");
395
+ expect(card.elements[0].text.content).toContain("[███████░░░░░░░░░░░░░]");
396
+ expect(card.elements[0].text.content).toContain("**周:** 已用 12%,剩余 88%,重置:");
397
+ expect(card.elements[0].text.content).toContain("约 3天18小时17分钟后");
398
+ expect(card.elements[0].text.content).toContain("[██░░░░░░░░░░░░░░░░░░]");
399
+ expect(card.elements[2].actions[0].text.content).toBe("发起重置");
400
+ expect(card.elements[2].actions[0].value).toEqual({ action: "codex_reset_request", availableCount: 2 });
347
401
  });
348
402
 
349
403
  it("handles /usage as Cursor usage in Cursor chats", async () => {
@@ -384,7 +438,7 @@ describe("handleCommand WeChat processing ack", () => {
384
438
  expect(codexPlatform.sendCard).toHaveBeenCalledWith(
385
439
  "feishu-group",
386
440
  "Codex Session Ready",
387
- expect.stringContaining("发送 **/usage** 查看 Codex 5h 和周用量。"),
441
+ expect.stringContaining("发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡。"),
388
442
  "green",
389
443
  );
390
444
 
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ ABD_APPEND_PROMPT,
5
+ applySharedPrefix,
6
+ } from "../shared-prefix.ts";
7
+
8
+ describe("applySharedPrefix", () => {
9
+ it("leaves normal messages unchanged", () => {
10
+ expect(applySharedPrefix("帮我分析")).toEqual({
11
+ matched: false,
12
+ text: "帮我分析",
13
+ body: "帮我分析",
14
+ });
15
+ });
16
+
17
+ it("removes /abd without requiring a space", () => {
18
+ const result = applySharedPrefix("/abd帮我分析");
19
+
20
+ expect(result.matched).toBe(true);
21
+ expect(result.body).toBe("帮我分析");
22
+ expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
23
+ });
24
+
25
+ it("removes whitespace after /abd", () => {
26
+ const result = applySharedPrefix("/abd 帮我分析");
27
+
28
+ expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
29
+ });
30
+
31
+ it("keeps the appendix when the user body is empty", () => {
32
+ const result = applySharedPrefix("/abd ");
33
+
34
+ expect(result.text).toBe(`---\n${ABD_APPEND_PROMPT}`);
35
+ });
36
+ });
package/src/cards.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // Button helpers
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
+ import { ABD_HELP_LINE } from "./shared-prefix.ts";
6
+
5
7
  export interface ButtonDef {
6
8
  text: string;
7
9
  value: string;
@@ -135,9 +137,10 @@ export function buildHelpCard(
135
137
  "发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
136
138
  "发送 **/plan** 以规划模式提问(只读,不执行写操作)",
137
139
  "发送 **/ask** 以问答模式提问(只读,不执行写操作)",
138
- "发送 **/usage** 查看 Codex 5h 和周用量",
140
+ "发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡",
139
141
  "发送 **/restart** 重启 ChatCCC 进程",
140
142
  "发送 **/update** 更新并重启(仅 npm 全局安装可用)",
143
+ ABD_HELP_LINE,
141
144
  ].join("\n");
142
145
  return JSON.stringify({
143
146
  config: { wide_screen_mode: true },
@@ -427,6 +430,72 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
427
430
  });
428
431
  }
429
432
 
433
+ export function buildCodexUsageCard(content: string, resetCreditsAvailable: number | null): string {
434
+ const elements: object[] = [
435
+ { tag: "div", text: { tag: "lark_md", content } },
436
+ ];
437
+ if (resetCreditsAvailable !== null && resetCreditsAvailable > 0) {
438
+ elements.push({ tag: "hr" });
439
+ elements.push({
440
+ tag: "action",
441
+ actions: [{
442
+ tag: "button",
443
+ text: { tag: "plain_text", content: "发起重置" },
444
+ type: "primary",
445
+ value: { action: "codex_reset_request", availableCount: resetCreditsAvailable },
446
+ }],
447
+ });
448
+ }
449
+
450
+ return JSON.stringify({
451
+ config: { wide_screen_mode: true },
452
+ header: { template: "blue", title: { content: "Codex Usage", tag: "plain_text" } },
453
+ elements,
454
+ });
455
+ }
456
+
457
+ export function buildCodexResetConfirmCard(params: {
458
+ availableCount: number;
459
+ parentMessageId: string;
460
+ requestId: string;
461
+ }): string {
462
+ const valueBase = {
463
+ parentMessageId: params.parentMessageId,
464
+ requestId: params.requestId,
465
+ };
466
+ return JSON.stringify({
467
+ config: { wide_screen_mode: true },
468
+ header: { template: "yellow", title: { content: "确认 Codex 主动重置", tag: "plain_text" } },
469
+ elements: [
470
+ {
471
+ tag: "div",
472
+ text: {
473
+ tag: "lark_md",
474
+ content: `当前可用主动重置次数:**${params.availableCount}**。\n\n确认后会消耗 1 次主动重置,并重置当前可重置的 Codex 用量窗口。`,
475
+ },
476
+ },
477
+ { tag: "hr" },
478
+ {
479
+ tag: "action",
480
+ actions: [
481
+ {
482
+ tag: "button",
483
+ text: { tag: "plain_text", content: "是,发起重置" },
484
+ type: "danger",
485
+ value: { action: "codex_reset_confirm", decision: "yes", ...valueBase },
486
+ },
487
+ {
488
+ tag: "button",
489
+ text: { tag: "plain_text", content: "否" },
490
+ type: "default",
491
+ value: { action: "codex_reset_confirm", decision: "no", ...valueBase },
492
+ },
493
+ ],
494
+ },
495
+ ],
496
+ });
497
+ }
498
+
430
499
  /** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
431
500
  export function buildModelCard(
432
501
  currentModel: string,
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { buildCodexResetConfirmCard } from "./cards.ts";
4
+ import type { CodexResetConsumeResult } from "./feishu-api.ts";
5
+
6
+ type CodexResetDecision = "yes" | "no";
7
+
8
+ interface PendingCodexResetRequest {
9
+ chatId: string;
10
+ parentMessageId: string;
11
+ status: "pending" | "handled";
12
+ }
13
+
14
+ export interface CodexResetActionDeps {
15
+ getTenantAccessToken(): Promise<string>;
16
+ sendRawCard(token: string, chatId: string, cardJson: string): Promise<boolean>;
17
+ sendCardReply(token: string, chatId: string, title: string, content: string, template?: string): Promise<boolean>;
18
+ updateCardMessage(token: string, messageId: string, content: string): Promise<boolean>;
19
+ consumeCodexRateLimitResetCredit(redeemRequestId: string): Promise<CodexResetConsumeResult>;
20
+ createRequestId?: () => string;
21
+ }
22
+
23
+ const pendingCodexResetRequests = new Map<string, PendingCodexResetRequest>();
24
+
25
+ function rawEvent(data: unknown): Record<string, unknown> {
26
+ return ((data as Record<string, unknown>)?.event ?? data) as Record<string, unknown>;
27
+ }
28
+
29
+ function actionValue(raw: Record<string, unknown>): Record<string, unknown> | null {
30
+ const action = raw.action as { value?: unknown } | undefined;
31
+ const value = action?.value;
32
+ if (!value || typeof value !== "object") return null;
33
+ return value as Record<string, unknown>;
34
+ }
35
+
36
+ function eventMessageId(raw: Record<string, unknown>): string {
37
+ return typeof raw.open_message_id === "string"
38
+ ? raw.open_message_id
39
+ : typeof (raw.context as Record<string, unknown> | undefined)?.open_message_id === "string"
40
+ ? (raw.context as Record<string, string>).open_message_id
41
+ : "";
42
+ }
43
+
44
+ function eventChatId(raw: Record<string, unknown>): string {
45
+ return typeof raw.open_chat_id === "string"
46
+ ? raw.open_chat_id
47
+ : typeof (raw.context as Record<string, unknown> | undefined)?.open_chat_id === "string"
48
+ ? (raw.context as Record<string, string>).open_chat_id
49
+ : typeof (raw.message as Record<string, unknown> | undefined)?.chat_id === "string"
50
+ ? (raw.message as Record<string, string>).chat_id
51
+ : "";
52
+ }
53
+
54
+ function collapsedCard(): string {
55
+ return JSON.stringify({
56
+ config: { wide_screen_mode: true },
57
+ elements: [{ tag: "markdown", content: " " }],
58
+ });
59
+ }
60
+
61
+ function resetResultMessage(result: CodexResetConsumeResult): { content: string; template: string } {
62
+ if (result.code === "reset") {
63
+ return {
64
+ content: `重置成功。已重置 ${result.windowsReset} 个 Codex 用量窗口。`,
65
+ template: "green",
66
+ };
67
+ }
68
+ if (result.code === "nothing_to_reset") {
69
+ return {
70
+ content: "没有需要重置的 Codex 用量窗口。",
71
+ template: "yellow",
72
+ };
73
+ }
74
+ if (result.code === "no_credit") {
75
+ return {
76
+ content: "没有可用的 Codex 主动重置次数。",
77
+ template: "red",
78
+ };
79
+ }
80
+ return {
81
+ content: "这次 Codex 主动重置请求已经处理过。",
82
+ template: "yellow",
83
+ };
84
+ }
85
+
86
+ async function sendResetRequestConfirmation(
87
+ raw: Record<string, unknown>,
88
+ value: Record<string, unknown>,
89
+ deps: CodexResetActionDeps,
90
+ ): Promise<boolean> {
91
+ const parentMessageId = eventMessageId(raw);
92
+ const chatId = eventChatId(raw);
93
+ if (!parentMessageId || !chatId) return true;
94
+ const availableCount = Number(value.availableCount);
95
+
96
+ const requestId = (deps.createRequestId ?? randomUUID)();
97
+ pendingCodexResetRequests.set(requestId, {
98
+ chatId,
99
+ parentMessageId,
100
+ status: "pending",
101
+ });
102
+
103
+ const token = await deps.getTenantAccessToken();
104
+ await deps.sendRawCard(token, chatId, buildCodexResetConfirmCard({
105
+ availableCount: Number.isFinite(availableCount) ? Math.max(1, Math.trunc(availableCount)) : 1,
106
+ parentMessageId,
107
+ requestId,
108
+ }));
109
+ return true;
110
+ }
111
+
112
+ async function handleResetConfirmation(
113
+ raw: Record<string, unknown>,
114
+ value: Record<string, unknown>,
115
+ deps: CodexResetActionDeps,
116
+ ): Promise<boolean> {
117
+ const decision = value.decision;
118
+ const requestId = value.requestId;
119
+ const parentMessageId = value.parentMessageId;
120
+ if ((decision !== "yes" && decision !== "no") || typeof requestId !== "string" || typeof parentMessageId !== "string") {
121
+ return true;
122
+ }
123
+
124
+ const token = await deps.getTenantAccessToken();
125
+ const confirmMessageId = eventMessageId(raw);
126
+ if (confirmMessageId) {
127
+ await deps.updateCardMessage(token, confirmMessageId, collapsedCard());
128
+ }
129
+
130
+ const pending = pendingCodexResetRequests.get(requestId);
131
+ const chatId = pending?.chatId ?? eventChatId(raw);
132
+ if (!chatId) return true;
133
+
134
+ if (!pending || pending.status !== "pending" || pending.parentMessageId !== parentMessageId) {
135
+ await deps.sendCardReply(token, chatId, "Codex 主动重置", "这张确认卡片已经失效或已处理。", "yellow");
136
+ return true;
137
+ }
138
+ pending.status = "handled";
139
+
140
+ if (decision === "no") {
141
+ await deps.sendCardReply(token, chatId, "Codex 主动重置", "用户取消了重置。", "grey");
142
+ return true;
143
+ }
144
+
145
+ try {
146
+ const result = await deps.consumeCodexRateLimitResetCredit(requestId);
147
+ const message = resetResultMessage(result);
148
+ await deps.sendCardReply(token, chatId, "Codex 主动重置", message.content, message.template);
149
+ } catch (err) {
150
+ await deps.sendCardReply(token, chatId, "Codex 主动重置", `重置失败:${(err as Error).message}`, "red");
151
+ }
152
+ return true;
153
+ }
154
+
155
+ export async function handleCodexResetCardAction(data: unknown, deps: CodexResetActionDeps): Promise<boolean> {
156
+ const raw = rawEvent(data);
157
+ const value = actionValue(raw);
158
+ if (!value) return false;
159
+ const action = value?.action;
160
+ if (action === "codex_reset_request") {
161
+ return sendResetRequestConfirmation(raw, value, deps);
162
+ }
163
+ if (action === "codex_reset_confirm") {
164
+ return handleResetConfirmation(raw, value, deps);
165
+ }
166
+ return false;
167
+ }
168
+
169
+ export function _resetCodexResetRequestsForTest(): void {
170
+ pendingCodexResetRequests.clear();
171
+ }
package/src/feishu-api.ts CHANGED
@@ -304,6 +304,8 @@ const AVATAR_COMBINATIONS_DIR = resolvePath(AVATAR_DIR, "combinations");
304
304
  const AVATAR_KEY_CACHE_FILE = resolvePath(USER_DATA_DIR, "state", "avatar-image-keys.json");
305
305
  const CODEX_AUTH_FILE = resolvePath(homedir(), ".codex", "auth.json");
306
306
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
307
+ const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
308
+ const CODEX_RESET_CONSUME_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
307
309
  const AVATAR_SOURCES: Record<string, string> = {
308
310
  new: resolvePath(AVATAR_DIR, "status_new.png"),
309
311
  busy: resolvePath(AVATAR_DIR, "status_busy.png"),
@@ -327,9 +329,23 @@ export interface CodexUsageBalance {
327
329
  resetAfterSeconds: number | null;
328
330
  }
329
331
 
332
+ export interface CodexRateLimitResetCredit {
333
+ grantedAt: string | null;
334
+ expiresAt: string;
335
+ }
336
+
330
337
  export interface CodexUsageSummary {
331
338
  fiveHour: CodexUsageBalance;
332
339
  weekly: CodexUsageBalance | null;
340
+ rateLimitResetCreditsAvailable: number | null;
341
+ rateLimitResetCredits: CodexRateLimitResetCredit[] | null;
342
+ }
343
+
344
+ export type CodexResetConsumeCode = "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed";
345
+
346
+ export interface CodexResetConsumeResult {
347
+ code: CodexResetConsumeCode;
348
+ windowsReset: number;
333
349
  }
334
350
 
335
351
  const avatarKeyCache = new Map<string, string>();
@@ -421,35 +437,109 @@ function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: stri
421
437
  return null;
422
438
  }
423
439
 
424
- async function getCodexAccessToken(): Promise<string | null> {
440
+ function parseRateLimitResetCredits(data: Record<string, unknown>): number | null {
441
+ const raw = data.rate_limit_reset_credits;
442
+ if (!raw || typeof raw !== "object") return null;
443
+ const availableCount = Number((raw as { available_count?: unknown }).available_count);
444
+ if (!Number.isFinite(availableCount)) return null;
445
+ return Math.max(0, Math.trunc(availableCount));
446
+ }
447
+
448
+ interface CodexAuth {
449
+ accessToken: string;
450
+ accountId?: string;
451
+ }
452
+
453
+ async function getCodexAuth(): Promise<CodexAuth | null> {
425
454
  try {
426
455
  const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
427
- const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown } };
456
+ const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown; account_id?: unknown } };
428
457
  const token = parsed.tokens?.access_token;
429
- return typeof token === "string" && token.trim() ? token : null;
458
+ if (typeof token !== "string" || !token.trim()) return null;
459
+ const accountId = parsed.tokens?.account_id;
460
+ return {
461
+ accessToken: token,
462
+ accountId: typeof accountId === "string" && accountId.trim() ? accountId : undefined,
463
+ };
430
464
  } catch {
431
465
  return null;
432
466
  }
433
467
  }
434
468
 
469
+ async function getCodexAccessToken(): Promise<string | null> {
470
+ return (await getCodexAuth())?.accessToken ?? null;
471
+ }
472
+
473
+ function codexAuthHeaders(auth: CodexAuth): Record<string, string> {
474
+ const headers: Record<string, string> = {
475
+ Authorization: `Bearer ${auth.accessToken}`,
476
+ "OpenAI-Beta": "codex-1",
477
+ originator: "Codex Desktop",
478
+ };
479
+ if (auth.accountId) headers["ChatGPT-Account-ID"] = auth.accountId;
480
+ return headers;
481
+ }
482
+
483
+ function parseCodexResetCreditDetails(data: Record<string, unknown>): {
484
+ availableCount: number | null;
485
+ availableCredits: CodexRateLimitResetCredit[];
486
+ } {
487
+ const availableCount = Number(data.available_count);
488
+ const credits = Array.isArray(data.credits) ? data.credits : [];
489
+ return {
490
+ availableCount: Number.isFinite(availableCount) ? Math.max(0, Math.trunc(availableCount)) : null,
491
+ availableCredits: credits.flatMap((raw) => {
492
+ if (!raw || typeof raw !== "object") return [];
493
+ const credit = raw as { status?: unknown; granted_at?: unknown; expires_at?: unknown };
494
+ if (credit.status !== "available") return [];
495
+ if (typeof credit.expires_at !== "string" || !credit.expires_at.trim()) return [];
496
+ return [{
497
+ grantedAt: typeof credit.granted_at === "string" && credit.granted_at.trim() ? credit.granted_at : null,
498
+ expiresAt: credit.expires_at,
499
+ }];
500
+ }),
501
+ };
502
+ }
503
+
504
+ async function fetchCodexRateLimitResetCredits(auth: CodexAuth): Promise<{
505
+ availableCount: number | null;
506
+ availableCredits: CodexRateLimitResetCredit[];
507
+ } | null> {
508
+ try {
509
+ const resp = await fetch(CODEX_RESET_CREDITS_URL, {
510
+ headers: codexAuthHeaders(auth),
511
+ });
512
+ const text = await resp.text();
513
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
514
+ return parseCodexResetCreditDetails(JSON.parse(text) as Record<string, unknown>);
515
+ } catch (err) {
516
+ console.warn(`[Codex] reset credits lookup failed: ${(err as Error).message}`);
517
+ return null;
518
+ }
519
+ }
520
+
435
521
  export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
436
- const token = await getCodexAccessToken();
437
- if (!token) throw new Error("missing ~/.codex/auth.json access token");
522
+ const auth = await getCodexAuth();
523
+ if (!auth) throw new Error("missing ~/.codex/auth.json access token");
524
+
525
+ const resetCreditsPromise = fetchCodexRateLimitResetCredits(auth);
438
526
 
439
527
  const resp = await fetch(CODEX_USAGE_URL, {
440
- headers: { Authorization: `Bearer ${token}` },
528
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
441
529
  });
442
530
  const text = await resp.text();
443
531
  if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
444
532
 
445
533
  const data = JSON.parse(text) as {
446
534
  rate_limit?: Record<string, unknown>;
535
+ rate_limit_reset_credits?: unknown;
447
536
  };
448
537
  const rateLimit = data.rate_limit;
449
538
  if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
450
539
 
451
540
  const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
452
541
  if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
542
+ const resetCredits = await resetCreditsPromise;
453
543
 
454
544
  return {
455
545
  fiveHour,
@@ -459,6 +549,41 @@ export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
459
549
  "week_window",
460
550
  "long_window",
461
551
  ]),
552
+ rateLimitResetCreditsAvailable: resetCredits?.availableCount ?? parseRateLimitResetCredits(data),
553
+ rateLimitResetCredits: resetCredits?.availableCredits ?? null,
554
+ };
555
+ }
556
+
557
+ export async function consumeCodexRateLimitResetCredit(redeemRequestId: string): Promise<CodexResetConsumeResult> {
558
+ if (!redeemRequestId.trim()) throw new Error("missing redeem_request_id");
559
+ const token = await getCodexAccessToken();
560
+ if (!token) throw new Error("missing ~/.codex/auth.json access token");
561
+
562
+ const resp = await fetch(CODEX_RESET_CONSUME_URL, {
563
+ method: "POST",
564
+ headers: {
565
+ Authorization: `Bearer ${token}`,
566
+ "Content-Type": "application/json",
567
+ },
568
+ body: JSON.stringify({ redeem_request_id: redeemRequestId }),
569
+ });
570
+ const text = await resp.text();
571
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
572
+
573
+ const data = JSON.parse(text) as { code?: unknown; windows_reset?: unknown };
574
+ const code = data.code;
575
+ if (
576
+ code !== "reset"
577
+ && code !== "nothing_to_reset"
578
+ && code !== "no_credit"
579
+ && code !== "already_redeemed"
580
+ ) {
581
+ throw new Error("missing or unknown reset result code");
582
+ }
583
+ const windowsReset = Number(data.windows_reset);
584
+ return {
585
+ code,
586
+ windowsReset: Number.isFinite(windowsReset) ? Math.max(0, Math.trunc(windowsReset)) : 0,
462
587
  };
463
588
  }
464
589