quotacap 0.0.40 → 0.0.41

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.
@@ -5,25 +5,35 @@ const MONTHS = {
5
5
  jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
6
6
  jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
7
7
  };
8
+ // Codex 0.157 prints "9:14 AM on 30 Sep" and "9:43 PM". Older panels print
9
+ // "23:04 on 19 Sep" and "19:38". 12 AM is 00:00 and 12 PM is 12:00.
10
+ function clockHours(hours, mins, ampm) {
11
+ if (!Number.isFinite(hours) || !Number.isFinite(mins) || mins < 0 || mins > 59)
12
+ return null;
13
+ if (!ampm)
14
+ return hours >= 0 && hours <= 23 ? hours : null;
15
+ if (hours < 1 || hours > 12)
16
+ return null;
17
+ const pm = ampm.toLowerCase() === "pm";
18
+ if (hours === 12)
19
+ return pm ? 12 : 0;
20
+ return pm ? hours + 12 : hours;
21
+ }
8
22
  function parseWeeklyReset(raw, now) {
9
- const m = raw.trim().match(/^(\d{1,2}):(\d{2})\s+on\s+(\d{1,2})\s+([A-Za-z]{3,9})$/);
23
+ const m = raw.trim().match(/^(\d{1,2}):(\d{2})(?:\s*([AaPp][Mm]))?\s+on\s+(\d{1,2})\s+([A-Za-z]{3,9})$/);
10
24
  if (!m)
11
25
  return null;
12
- const hours = parseInt(m[1], 10);
13
- const mins = parseInt(m[2], 10);
14
- const day = parseInt(m[3], 10);
15
- const monStr = m[4].slice(0, 3).toLowerCase();
16
- const month = MONTHS[monStr];
17
- if (month === undefined || !Number.isFinite(hours) || !Number.isFinite(mins) || !Number.isFinite(day))
18
- return null;
19
- if (hours < 0 || hours > 23 || mins < 0 || mins > 59 || day < 1 || day > 31)
26
+ const hours = clockHours(parseInt(m[1], 10), parseInt(m[2], 10), m[3]);
27
+ const day = parseInt(m[4], 10);
28
+ const month = MONTHS[m[5].slice(0, 3).toLowerCase()];
29
+ if (hours === null || month === undefined || !Number.isFinite(day) || day < 1 || day > 31)
20
30
  return null;
21
31
  let year = now.getFullYear();
22
- let dt = new Date(year, month, day, hours, mins, 0, 0);
32
+ let dt = new Date(year, month, day, hours, parseInt(m[2], 10), 0, 0);
23
33
  if (Number.isNaN(dt.getTime()))
24
34
  return null;
25
35
  if (dt.getTime() < now.getTime()) {
26
- dt = new Date(year + 1, month, day, hours, mins, 0, 0);
36
+ dt = new Date(year + 1, month, day, hours, parseInt(m[2], 10), 0, 0);
27
37
  if (Number.isNaN(dt.getTime()))
28
38
  return null;
29
39
  }
@@ -32,19 +42,18 @@ function parseWeeklyReset(raw, now) {
32
42
  return dt.toISOString();
33
43
  }
34
44
  function parseFiveReset(raw, now) {
35
- // The 5h reset is bare ("19:38") when it falls today and date-qualified
36
- // ("02:43 on 15 Sep", weekly shape) otherwise; accept both.
45
+ // The 5h reset is bare ("19:38" or "9:43 PM") when it falls today and
46
+ // date-qualified ("02:43 on 15 Sep", weekly shape) otherwise; accept both.
37
47
  const qualified = parseWeeklyReset(raw, now);
38
48
  if (qualified)
39
49
  return qualified;
40
- const m = raw.trim().match(/^(\d{1,2}):(\d{2})$/);
50
+ const m = raw.trim().match(/^(\d{1,2}):(\d{2})(?:\s*([AaPp][Mm]))?$/);
41
51
  if (!m)
42
52
  return null;
43
- const hours = parseInt(m[1], 10);
44
- const mins = parseInt(m[2], 10);
45
- if (hours < 0 || hours > 23 || mins < 0 || mins > 59)
53
+ const hours = clockHours(parseInt(m[1], 10), parseInt(m[2], 10), m[3]);
54
+ if (hours === null)
46
55
  return null;
47
- let dt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, mins, 0, 0);
56
+ let dt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, parseInt(m[2], 10), 0, 0);
48
57
  if (dt.getTime() < now.getTime()) {
49
58
  dt = new Date(dt.getTime() + 86400000);
50
59
  }
@@ -1,5 +1,46 @@
1
1
  import type { ParsedQuota } from "./types.js";
2
+ export declare function kimiProbeDir(): string;
2
3
  export declare function parseKimiTui(text: string, now?: Date): ParsedQuota;
4
+ export declare function parseKimiApiUsage(usagesBody: unknown, meBody?: unknown, now?: Date): ParsedQuota;
5
+ export interface KimiStoredCreds {
6
+ access_token?: string;
7
+ refresh_token?: string;
8
+ expires_at?: number;
9
+ token_type?: string;
10
+ scope?: string;
11
+ }
12
+ export interface KimiAuthContext {
13
+ token: string;
14
+ credPath?: string;
15
+ creds?: KimiStoredCreds;
16
+ deviceId?: string;
17
+ oauthHost: string;
18
+ baseUrl: string;
19
+ }
20
+ export interface KimiProviderConfig {
21
+ baseUrl?: string;
22
+ oauthHost?: string;
23
+ key?: string;
24
+ }
25
+ /**
26
+ * Reads the Kimi provider's own entries from config.toml. Only keys inside
27
+ * `[providers."managed:kimi-code"]` and its `.oauth` sub-table count; comments
28
+ * and every other table are ignored.
29
+ */
30
+ export declare function readKimiProviderConfig(content: string): KimiProviderConfig;
31
+ export declare function resolveKimiAuth(env?: Record<string, string | undefined>, customKimiDir?: string): KimiAuthContext | null;
32
+ export declare function refreshKimiOAuthToken(oauthHost: string, refreshToken: string, deviceId?: string, signal?: AbortSignal): Promise<{
33
+ access_token: string;
34
+ refresh_token?: string;
35
+ expires_in?: number;
36
+ }>;
37
+ /** A refresh rotated the tokens but the result could not be saved for the Kimi CLI. */
38
+ export declare class KimiCredentialWriteError extends Error {
39
+ constructor(credPath: string, cause: unknown);
40
+ }
41
+ export declare function ensureFreshKimiToken(ctx: KimiAuthContext, signal?: AbortSignal): Promise<string>;
42
+ export declare function pollKimiApi(ctx: KimiAuthContext): Promise<ParsedQuota>;
43
+ export declare function pollKimiPty(): Promise<ParsedQuota>;
3
44
  export declare const kimiAdapter: {
4
45
  id: string;
5
46
  requiresAuth: string;
@@ -1,7 +1,16 @@
1
+ import fs from "node:fs";
1
2
  import os from "node:os";
3
+ import path from "node:path";
2
4
  import { parseResetText } from "./parse.js";
3
5
  import { runPty, stripAnsi } from "./pty.js";
4
6
  import { adapterSignal } from "../runtime/spawn.js";
7
+ const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
8
+ const DEFAULT_OAUTH_HOST = "https://auth.kimi.ai";
9
+ const DEFAULT_BASE_URL = "https://api.kimi.ai/coding/v1";
10
+ const POLL_TIMEOUT_MS = 8000;
11
+ export function kimiProbeDir() {
12
+ return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "kimi-probe");
13
+ }
5
14
  export function parseKimiTui(text, now = new Date()) {
6
15
  const cleaned = stripAnsi(text);
7
16
  const weeklyRe = /Weekly limit\s+[^0-9]*(\d+)%\s+used\s+resets\s+(in\s+[^\n│\r]+)/i;
@@ -47,6 +56,28 @@ export function parseKimiTui(text, now = new Date()) {
47
56
  if (lvl)
48
57
  plan = lvl[1].trim().toLowerCase().replace(/^level_/i, "");
49
58
  }
59
+ // Optional monthly window
60
+ const m = cleaned.match(/Monthly limit[^\d%]*(\d+)%\s+used(?:\s+resets\s+(in\s+[^\n│\r]+|[^\n│\r]+))?/i);
61
+ let monthlyFields = {};
62
+ if (m) {
63
+ const monthlyPct = parseInt(m[1], 10);
64
+ if (Number.isFinite(monthlyPct) && monthlyPct >= 0) {
65
+ const exhausted = monthlyPct >= 100;
66
+ const rawReset = m[2]?.trim();
67
+ const textToParse = rawReset
68
+ ? rawReset.startsWith("in ")
69
+ ? `resets ${rawReset}`
70
+ : `resets in ${rawReset}`
71
+ : "";
72
+ const resetIso = textToParse ? (parseResetText(textToParse, now) ?? undefined) : undefined;
73
+ monthlyFields = {
74
+ monthlyKind: "included",
75
+ monthlyStatus: exhausted ? "exhausted" : "ok",
76
+ monthlyPct: exhausted ? 100 : Math.min(100, monthlyPct),
77
+ monthlyResetsAt: resetIso,
78
+ };
79
+ }
80
+ }
50
81
  const periodStart = new Date(new Date(weeklyIso).getTime() - 7 * 86400000).toISOString();
51
82
  return {
52
83
  provider: "kimi",
@@ -58,30 +89,431 @@ export function parseKimiTui(text, now = new Date()) {
58
89
  source: "tui",
59
90
  fetchedAt: now.toISOString(),
60
91
  raw: cleaned.slice(0, 4096),
92
+ ...monthlyFields,
93
+ };
94
+ }
95
+ // Exhaustion comes from the raw ratio; only the displayed pct is rounded, and
96
+ // capped at 99 below full so a rounded 100 never reads as an empty window.
97
+ // An unreadable reset stays undefined: the monthly window is independent of
98
+ // the weekly one, so the weekly reset is no stand-in for it.
99
+ function monthlyFromRatio(ratio, resetRaw) {
100
+ const exhausted = ratio >= 1;
101
+ const resetsMs = Date.parse(String(resetRaw ?? ""));
102
+ return {
103
+ monthlyKind: "included",
104
+ monthlyStatus: exhausted ? "exhausted" : "ok",
105
+ monthlyPct: exhausted ? 100 : Math.min(99, Math.max(0, Math.round(ratio * 100))),
106
+ monthlyResetsAt: Number.isFinite(resetsMs) ? new Date(resetsMs).toISOString() : undefined,
61
107
  };
62
108
  }
109
+ function parseKimiApiMonthly(u, legacyUsages) {
110
+ // 1. Check limits array for an explicit monthly duration (>= 28 days)
111
+ if (Array.isArray(u.limits)) {
112
+ for (const item of u.limits) {
113
+ if (item && typeof item === "object") {
114
+ const win = item.window;
115
+ const unit = typeof win?.timeUnit === "string" ? win.timeUnit : "";
116
+ const dur = Number(win?.duration);
117
+ const isMonthly = unit === "TIME_UNIT_MONTH" ||
118
+ (unit === "TIME_UNIT_DAY" && dur >= 28) ||
119
+ (unit === "TIME_UNIT_MINUTE" && dur >= 40320);
120
+ if (isMonthly) {
121
+ const detail = item.detail;
122
+ if (detail && typeof detail === "object") {
123
+ const used = Number(detail.used);
124
+ const limit = Number(detail.limit);
125
+ if (Number.isFinite(used) && Number.isFinite(limit) && limit > 0) {
126
+ return monthlyFromRatio(used / limit, detail.resetTime);
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ // 2. Check usages.limit_month_total (Moonshot CLI schema)
134
+ if (legacyUsages && typeof legacyUsages === "object") {
135
+ const lMonth = legacyUsages.limit_month_total;
136
+ if (lMonth && typeof lMonth === "object") {
137
+ const ratio = Number(lMonth.used_ratio);
138
+ if (Number.isFinite(ratio) && ratio >= 0) {
139
+ return monthlyFromRatio(ratio, lMonth.reset_time);
140
+ }
141
+ }
142
+ }
143
+ // 3. Check subscriptionBalance / subscription_balance (Moonshot web gateway schema)
144
+ const subBal = (u.subscriptionBalance ?? u.subscription_balance);
145
+ if (subBal && typeof subBal === "object") {
146
+ const ratio = Number(subBal.amountUsedRatio ?? subBal.amount_used_ratio);
147
+ if (Number.isFinite(ratio) && ratio >= 0) {
148
+ return monthlyFromRatio(ratio, subBal.expireTime ?? subBal.expire_time);
149
+ }
150
+ }
151
+ return {};
152
+ }
153
+ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
154
+ if (typeof usagesBody !== "object" || usagesBody === null) {
155
+ throw new Error("kimi: invalid api response");
156
+ }
157
+ const u = usagesBody;
158
+ // 1. Weekly window (from usage object)
159
+ let weeklyPct = null;
160
+ let resetsAt = null;
161
+ const usage = u.usage;
162
+ if (usage && typeof usage === "object") {
163
+ const used = Number(usage.used);
164
+ const limit = Number(usage.limit);
165
+ if (Number.isFinite(used) && Number.isFinite(limit) && limit > 0) {
166
+ weeklyPct = Math.round((used / limit) * 100);
167
+ }
168
+ if (typeof usage.resetTime === "string" && usage.resetTime.length > 0) {
169
+ const parsed = Date.parse(usage.resetTime);
170
+ if (Number.isFinite(parsed))
171
+ resetsAt = new Date(parsed).toISOString();
172
+ }
173
+ }
174
+ // 2. 5-hour window (from limits array)
175
+ let sessionPct = null;
176
+ if (Array.isArray(u.limits)) {
177
+ for (const item of u.limits) {
178
+ if (item && typeof item === "object") {
179
+ const detail = item.detail;
180
+ if (detail && typeof detail === "object") {
181
+ const used = Number(detail.used);
182
+ const limit = Number(detail.limit);
183
+ if (Number.isFinite(used) && Number.isFinite(limit) && limit > 0) {
184
+ sessionPct = Math.round((used / limit) * 100);
185
+ break;
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ // Fallback to legacy usages object if limits/usage were omitted
192
+ const legacyUsages = u.usages;
193
+ if (legacyUsages && typeof legacyUsages === "object") {
194
+ if (weeklyPct === null) {
195
+ const l7d = legacyUsages.limit_7d;
196
+ if (l7d && typeof l7d.used_ratio === "number") {
197
+ weeklyPct = Math.round(l7d.used_ratio * 100);
198
+ }
199
+ if (l7d && typeof l7d.reset_time === "string" && !resetsAt) {
200
+ const parsed = Date.parse(l7d.reset_time);
201
+ if (Number.isFinite(parsed))
202
+ resetsAt = new Date(parsed).toISOString();
203
+ }
204
+ }
205
+ if (sessionPct === null) {
206
+ const l5h = legacyUsages.limit_5h;
207
+ if (l5h && typeof l5h.used_ratio === "number") {
208
+ sessionPct = Math.round(l5h.used_ratio * 100);
209
+ }
210
+ }
211
+ }
212
+ if (weeklyPct === null || weeklyPct < 0 || weeklyPct > 100) {
213
+ throw new Error("kimi: weekly pct not found in api response");
214
+ }
215
+ if (sessionPct === null || sessionPct < 0 || sessionPct > 100) {
216
+ throw new Error("kimi: 5h pct not found in api response");
217
+ }
218
+ if (!resetsAt || Number.isNaN(new Date(resetsAt).getTime())) {
219
+ throw new Error("kimi: weekly reset not found in api response");
220
+ }
221
+ // 3. Plan identity (from meBody)
222
+ let plan = "unknown";
223
+ if (meBody && typeof meBody === "object") {
224
+ const me = meBody;
225
+ if (typeof me.user_level_name === "string" && me.user_level_name.trim().length > 0) {
226
+ plan = me.user_level_name.trim().toLowerCase();
227
+ }
228
+ }
229
+ // 4. Optional monthly quota (from limit_month_total, limits, or subscriptionBalance)
230
+ const monthly = parseKimiApiMonthly(u, legacyUsages);
231
+ const periodStart = new Date(new Date(resetsAt).getTime() - 7 * 86400000).toISOString();
232
+ return {
233
+ provider: "kimi",
234
+ plan,
235
+ weeklyPct,
236
+ fiveHourPct: sessionPct,
237
+ resetsAt,
238
+ periodStart,
239
+ source: "api",
240
+ fetchedAt: now.toISOString(),
241
+ ...monthly,
242
+ };
243
+ }
244
+ const KIMI_PROVIDER_TABLE = 'providers."managed:kimi-code"';
245
+ /**
246
+ * Reads the Kimi provider's own entries from config.toml. Only keys inside
247
+ * `[providers."managed:kimi-code"]` and its `.oauth` sub-table count; comments
248
+ * and every other table are ignored.
249
+ */
250
+ export function readKimiProviderConfig(content) {
251
+ const out = {};
252
+ let table = "";
253
+ for (const raw of content.split(/\r?\n/)) {
254
+ const line = raw.trim();
255
+ if (!line || line.startsWith("#"))
256
+ continue;
257
+ if (line.startsWith("[")) {
258
+ const header = line.match(/^\[\s*([^[\]]+?)\s*\]\s*(?:#.*)?$/);
259
+ table = header ? header[1] : "";
260
+ continue;
261
+ }
262
+ const kv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*(?:#.*)?$/);
263
+ if (!kv || !kv[2].trim())
264
+ continue;
265
+ const [, k, v] = kv;
266
+ if (table === KIMI_PROVIDER_TABLE && k === "base_url")
267
+ out.baseUrl = v.trim();
268
+ if (table === `${KIMI_PROVIDER_TABLE}.oauth` && k === "oauth_host")
269
+ out.oauthHost = v.trim();
270
+ if (table === `${KIMI_PROVIDER_TABLE}.oauth` && k === "key")
271
+ out.key = v.trim();
272
+ }
273
+ return out;
274
+ }
275
+ export function resolveKimiAuth(env = process.env, customKimiDir) {
276
+ const fromEnv = env.KIMI_CODE_API_KEY || env.KIMI_API_KEY;
277
+ if (fromEnv && fromEnv.trim()) {
278
+ return {
279
+ token: fromEnv.trim(),
280
+ oauthHost: env.KIMI_CODE_OAUTH_HOST ?? DEFAULT_OAUTH_HOST,
281
+ baseUrl: env.KIMI_CODE_BASE_URL ?? DEFAULT_BASE_URL,
282
+ };
283
+ }
284
+ const kimiDir = customKimiDir ?? path.join(os.homedir(), ".kimi-code");
285
+ let deviceId = "";
286
+ try {
287
+ deviceId = fs.readFileSync(path.join(kimiDir, "device_id"), "utf8").trim();
288
+ }
289
+ catch { }
290
+ let config = {};
291
+ try {
292
+ config = readKimiProviderConfig(fs.readFileSync(path.join(kimiDir, "config.toml"), "utf8"));
293
+ }
294
+ catch { }
295
+ // Explicit environment overrides win; otherwise only the Kimi provider's own
296
+ // table supplies hosts, so a token is never paired with another provider's URL.
297
+ const oauthHost = env.KIMI_CODE_OAUTH_HOST ?? config.oauthHost ?? DEFAULT_OAUTH_HOST;
298
+ const baseUrl = env.KIMI_CODE_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL;
299
+ let credPath = null;
300
+ if (config.key) {
301
+ // The config names its credential: use exactly that file or nothing.
302
+ const candidate = path.join(kimiDir, "credentials", `${path.basename(config.key)}.json`);
303
+ if (!fs.existsSync(candidate))
304
+ return null;
305
+ credPath = candidate;
306
+ }
307
+ else {
308
+ // No reference in config: scan credentials directory for newest .json file
309
+ const credsDir = path.join(kimiDir, "credentials");
310
+ try {
311
+ const files = fs.readdirSync(credsDir).filter((f) => f.endsWith(".json"));
312
+ if (files.length > 0) {
313
+ files.sort((a, b) => {
314
+ const sA = fs.statSync(path.join(credsDir, a)).mtimeMs;
315
+ const sB = fs.statSync(path.join(credsDir, b)).mtimeMs;
316
+ return sB - sA;
317
+ });
318
+ credPath = path.join(credsDir, files[0]);
319
+ }
320
+ }
321
+ catch { }
322
+ }
323
+ if (!credPath)
324
+ return null;
325
+ try {
326
+ const creds = JSON.parse(fs.readFileSync(credPath, "utf8"));
327
+ const hasToken = (typeof creds.access_token === "string" && creds.access_token.trim().length > 0) ||
328
+ (typeof creds.refresh_token === "string" && creds.refresh_token.trim().length > 0);
329
+ if (hasToken) {
330
+ return {
331
+ token: creds.access_token?.trim() ?? "",
332
+ credPath,
333
+ creds,
334
+ deviceId: deviceId || undefined,
335
+ oauthHost,
336
+ baseUrl,
337
+ };
338
+ }
339
+ }
340
+ catch { }
341
+ return null;
342
+ }
343
+ export async function refreshKimiOAuthToken(oauthHost, refreshToken, deviceId, signal) {
344
+ const url = `${oauthHost.replace(/\/+$/, "")}/api/oauth/token`;
345
+ const body = new URLSearchParams({
346
+ client_id: CLIENT_ID,
347
+ grant_type: "refresh_token",
348
+ refresh_token: refreshToken,
349
+ });
350
+ const headers = {
351
+ "Content-Type": "application/x-www-form-urlencoded",
352
+ Accept: "application/json",
353
+ "User-Agent": "kimi-code-cli/2.0.2",
354
+ "X-Msh-Version": "2.0.2",
355
+ };
356
+ if (deviceId)
357
+ headers["X-Msh-Device-Id"] = deviceId;
358
+ const res = await fetch(url, {
359
+ method: "POST",
360
+ headers,
361
+ body: body.toString(),
362
+ signal,
363
+ });
364
+ if (!res.ok) {
365
+ throw new Error(`kimi: token refresh failed (HTTP ${res.status})`);
366
+ }
367
+ const data = (await res.json());
368
+ if (typeof data.access_token !== "string" || !data.access_token.trim()) {
369
+ throw new Error("kimi: refresh response missing access_token");
370
+ }
371
+ return {
372
+ access_token: data.access_token.trim(),
373
+ refresh_token: typeof data.refresh_token === "string" && data.refresh_token.trim() ? data.refresh_token.trim() : undefined,
374
+ expires_in: typeof data.expires_in === "number" ? data.expires_in : undefined,
375
+ };
376
+ }
377
+ /** A refresh rotated the tokens but the result could not be saved for the Kimi CLI. */
378
+ export class KimiCredentialWriteError extends Error {
379
+ constructor(credPath, cause) {
380
+ super(`kimi: refreshed token could not be saved to ${credPath} (${cause?.message ?? cause}); run \`kimi login\``);
381
+ this.name = "KimiCredentialWriteError";
382
+ }
383
+ }
384
+ function readCredsFile(credPath) {
385
+ try {
386
+ const raw = fs.readFileSync(credPath, "utf8");
387
+ return { raw, creds: JSON.parse(raw) };
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ }
393
+ export async function ensureFreshKimiToken(ctx, signal) {
394
+ if (!ctx.credPath || !ctx.creds)
395
+ return ctx.token;
396
+ const nowSec = Math.floor(Date.now() / 1000);
397
+ const isFresh = (c) => !!c.access_token && (c.expires_at ?? 0) > nowSec + 60;
398
+ if (isFresh(ctx.creds)) {
399
+ return ctx.creds.access_token;
400
+ }
401
+ // The Kimi CLI owns this file: re-read it, and adopt a token it refreshed since we loaded it.
402
+ const before = readCredsFile(ctx.credPath);
403
+ if (before && before.creds.access_token !== ctx.creds.access_token && isFresh(before.creds)) {
404
+ ctx.creds = before.creds;
405
+ ctx.token = before.creds.access_token;
406
+ return ctx.token;
407
+ }
408
+ const creds = before?.creds ?? ctx.creds;
409
+ if (!creds.refresh_token) {
410
+ if (creds.access_token)
411
+ return creds.access_token;
412
+ throw new Error("kimi: missing refresh_token");
413
+ }
414
+ const refreshed = await refreshKimiOAuthToken(ctx.oauthHost, creds.refresh_token, ctx.deviceId, signal);
415
+ const next = {
416
+ ...creds,
417
+ access_token: refreshed.access_token,
418
+ refresh_token: refreshed.refresh_token ?? creds.refresh_token,
419
+ expires_at: nowSec + (refreshed.expires_in ?? 900),
420
+ };
421
+ ctx.creds = next;
422
+ ctx.token = refreshed.access_token;
423
+ // Another writer changed the file during the refresh: keep its copy rather than overwrite it.
424
+ if (readCredsFile(ctx.credPath)?.raw !== before?.raw)
425
+ return ctx.token;
426
+ // Atomic replace: write a private temp file beside the original, then rename over it.
427
+ const tmp = `${ctx.credPath}.quotacap-${process.pid}.tmp`;
428
+ try {
429
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
430
+ fs.renameSync(tmp, ctx.credPath);
431
+ }
432
+ catch (e) {
433
+ try {
434
+ fs.rmSync(tmp, { force: true });
435
+ }
436
+ catch { }
437
+ throw new KimiCredentialWriteError(ctx.credPath, e);
438
+ }
439
+ return ctx.token;
440
+ }
441
+ export async function pollKimiApi(ctx) {
442
+ const adapter = adapterSignal("kimi");
443
+ const timeout = AbortSignal.timeout(POLL_TIMEOUT_MS);
444
+ const signal = adapter ? AbortSignal.any([adapter, timeout]) : timeout;
445
+ const token = await ensureFreshKimiToken(ctx, signal);
446
+ const headers = {
447
+ Authorization: `Bearer ${token}`,
448
+ Accept: "application/json",
449
+ "User-Agent": "kimi-code-cli/2.0.2",
450
+ "X-Msh-Version": "2.0.2",
451
+ };
452
+ if (ctx.deviceId)
453
+ headers["X-Msh-Device-Id"] = ctx.deviceId;
454
+ const usagesUrl = `${ctx.baseUrl.replace(/\/+$/, "")}/usages`;
455
+ let usagesRes = await fetch(usagesUrl, { headers, signal });
456
+ // On 401, attempt one forced refresh if refresh_token is available
457
+ if (usagesRes.status === 401 && ctx.credPath && ctx.creds?.refresh_token) {
458
+ ctx.creds.expires_at = 0;
459
+ const freshToken = await ensureFreshKimiToken(ctx, signal);
460
+ headers.Authorization = `Bearer ${freshToken}`;
461
+ usagesRes = await fetch(usagesUrl, { headers, signal });
462
+ }
463
+ if (!usagesRes.ok) {
464
+ throw new Error(`kimi: usage request failed (HTTP ${usagesRes.status})`);
465
+ }
466
+ const usagesBody = await usagesRes.json();
467
+ let meBody = undefined;
468
+ try {
469
+ const meUrl = `${ctx.baseUrl.replace(/\/+$/, "")}/me`;
470
+ const meRes = await fetch(meUrl, { headers, signal });
471
+ if (meRes.ok) {
472
+ meBody = await meRes.json();
473
+ }
474
+ }
475
+ catch { }
476
+ return parseKimiApiUsage(usagesBody, meBody);
477
+ }
478
+ export async function pollKimiPty() {
479
+ const cwd = kimiProbeDir();
480
+ fs.mkdirSync(cwd, { recursive: true });
481
+ const transcript = await runPty({
482
+ file: "kimi",
483
+ args: [],
484
+ cols: 140,
485
+ rows: 35,
486
+ cwd,
487
+ readyRegex: /Welcome to Kimi Code|context:/i,
488
+ readyTimeoutMs: 8000,
489
+ preReadyResponse: {
490
+ match: /Trust this folder\?/i,
491
+ reply: "\r",
492
+ },
493
+ input: "/usage\r",
494
+ completionRegex: /Weekly limit[\s\S]*?5h limit|5h limit[\s\S]*?Weekly limit/i,
495
+ timeoutMs: 8000,
496
+ maxBytes: 256 * 1024,
497
+ signal: adapterSignal("kimi"),
498
+ label: "kimi",
499
+ });
500
+ return parseKimiTui(transcript);
501
+ }
63
502
  export const kimiAdapter = {
64
503
  id: "kimi",
65
504
  requiresAuth: "kimi login (CLI owns credentials)",
66
505
  async poll() {
67
- // Use homedir as neutral cwd so quota modal does not depend on project path
68
- // and workspace-trust prompts are minimized. Fail closed if trust prompt appears.
69
- const transcript = await runPty({
70
- file: "kimi",
71
- args: [],
72
- cols: 140,
73
- rows: 35,
74
- cwd: os.homedir(),
75
- readyRegex: /Welcome to Kimi Code|context:/i,
76
- readyTimeoutMs: 8000,
77
- input: "/usage\r",
78
- completionRegex: /Weekly limit[\s\S]*?5h limit|5h limit[\s\S]*?Weekly limit/i,
79
- abortOn: /Trust this folder\?/i,
80
- timeoutMs: 8000,
81
- maxBytes: 256 * 1024,
82
- signal: adapterSignal("kimi"),
83
- label: "kimi",
84
- });
85
- return parseKimiTui(transcript);
506
+ const authCtx = resolveKimiAuth();
507
+ if (authCtx) {
508
+ try {
509
+ return await pollKimiApi(authCtx);
510
+ }
511
+ catch (e) {
512
+ // A lost rotated token leaves the CLI logged out: report it, do not mask it with the PTY path.
513
+ if (e instanceof KimiCredentialWriteError)
514
+ throw e;
515
+ }
516
+ }
517
+ return pollKimiPty();
86
518
  },
87
519
  };
@@ -177,7 +177,7 @@ export const museAdapter = {
177
177
  unavailable: e,
178
178
  usagePass: (timeoutMs) => usagePass(timeoutMs),
179
179
  warmTurn: async (timeoutMs) => {
180
- await trackedExecFile("muse", "muse", ["exec", "hi"], {
180
+ await trackedExecFile("muse", "muse", ["exec", "1+1"], {
181
181
  cwd: museProbeDir(),
182
182
  env: { ...process.env, MUSE_NO_AUTO_UPDATE: "1" },
183
183
  timeout: timeoutMs,
@@ -29,6 +29,15 @@ export interface PtyRunOptions {
29
29
  */
30
30
  respondToQueries?: boolean;
31
31
  completionRegex?: RegExp;
32
+ /**
33
+ * Optional initial prompt responder before readyRegex matches.
34
+ * If matched against the cleaned transcript during the pre-ready poll loop,
35
+ * sends `reply` once to dismiss or answer the prompt (e.g. workspace trust in a probe directory).
36
+ */
37
+ preReadyResponse?: {
38
+ match: RegExp;
39
+ reply: string;
40
+ };
32
41
  /** If matched, abort immediately with a clear error (e.g. trust prompts). */
33
42
  abortOn?: RegExp;
34
43
  timeoutMs: number;
@@ -329,6 +329,7 @@ async function runPtyBun(opts) {
329
329
  if (opts.readyRegex) {
330
330
  const deadline = Date.now() + readyTimeoutMs;
331
331
  let matched = false;
332
+ let preReadySent = false;
332
333
  while (Date.now() < deadline) {
333
334
  checkCap();
334
335
  checkAborted();
@@ -341,6 +342,13 @@ async function runPtyBun(opts) {
341
342
  });
342
343
  }
343
344
  const clean = stripAnsi(transcript);
345
+ if (opts.preReadyResponse && !preReadySent && regexTest(opts.preReadyResponse.match, clean)) {
346
+ preReadySent = true;
347
+ try {
348
+ proc.terminal.write(opts.preReadyResponse.reply);
349
+ }
350
+ catch { }
351
+ }
344
352
  checkAbort(clean);
345
353
  if (regexTest(opts.readyRegex, clean)) {
346
354
  matched = true;
@@ -633,6 +641,7 @@ async function runPtyNode(opts) {
633
641
  if (opts.readyRegex) {
634
642
  const deadline = Date.now() + readyTimeoutMs;
635
643
  let matched = false;
644
+ let preReadySent = false;
636
645
  while (Date.now() < deadline) {
637
646
  checkCap();
638
647
  checkAborted();
@@ -645,6 +654,13 @@ async function runPtyNode(opts) {
645
654
  });
646
655
  }
647
656
  const clean = stripAnsi(transcript);
657
+ if (opts.preReadyResponse && !preReadySent && regexTest(opts.preReadyResponse.match, clean)) {
658
+ preReadySent = true;
659
+ try {
660
+ ptyProcess.write(opts.preReadyResponse.reply);
661
+ }
662
+ catch { }
663
+ }
648
664
  checkAbort(clean);
649
665
  if (regexTest(opts.readyRegex, clean)) {
650
666
  matched = true;