creditdaddy 1.5.2 → 1.6.2

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/providers.js CHANGED
@@ -10,12 +10,13 @@
10
10
 
11
11
  import { productOf } from './constants.js';
12
12
  import { checkinOne as checkinQoder, fetchQuotaUsage, fetchUserinfo, fetchUsageDetail } from './qoderClient.js';
13
- import { checkinWorkbuddy, checkinWorkbuddyIntl, fetchWorkbuddyQuota, inspectToken } from './workbuddyClient.js';
13
+ import { checkinWorkbuddy, fetchWorkbuddyQuota, inspectToken } from './workbuddyClient.js';
14
14
  import { fetchZcodeQuota } from './zcodeClient.js';
15
15
  import { fetchMirasimQuota, fetchMirasimProfile } from './mirasimClient.js';
16
16
  import { fetchCatpawQuota, fetchCatpawProfile } from './catpawClient.js';
17
17
  import { fetchTraeQuota, checkinTrae, verifyTraeAccount } from './traeClient.js';
18
18
  import { fetchMiniMaxQuota, checkinMiniMax, verifyMiniMaxAccount } from './minimaxClient.js';
19
+ import { fetchDoubaoStatus, checkinDoubao } from './doubaoClient.js';
19
20
  /** 从 userinfo 响应中挑一个可读的显示名 */
20
21
  export function displayNameFrom(ui) {
21
22
  const pick = [ui?.nickname, ui?.name, ui?.username, ui?.email]
@@ -124,8 +125,9 @@ const PRODUCTS = {
124
125
  },
125
126
  workbuddy: {
126
127
  label: 'WorkBuddy',
127
- // 国内版走 billing daily-checkin 签到;国际版没有签到接口,走「活跃领取」(免费档对话探测)
128
- checkin: (account, ctx) => (account.provider === 'workbuddy-intl' ? checkinWorkbuddyIntl(account, ctx) : checkinWorkbuddy(account, ctx)),
128
+ // 国内版走 billing daily-checkin 签到;国际版无签到动作(原「活跃领取」线已删——
129
+ // 逐日主动打上游对话接口博资格的机制整体下线,workbuddy-intl 产品线本身保留,额度查看照常)
130
+ checkin: (account, ctx) => checkinWorkbuddy(account, ctx),
129
131
  quota: (account, ctx) => fetchWorkbuddyQuota(account, ctx),
130
132
  verify: async (account) => {
131
133
  const info = inspectToken(account.token);
@@ -194,6 +196,40 @@ const PRODUCTS = {
194
196
  quota: (account, ctx = {}) => fetchMiniMaxQuota(account, wrapMiniMaxRefresh(account, ctx)),
195
197
  verify: (account) => verifyMiniMaxAccount(account),
196
198
  },
199
+ doubao: {
200
+ label: '豆包',
201
+ // 网页会话捕获线(issue #32):凭据是 doubao.com 会话 cookie(meta.cookieSiteSession.cookie)。
202
+ // 每日签到 +200 额度(服务端幂等);「额度」= 活动累计获得点数(progress.total_amount),
203
+ // 与其他产品线的套餐/积分口径不同,面板以独立产品页呈现。
204
+ checkin: (account) => checkinDoubao(account),
205
+ quota: async (account) => {
206
+ const cookie = account.meta?.cookieSiteSession?.cookie;
207
+ const st = await fetchDoubaoStatus(cookie);
208
+ if (!st.auth) {
209
+ const e = new Error('豆包会话已失效,请重新登录捕获');
210
+ e.auth = true;
211
+ throw e;
212
+ }
213
+ return {
214
+ total: st.totalAmount,
215
+ used: 0,
216
+ remaining: st.totalAmount,
217
+ exceeded: false,
218
+ plan: null,
219
+ planExpiresAt: null,
220
+ parts: st.checkin ? [{
221
+ name: '每日签到',
222
+ total: st.checkin.rewardAmount,
223
+ used: 0,
224
+ remaining: st.checkin.checkedIn ? st.checkin.rewardAmount : 0,
225
+ expiresAt: null,
226
+ recurring: true,
227
+ unit: '额度',
228
+ }] : [],
229
+ empty: st.totalAmount === 0 && !st.checkin,
230
+ };
231
+ },
232
+ },
197
233
  };
198
234
 
199
235
  /** 包裹 MiniMax 的 ctx.onRefresh:回写账号库后,安全同步新凭据回本机 auth.json(仅本机导入账号且客户端未运行时) */
package/src/qoderApp.js CHANGED
@@ -496,7 +496,16 @@ export async function switchTo(account, { force = false } = {}) {
496
496
  if (!auth || !file) {
497
497
  throw new Error(`账号「${account.name || account.id}」没有本机登录快照,无法切换:请在 Qoder 里登录该账号后到「添加账号 → 本机导入」导入一次`);
498
498
  }
499
- if (!fs.existsSync(file)) throw new Error(`Qoder 凭据文件不存在:${file}(客户端可能被卸载或换了数据目录)`);
499
+ if (!fs.existsSync(file)) {
500
+ // 本机当初导入时文件是在的,现在没了:绝大多数情况是客户端退出登录(登出/换号后它会删掉
501
+ // auth.v1.dat),只有少数是卸载或换了数据目录。按发生率给指引,别让人以为软件坏了。
502
+ const app = detectQoderApps().find((a) => a.provider === account.provider);
503
+ const label = app?.label || (account.provider === 'qoder-cn' ? 'Qoder 国内版' : 'Qoder 国际版');
504
+ const hint = app?.installed
505
+ ? `请打开「${label}」客户端确认已登录该账号,然后到「添加账号 → 本机导入」重新导入一次`
506
+ : `没检测到「${label}」客户端,请先安装并登录,然后到「添加账号 → 本机导入」导入`;
507
+ throw new Error(`账号「${account.name || account.id}」的切换凭据已失效:${label}的登录文件不在了(${file})。${hint}`);
508
+ }
500
509
  if (!force && qoderRunning()) {
501
510
  const e = new Error('Qoder 正在运行。切换会改写 Qoder 的登录文件,运行中的 Qoder 退出时会把旧登录写回去——退出后重试,或选择「关闭并强制切换」。');
502
511
  e.qoderRunning = true;
package/src/tenrouter.js CHANGED
@@ -29,6 +29,14 @@ const DEFAULT_CONFIG = {
29
29
  syncState: {},
30
30
  lastSync: null,
31
31
  quotaPush: { enabled: true, intervalMin: 60, lastPush: null },
32
+ // 账号连接同步(账号管理弹窗「同步账号到 10R」):enabled 总开关 + 渠道勾选。
33
+ // providers 空 = 全部候选渠道(向后兼容老配置);渠道是 CD provider 视角
34
+ // (qoder / qoder-cn / workbuddy / workbuddy-intl / tokenharbor)。
35
+ accountSync: { enabled: true, providers: [] },
36
+ // 额度卡推送的渠道排除:10Router 已单独配了同源渠道(如 minimax-code 连接)时,
37
+ // 免去 CD 侧维护同名免费卡(minimax-free-*),两边卡片重复且数字各刷新各的。
38
+ // 记「明确不推」的 CD provider,空 = 全推。
39
+ quotaPushDisabledProviders: [],
32
40
  };
33
41
  // v1.0.0 及更早保存的来源全集;版本迁移只补「此后新增」的来源,不复活用户手动关掉的
34
42
  const LEGACY_SOURCES = ['zcode', 'opencode', 'mirasim', 'mimo', 'catpaw'];
@@ -39,6 +47,7 @@ export function loadConfig() {
39
47
  const c = JSON.parse(fs.readFileSync(FILE(), 'utf8'));
40
48
  const sync = { ...DEFAULT_CONFIG.sync, ...(c.sync || {}) };
41
49
  const quotaPush = { ...DEFAULT_CONFIG.quotaPush, ...(c.quotaPush || {}) };
50
+ const accountSync = { ...DEFAULT_CONFIG.accountSync, ...(c.accountSync || {}) };
42
51
  // 旧配置里的 sources 是保存时的快照,会盖掉新增来源(妙手因此被静默关掉);
43
52
  // 只把此后新增的来源默认并入,用户手动关掉的保持关闭。
44
53
  const savedVersion = Number(c.sync?.sourcesVersion) || 1;
@@ -47,7 +56,7 @@ export function loadConfig() {
47
56
  for (const s of SOURCES) if (!LEGACY_SOURCES.includes(s) && !sync.sources.includes(s)) sync.sources.push(s);
48
57
  sync.sourcesVersion = SOURCES_VERSION;
49
58
  }
50
- return { ...DEFAULT_CONFIG, ...c, sync, quotaPush, syncState: c.syncState || {} };
59
+ return { ...DEFAULT_CONFIG, ...c, sync, quotaPush, accountSync, syncState: c.syncState || {} };
51
60
  } catch { return structuredClone(DEFAULT_CONFIG); }
52
61
  }
53
62
 
@@ -97,6 +106,8 @@ export async function publicConfig() {
97
106
  hasAdminPassword: Boolean(c.adminPassword),
98
107
  sync: c.sync,
99
108
  quotaPush: c.quotaPush,
109
+ accountSync: c.accountSync,
110
+ quotaPushDisabledProviders: c.quotaPushDisabledProviders || [],
100
111
  lastSync: c.lastSync,
101
112
  syncing: Boolean(syncing),
102
113
  sources: await detectSources(),
@@ -104,7 +115,7 @@ export async function publicConfig() {
104
115
  }
105
116
 
106
117
  /** 更新配置:key 留空表示保持原值;endpoint 为空 = 清除整个配置 */
107
- export function updateConfig({ endpoint, key, adminPassword, syncEnabled, sources, quotaPushEnabled, quotaPushIntervalMin } = {}) {
118
+ export function updateConfig({ endpoint, key, adminPassword, syncEnabled, sources, quotaPushEnabled, quotaPushIntervalMin, accountSyncEnabled, accountSyncProviders, quotaPushDisabledProviders } = {}) {
108
119
  return withConfig((c) => {
109
120
  if (endpoint !== undefined) {
110
121
  c.endpoint = normalizeEndpoint(endpoint);
@@ -127,6 +138,12 @@ export function updateConfig({ endpoint, key, adminPassword, syncEnabled, source
127
138
  const min = Math.max(15, Math.min(1440, Number(quotaPushIntervalMin) || 60));
128
139
  c.quotaPush.intervalMin = min;
129
140
  }
141
+ // 账号连接同步:总开关 + 渠道白名单(空数组 = 不限渠道,向后兼容)
142
+ c.accountSync = { ...DEFAULT_CONFIG.accountSync, ...(c.accountSync || {}) };
143
+ if (typeof accountSyncEnabled === 'boolean') c.accountSync.enabled = accountSyncEnabled;
144
+ if (Array.isArray(accountSyncProviders)) c.accountSync.providers = accountSyncProviders.map(String);
145
+ // 额度卡推送的渠道排除(10R 侧已有同源渠道时免去重复卡片)
146
+ if (Array.isArray(quotaPushDisabledProviders)) c.quotaPushDisabledProviders = quotaPushDisabledProviders.map(String);
130
147
  });
131
148
  }
132
149
 
@@ -292,7 +309,14 @@ export async function pushQuotaCards({ onlySources } = {}) {
292
309
  if (c.quotaPush?.enabled === false) return { results: [], skipped: 'disabled' };
293
310
  const { runQuotaPush } = await import('./externalQuota.js');
294
311
  const { loadAccounts } = await import('./store.js');
295
- const accounts = await loadAccounts();
312
+ let accounts = await loadAccounts();
313
+ // 渠道排除:10Router 已单独配了同源渠道(如 minimax-code 连接)时,跳过对应 CD 渠道的
314
+ // 免费卡推送——两边卡片重复且数字各刷各的。账号从源头滤掉比在 runQuotaPush 里逐家族判
315
+ // 更简单:没有账号的家族自然一个卡都不出。
316
+ const disabled = Array.isArray(c.quotaPushDisabledProviders) ? c.quotaPushDisabledProviders : [];
317
+ if (disabled.length && !Array.isArray(onlySources)) {
318
+ accounts = accounts.filter((a) => !disabled.includes(a.provider));
319
+ }
296
320
  const res = await runQuotaPush({
297
321
  endpoint: c.endpoint, key: c.key, accounts,
298
322
  ...(Array.isArray(onlySources) && onlySources.length ? { onlySource: onlySources } : {}),
package/src/traeClient.js CHANGED
@@ -52,9 +52,12 @@ function seeded(uid, salt, n) {
52
52
  return out.slice(0, n);
53
53
  }
54
54
 
55
- export function traeDevice(uid, storedDeviceId) {
56
- const d = seeded(uid, 'devid', 15).map((b) => String(48 + (b % 10)));
57
- const m = seeded(uid, 'market', 16);
55
+ export function traeDevice(uid, storedDeviceId, attempt = 0) {
56
+ // attempt 参数:9074 重试时轮换派生设备号(issue #31 报告者实测——派生号被服务端一律拒 9074、
57
+ // 同一时刻真机号可领,说明服务端大概率只认登录时注册的设备号,换号重试才有机会过)。
58
+ // attempt=0 为默认指纹(跨天稳定恒定);attempt>0 派生结果互异但同样确定(同 attempt 重放一致)。
59
+ const d = seeded(`${uid}`, `devid:${attempt}`, 15).map((b) => String(48 + (b % 10)));
60
+ const m = seeded(`${uid}`, `market:${attempt}`, 16);
58
61
  m[6] = (m[6] & 0x0f) | 0x40;
59
62
  m[8] = (m[8] & 0x3f) | 0x80;
60
63
  const h = m.map((b) => b.toString(16).padStart(2, '0')).join('');
@@ -102,12 +105,12 @@ function findField(obj, key, depth = 0) {
102
105
  return undefined;
103
106
  }
104
107
 
105
- /** 统一 POST → { status, body };401 / code 1001 抛 auth 错误(凭据失效) */
106
- async function post(url, account, body = {}) {
108
+ /** 统一 POST → { status, body };401 / code 1001 抛 auth 错误(凭据失效)。attempt 透传给设备派生(9074 轮换重试用) */
109
+ async function post(url, account, body = {}, attempt = 0) {
107
110
  const jwt = account.token;
108
111
  if (!jwt) throw new Error('账号缺少 Trae 登录 token(请到 Trae 客户端登录后用「本机导入」同步)');
109
112
  const uid = account.uid || uidFromJwt(jwt);
110
- const dev = traeDevice(uid || 'anonymous', account.meta?.deviceId);
113
+ const dev = traeDevice(uid || 'anonymous', account.meta?.deviceId, attempt);
111
114
  const res = await fetchJsonRace(url, {
112
115
  method: 'POST',
113
116
  headers: traeHeaders(jwt, dev),
@@ -230,10 +233,12 @@ export async function checkinTrae(account, ctx = {}) {
230
233
  if (/已经签到|已签到|already.*sign/i.test(String(r.message || ''))) {
231
234
  return { ...base, status: 'already', message: withCode(r.message || '今天已签到', r.code), uid };
232
235
  }
233
- // 9074 实测文案「当前参与用户太多,请稍后再试」= 服务端瞬时限流(issue #31 面板实测),
234
- // 不是设备限领——退避重试而非直接放弃;重试耗尽归 failed(不写签到 memo,下一轮还试)
236
+ // 9074 实测两副面孔(issue #31):面板文案「当前参与用户太多」像瞬时限流,但报告者对照
237
+ // 实验显示派生设备号被服务端一律拒 9074、同一时刻真机号可领——服务端大概率只认登录时
238
+ // 注册的设备号。因此重试时**轮换派生设备号**(attempt 递增),退避 + 换号双管齐下;
239
+ // 重试耗尽归 failed(不写签到 memo,下一轮还试)
235
240
  if (r.code === 9074) {
236
- return finalizeClaim(await claimWithRetry(ctx, () => post(CLAIM_URL, account)), base, uid, TRAE_DEFAULT_DAILY_CREDITS);
241
+ return finalizeClaim(await claimWithRetry(ctx, (i) => post(CLAIM_URL, account, {}, i)), base, uid, TRAE_DEFAULT_DAILY_CREDITS);
237
242
  }
238
243
  return { ...base, status: 'failed', message: withCode(r.message || `code ${r.code}`, r.code), uid };
239
244
  }
@@ -246,17 +251,20 @@ export async function checkinTrae(account, ctx = {}) {
246
251
  // claim 接口有时只返回 { code: 0, message: "success" } 而不包含 credits 字段,作为 fallback 避免 +0
247
252
  const expectedCredits = Number(findField(r.body, 'credits') ?? findField(r.body, 'extra_credits')) || TRAE_DEFAULT_DAILY_CREDITS;
248
253
 
249
- return finalizeClaim(await claimWithRetry(ctx, () => post(CLAIM_URL, account)), base, uid, expectedCredits);
254
+ return finalizeClaim(await claimWithRetry(ctx, (i) => post(CLAIM_URL, account, {}, i)), base, uid, expectedCredits);
250
255
  }
251
256
 
252
- /** 9074 瞬时限流退避重试:3 次,间隔 3s/8s/15s;始终失败返回最后一次响应(由 finalizeClaim 归 failed) */
253
- async function claimWithRetry(ctx, attempt) {
257
+ /**
258
+ * claim 重试:间隔 3s/8s/15s,每次重试换一个派生设备号(attempt 1/2/3)。
259
+ * 首次尝试用默认指纹(attempt 0),始终失败返回最后一次响应(由 finalizeClaim 归 failed)。
260
+ */
261
+ async function claimWithRetry(ctx, attemptFn) {
254
262
  const backoff = [0, 3000, 8000, 15000];
255
- let res = await attempt();
263
+ let res = await attemptFn(0);
256
264
  for (let i = 1; i < backoff.length && res.code === 9074; i++) {
257
- ctx.log?.(`Trae 服务端限流(code 9074),${backoff[i] / 1000}s 后重试(${i}/${backoff.length - 1})`);
265
+ ctx.log?.(`Trae 服务端拒绝(code 9074),${backoff[i] / 1000}s 后换设备号重试(${i}/${backoff.length - 1})`);
258
266
  await new Promise((ok) => setTimeout(ok, backoff[i]));
259
- res = await attempt();
267
+ res = await attemptFn(i);
260
268
  }
261
269
  return res;
262
270
  }
@@ -7,7 +7,7 @@
7
7
  * 架构特性:
8
8
  * 1. 账号多路轮换(provider=trae):自动在有效账号间按剩余积分加权轮询(SWRR)
9
9
  * 2. 凭据热对齐:401 时若为本机导入账号,尝试重读 storage.json 最新登录态
10
- * 3. 429 限流保护:429 限流自动冷却 5 分钟
10
+ * 3. 429 限流保护:429 限流自动冷却(退避阶梯 30s → 1min → 2min → 5min → 10min 封顶,成功一次即清零)
11
11
  * 4. 局域网访问控制:继承相同的白名单与回环检查机制
12
12
  * 5. SSE 增量转换:将 Trae SOLO 累积流转换为标准 Anthropic 流式 SSE 或一次性 JSON 响应
13
13
  */
@@ -18,9 +18,12 @@ import { fetchTraeQuota } from './traeClient.js';
18
18
  import { gatewayHostSuggestion } from './tenrouter.js';
19
19
  import { logger, summarizeAttempts } from './logger.js';
20
20
  import { t } from './i18n.js';
21
+ import { formatDurationMs } from './formatDuration.js';
21
22
 
22
23
  const TRAE_SOLO_BASE = 'https://solo.trae.cn/api/remote/v1';
23
24
  const ACCOUNT_COOLING_MS = 5 * 60_000;
25
+ // 429 退避阶梯:30s → 1min → 2min → 5min → 10min 封顶(与 ZCode 网关同款)。
26
+ const RATE_LIMIT_BACKOFF_MS = [30_000, 60_000, 120_000, 300_000, 600_000];
24
27
  const MAX_ATTEMPTS = 5;
25
28
  const UPSTREAM_TIMEOUT_MS = 600_000;
26
29
  const UPSTREAM_CONNECT_MS = 15_000;
@@ -38,12 +41,15 @@ export async function setGatewayEnabled(v) {
38
41
  if (on) {
39
42
  dead.clear();
40
43
  cooling.clear();
44
+ coolingStreak.clear();
41
45
  }
42
46
  }
43
47
 
44
48
  // ── 账号轮换状态(进程内) ──
45
49
 
46
50
  const cooling = new Map(); // accountId → 冷却截止(ms)
51
+ // accountId → 连续 429 次数(成功即清零):决定本次 429 走到退避阶梯的哪一档
52
+ const coolingStreak = new Map();
47
53
  const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/导入,自动复活)
48
54
  const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }
49
55
  const WEIGHT_TTL_MS = 10 * 60_000; // 超过 10 分钟重查额度,让积分变化反映到分流上
@@ -64,6 +70,16 @@ function markCooling(id, ms = ACCOUNT_COOLING_MS) {
64
70
  cooling.set(id, Date.now() + ms);
65
71
  }
66
72
 
73
+ function markRateLimited(id) {
74
+ const streak = (coolingStreak.get(id) || 0) + 1;
75
+ coolingStreak.set(id, streak);
76
+ const ms = RATE_LIMIT_BACKOFF_MS[Math.min(streak, RATE_LIMIT_BACKOFF_MS.length) - 1];
77
+ cooling.set(id, Date.now() + ms);
78
+ return ms;
79
+ }
80
+
81
+ function resetCoolingStreak(id) { coolingStreak.delete(id); }
82
+
67
83
  function markDead(account) {
68
84
  dead.set(account.id, credFingerprint(account));
69
85
  cooling.set(account.id, Number.MAX_SAFE_INTEGER);
@@ -75,12 +91,20 @@ function isDead(account) {
75
91
  if (fp === credFingerprint(account)) return true;
76
92
  dead.delete(account.id);
77
93
  cooling.delete(account.id);
94
+ coolingStreak.delete(account.id);
78
95
  logger.info('TRAE-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
79
96
  return false;
80
97
  }
81
98
 
99
+ /**
100
+ * 测试专用:把某账号的冷却截止拨到指定时间戳(0 = 立即到期,用于快进冷却窗口)。
101
+ * 生产不走这里——冷却窗口只能自然到期。与 zcodeGateway.__setCoolingForTests 同款。
102
+ */
103
+ export function __setCoolingForTests(accountId, untilMs) { cooling.set(accountId, untilMs); }
104
+
82
105
  export function __resetForTests() {
83
106
  cooling.clear();
107
+ coolingStreak.clear();
84
108
  dead.clear();
85
109
  weights.clear();
86
110
  swrr.clear();
@@ -397,9 +421,11 @@ export async function handleGateway(req, res) {
397
421
  }
398
422
 
399
423
  if (sessionRes.status === 429) {
400
- markCooling(account.id, ACCOUNT_COOLING_MS);
401
- attempted.push({ account: label, ok: false, error: '429 限流,冷却 5 分钟' });
402
- logger.info('TRAE-GW', '{label} 429 限流,冷却 5 分钟', { label });
424
+ const ms = markRateLimited(account.id);
425
+ const dur = formatDurationMs(ms);
426
+ const streak = coolingStreak.get(account.id);
427
+ attempted.push({ account: label, ok: false, error: `429 限流,冷却 ${dur}` });
428
+ logger.info('TRAE-GW', '{label} 429 限流,冷却 {dur}(连续第 {streak} 次)', { label, dur, streak });
403
429
  continue;
404
430
  }
405
431
 
@@ -446,11 +472,22 @@ export async function handleGateway(req, res) {
446
472
 
447
473
  if (!eventsRes.ok || !eventsRes.body) {
448
474
  cleanupSession();
475
+ // 事件流被 429(会话建好了、拉流被限流)同样按阶梯冷却——不判的话这个号
476
+ // 下一轮会话创建仍从 30 秒档起算,继续撞限流
477
+ if (eventsRes.status === 429) {
478
+ const ms = markRateLimited(account.id);
479
+ const dur = formatDurationMs(ms);
480
+ const streak = coolingStreak.get(account.id);
481
+ attempted.push({ account: label, ok: false, error: `429 限流(事件流),冷却 ${dur}` });
482
+ logger.info('TRAE-GW', '{label} 429 限流(事件流),冷却 {dur}(连续第 {streak} 次)', { label, dur, streak });
483
+ continue;
484
+ }
449
485
  attempted.push({ account: label, ok: false, error: `事件流 HTTP ${eventsRes.status}` });
450
486
  continue;
451
487
  }
452
488
 
453
489
  stats.lastAccount = label;
490
+ resetCoolingStreak(account.id); // 成功一次即清零连续 429 计数
454
491
  logger.debug('TRAE-GW', '{label} 连接成功,开始流式输出 ({model})', { label, model: requestedModel });
455
492
 
456
493
  // 成功建立流,按 isStream 分发
@@ -691,7 +728,18 @@ export async function gatewayStatus() {
691
728
  lastAccount: stats.lastAccount,
692
729
  },
693
730
  accounts: all.length,
731
+ // cooling 保持字符串数组(历史形状);冷却细节走 coolingDetail(与 zcodeGateway 同款)
694
732
  cooling: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now).map((a) => a.name || a.uid || a.id),
733
+ coolingDetail: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now)
734
+ .map((a) => {
735
+ const remainMs = (cooling.get(a.id) || 0) - now;
736
+ return {
737
+ id: a.id, name: a.name || a.uid || a.id,
738
+ remainMs, remainSeconds: Math.max(0, Math.ceil(remainMs / 1000)),
739
+ remainText: formatDurationMs(remainMs),
740
+ streak: coolingStreak.get(a.id) || 0,
741
+ };
742
+ }),
695
743
  dead: all.filter((a) => isDead(a)).map((a) => a.name || a.uid || a.id),
696
744
  endpoint: '/gateway/trae/v1/messages',
697
745
  };
package/src/traeLocal.js CHANGED
@@ -16,8 +16,12 @@ import crypto from 'node:crypto';
16
16
  import { execFileSync } from 'node:child_process';
17
17
  import { dataDir } from './store.js';
18
18
 
19
- const APP_NAMES = ['TRAE SOLO CN', 'Trae CN', 'TRAE SOLO', 'Trae'];
20
- const EXE_NAMES = ['TRAE SOLO CN.exe', 'Trae CN.exe', 'TRAE SOLO.exe', 'Trae.exe'];
19
+ // 探测顺序(2026-10-21 TraeWork→TRAE 迁移后定版):新 TRAE CN 优先、国际版次之,
20
+ // SOLO 两个旧变体垫底——SOLO CN 客户端已停发(官方数据迁移页引导全员升新 TRAE),
21
+ // 但迁移期仍有未升级用户,保留兜底;已卸载残留的旧目录读到陈旧凭据的危害大于漏检,
22
+ // 故不再排最前。TRAE_HOME 环境变量可显式指定目录(traeDataDir)。
23
+ const APP_NAMES = ['Trae CN', 'Trae', 'TRAE SOLO CN', 'TRAE SOLO'];
24
+ const EXE_NAMES = ['Trae CN.exe', 'Trae.exe', 'TRAE SOLO CN.exe', 'TRAE SOLO.exe'];
21
25
  const AUTH_KEY = 'iCubeAuthInfo://icube.cloudide';
22
26
  const DC_PREFIX = 'iCubeAuthInfo://icube-dc:';
23
27
  const HEADER = Buffer.from([116, 99, 5, 16, 0, 0]);
@@ -98,7 +102,12 @@ export function readLiveAuth() {
98
102
  if (!storage) return null;
99
103
  const auth = tcDecrypt(storage[AUTH_KEY]);
100
104
  if (!auth || typeof auth.token !== 'string') return null;
101
- return { dataDir: dir, deviceId: deviceIdOf(storage), auth };
105
+ // 国际版防御:信封里带登录 host(api.trae.cn = 国内 / *.trae.ai = 国际,实测 TraeWork→TRAE
106
+ // 迁移后的国际版目录 host 为 growsg-normal.trae.ai)。CD 的 Trae 线(额度/签到/网关)全部
107
+ // 打 api.trae.cn,国际版 JWT 不被认——标记 isIntl 让上层明确提示,而不是导入后对着空额度卡困惑。
108
+ const host = String(auth.host || '');
109
+ const isIntl = Boolean(host) && !/(^|\.)trae\.cn$/.test(new URL(host).hostname || '');
110
+ return { dataDir: dir, deviceId: deviceIdOf(storage), auth, isIntl, host: host || null };
102
111
  }
103
112
 
104
113
  const decodeTasklist = (buf) => {
@@ -157,7 +166,7 @@ export function detectTrae() {
157
166
  export async function liveToAccount() {
158
167
  const live = readLiveAuth();
159
168
  if (!live) return null;
160
- const { auth, deviceId } = live;
169
+ const { auth, deviceId, isIntl } = live;
161
170
  const account = {
162
171
  provider: 'trae',
163
172
  token: auth.token,
@@ -174,6 +183,7 @@ export async function liveToAccount() {
174
183
  deviceId: deviceId || undefined,
175
184
  host: auth.host || undefined,
176
185
  region: auth.userRegion?.region || undefined,
186
+ isIntl: isIntl || undefined,
177
187
  avatarUrl: auth.account?.avatar_url || undefined,
178
188
  capturedAt: new Date().toISOString(),
179
189
  },
package/src/usageSync.js CHANGED
@@ -451,7 +451,7 @@ function collectMimo(files, now = Date.now()) {
451
451
  function collectMirasim(files, endpoint, now = Date.now()) {
452
452
  const out = [];
453
453
  const seen = new Set();
454
- let selfHosted = 0, unsettled = 0;
454
+ let selfHosted = 0, relayed = 0, unsettled = 0;
455
455
  for (const f of files) {
456
456
  for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
457
457
  if (!line.trim()) continue;
@@ -460,6 +460,14 @@ function collectMirasim(files, endpoint, now = Date.now()) {
460
460
  if (!e || typeof e !== 'object') continue;
461
461
  if (e.id) { if (seen.has(e.id)) continue; seen.add(e.id); }
462
462
  if (isSelfHostedUpstream(e.upstreamHost, endpoint)) { selfHosted++; continue; }
463
+ // 只同步走 mirasim 官方中继(relay.mirasim.ai)的调用(2026-10-21 用户拍板):
464
+ // mirasim IDE 里直连上游的调用,上游就是本机 10Router——10R 已原生记账(落在
465
+ // codebuddy-cn 等连接名下),CD 再导一遍就是同一批调用记两笔(10R 用量表里
466
+ // glm-5.3-flashx 出现 codebuddy-cn 3,800 + mirasim-anthropic 2,917 两行重复)。
467
+ // viaRelay=true 的行走 mirasim 官方云中继,10R 没见过,才是 CD 该补的账。
468
+ // direct 行 upstreamHost 全为 null 无从判上游,viaRelay 就是现成的分界(实测
469
+ // relay 行全部是 claude/kimi 家族、direct 行全部是 glm/deepseek/gemini,零交叉)。
470
+ if (!e.viaRelay) { relayed++; continue; }
463
471
  if (!((e.input || 0) + (e.output || 0) + (e.cacheRead || 0) + (e.cacheWrite || 0))) continue;
464
472
  // 刚发生的调用可能还在补字段(durationMs / token 等),定型前先不发
465
473
  const tsMs = Date.parse(e.ts);
@@ -467,7 +475,7 @@ function collectMirasim(files, endpoint, now = Date.now()) {
467
475
  out.push(convertMirasimRow(e));
468
476
  }
469
477
  }
470
- return { entries: out, notes: (selfHosted ? [`跳过 ${selfHosted} 行经 10Router 中转的调用(已在 10Router 记账)`] : []).concat(unsettled ? [`暂不同步 ${unsettled} 行 1 小时内的新调用(等记录定型)`] : []) };
478
+ return { entries: out, notes: (selfHosted ? [`跳过 ${selfHosted} 行经 10Router 中转的调用(已在 10Router 记账)`] : []).concat(relayed ? [`跳过 ${relayed} 行直连上游的调用(上游为本机 10Router,已在 10Router 记账)`] : []).concat(unsettled ? [`暂不同步 ${unsettled} 行 1 小时内的新调用(等记录定型)`] : []) };
471
479
  }
472
480
 
473
481
  /** 读取某来源的全部行:{ entries, notes, files, sentTotals? };来源不存在返回 entries=[] */
@@ -94,51 +94,6 @@ async function post(account, path, token) {
94
94
  return { status: res.status, body, text };
95
95
  }
96
96
 
97
- // 国际版「活跃领取」探测:官方规则为「当天有 ≥1 次有效对话请求即视为活跃用户,发放每日赠送积分」。
98
- // 没有签到接口,所以每天发一条免费档模型(rateMultiplier 0,~0 消耗)的极短流式请求。
99
- // 对齐 10router src/sse/services/codebuddyCheckin.js 的 intl 探测(stream 网关的系统提示与 typed blocks 是必需的,否则 11101)。
100
- const INTL_PROBE_MODEL = 'hy4-preview';
101
- async function postIntlProbe(account, token) {
102
- const res = await fetchJsonRace(`https://${apiHost(account)}/v2/chat/completions`, {
103
- method: 'POST',
104
- headers: { ...headersFor(account, token), 'Content-Type': 'application/json', Accept: 'text/event-stream' },
105
- body: JSON.stringify({
106
- model: INTL_PROBE_MODEL,
107
- stream: true,
108
- max_tokens: 16,
109
- messages: [
110
- { role: 'system', content: 'You are CodeBuddy Code.' },
111
- { role: 'user', content: [{ type: 'text', text: 'hi' }] },
112
- ],
113
- }),
114
- timeoutMs: Math.max(FETCH_TIMEOUT_MS, 25_000),
115
- });
116
- // 2xx = 会话建立成功,把 SSE 流排干(最多几个 token)
117
- const text = await res.text().catch(() => '');
118
- return { status: res.status, text };
119
- }
120
-
121
- /**
122
- * 国际版「活跃领取」:一次免费档对话请求使账号成为当日活跃用户。
123
- * 返回与每日签到一致的结果对象(session-ok → checked-in,claimedAmount 0 表示额度随官方结算发放)。
124
- */
125
- export async function checkinWorkbuddyIntl(account, { onRefresh } = {}) {
126
- const label = account.name || account.id;
127
- const base = { accountId: account.id, account: label, provider: account.provider };
128
- try {
129
- const r = await withToken(account, (t) => postIntlProbe(account, t), onRefresh);
130
- if (r.status === 401) return { ...base, status: 'failed', error: '鉴权失败(401),token 已失效,请在客户端重新登录后重新导入' };
131
- if (r.status === 429) return { ...base, status: 'no-activity', message: '今日额度已耗尽,活跃请求暂不可达(等官方发放后再试)', claimedAmount: 0 };
132
- if (r.status >= 200 && r.status < 300) {
133
- return { ...base, uid: account.uid, status: 'checked-in', claimedAmount: 0, message: '已发送活跃请求(保持活跃,活跃不等于必有奖励)' };
134
- }
135
- const m = (() => { try { return JSON.parse(r.text)?.error?.data?.msg || ''; } catch { return ''; } })();
136
- return { ...base, status: 'failed', error: `活跃请求失败:HTTP ${r.status} ${m || r.text.slice(0, 120)}`.trim() };
137
- } catch (e) {
138
- return { ...base, status: 'failed', error: e?.message || '网络错误' };
139
- }
140
- }
141
-
142
97
  export function isExpired(account, now = Date.now()) {
143
98
  const exp = account.expiresAt || inspectToken(account.token).expiresAt;
144
99
  return Boolean(exp) && new Date(exp).getTime() - EXPIRY_SKEW_MS <= now;
@@ -99,6 +99,46 @@ export function currentWorkbuddyUid() {
99
99
  return readWorkbuddySessions().accounts.find((a) => a.current)?.uid || null;
100
100
  }
101
101
 
102
+ // 客户端可执行文件候选(VS Code 系安装惯例:%LOCALAPPDATA%\Programs\<名>\<名>.exe)。
103
+ // 「已安装」只认 exe:凭据目录不能当依据——CreditDaddy 自己切换时 mkdir 也会把目录建出来
104
+ // (无客户端时静默切换留下的空壳目录),拿目录判断会把没装客户端的机器误报成已安装。
105
+ const CLIENT_CANDIDATES = process.platform === 'win32'
106
+ ? [
107
+ ['Programs', 'CodeBuddy', 'CodeBuddy.exe'],
108
+ ['Programs', 'WorkBuddy', 'WorkBuddy.exe'],
109
+ ['CodeBuddy', 'CodeBuddy.exe'],
110
+ ['WorkBuddy', 'WorkBuddy.exe'],
111
+ ]
112
+ : process.platform === 'darwin'
113
+ ? ['/Applications/CodeBuddy.app', '/Applications/WorkBuddy.app']
114
+ : [];
115
+
116
+ /**
117
+ * 本机 WorkBuddy / CodeBuddy 客户端探测(面板「客户端:未安装」提示与切换拦截用)。
118
+ * Linux / NAS 没有已知安装路径,退而用「凭据目录里有会话文件」判定——
119
+ * 会话文件只有客户端登录后才写,纯 Token 导入的机器不会产生。
120
+ */
121
+ export function detectWorkbuddy() {
122
+ const dir = workbuddyAuthDir();
123
+ let dirExists = false;
124
+ try { dirExists = fs.statSync(dir).isDirectory(); } catch { /* 未安装 / 从未登录 */ }
125
+ let clientPath = null;
126
+ for (const cand of CLIENT_CANDIDATES) {
127
+ const p = process.platform === 'win32' && Array.isArray(cand)
128
+ ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), ...cand)
129
+ : cand;
130
+ try { if (fs.existsSync(p)) { clientPath = p; break; } } catch { /* 下一个候选 */ }
131
+ }
132
+ const sessions = dirExists ? readWorkbuddySessions() : { accounts: [] };
133
+ return {
134
+ dir,
135
+ dirExists,
136
+ clientPath,
137
+ installed: Boolean(clientPath) || (CLIENT_CANDIDATES.length === 0 && sessions.accounts.length > 0),
138
+ signedIn: sessions.accounts.some((a) => a.current),
139
+ };
140
+ }
141
+
102
142
  /**
103
143
  * 把账号写成 WorkBuddy 当前会话(切换账号)。需要账号带有导入时保存的会话信息。
104
144
  * @returns {{ file, previousUid }}
@@ -67,7 +67,10 @@ export async function zcodeAutoClaim(account) {
67
67
  // 第 2 步:先试不带验证码
68
68
  try {
69
69
  const r = await claimPlan(account, plan.planId, {});
70
- claims.push({ plan: planName, via: 'direct', ok: true });
70
+ // startsAt 是「到账时间」:活动权益常带 effective_at(如当天 23:00),
71
+ // 领到了但余额里还没有——不把这个时间带出去,面板只能显示冷冰冰的
72
+ // 「待生效」,用户以为没领上。
73
+ claims.push({ plan: planName, via: 'direct', ok: true, startsAt: r?.startsAt || null });
71
74
  anySuccess = true;
72
75
  logger.info('ZCODE-CLAIM', '{label} 已领取「{plan}」(免验证码){grants}', { label, plan: planName, grants: grantsText(plan) ? ':' + grantsText(plan) : '' });
73
76
  continue;
@@ -97,8 +100,8 @@ export async function zcodeAutoClaim(account) {
97
100
  const cfg = await fetchCaptchaConfig();
98
101
  if (!cfg.enabled || !cfg.sceneId) throw new Error('验证码配置不可用');
99
102
  const { captchaParam, region } = await captchaProvider(cfg);
100
- await claimPlan(account, plan.planId, { captchaParam, region });
101
- claims.push({ plan: planName, via: 'captcha', ok: true });
103
+ const r2 = await claimPlan(account, plan.planId, { captchaParam, region });
104
+ claims.push({ plan: planName, via: 'captcha', ok: true, startsAt: r2?.startsAt || null });
102
105
  anySuccess = true;
103
106
  logger.info('ZCODE-CLAIM', '{label} 已领取「{plan}」(静默验证码){grants}', { label, plan: planName, grants: grantsText(plan) ? ':' + grantsText(plan) : '' });
104
107
  } catch (e2) {
@@ -128,8 +131,22 @@ export async function zcodeAutoClaim(account) {
128
131
  const status = names.length ? 'checked-in'
129
132
  : claims.every((c) => c.already) ? 'already'
130
133
  : 'failed';
134
+ // 到账时间:新领的活动若带 effective_at(当天稍晚才进余额),把时间写进消息——
135
+ // 否则面板只显示「待生效」,用户无法判断是没领上还是要等。
136
+ const fmtAt = (iso) => {
137
+ if (!iso) return '';
138
+ const d = new Date(iso);
139
+ if (Number.isNaN(d.getTime())) return '';
140
+ const p2 = (n) => String(n).padStart(2, '0');
141
+ const now = new Date();
142
+ const sameDay = d.toDateString() === now.toDateString();
143
+ const hm = `${p2(d.getHours())}:${p2(d.getMinutes())}`;
144
+ return sameDay ? hm : `${d.getMonth() + 1}-${p2(d.getDate())} ${hm}`;
145
+ };
146
+ const starts = claims.filter((c) => c.ok && c.startsAt && new Date(c.startsAt).getTime() > Date.now());
147
+ const startsText = starts.length ? `(${fmtAt(starts[0].startsAt)} 到账)` : '';
131
148
  const message = status === 'checked-in'
132
- ? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}`
149
+ ? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}${startsText}`
133
150
  : status === 'already' ? '活动均已领取过'
134
151
  : needManual ? `有 ${claims.filter((c) => c.needManual).length} 个活动需要验证码,请在面板手动领取`
135
152
  : `领取失败:${claims.filter((c) => !c.ok).map((c) => c.message).filter(Boolean)[0] || '未知错误'}`;
@@ -502,6 +502,8 @@ export function normalizeBalance(balanceResp) {
502
502
  const pending = Boolean(startsAt && new Date(startsAt).getTime() > now);
503
503
  return {
504
504
  name: e.show_name || e.entitlement_id || '活动额度',
505
+ // 活动名(如「ZCode 1024 Dev Day」):未生效权益靠它告诉用户「这是哪个活动发的」
506
+ planName: String(p.name || p.plan_id || '') || null,
505
507
  unit: 'Token',
506
508
  total: grant,
507
509
  used: 0,
@@ -514,6 +516,10 @@ export function normalizeBalance(balanceResp) {
514
516
  }),
515
517
  );
516
518
  const parts = balanceParts.length ? balanceParts : entitlementParts;
519
+ // 未生效权益汇总:面板用它显示「待生效 N Token · 活动名 · HH:MM 到账」,
520
+ // 而不是把 0 当成「已用完」——活动权益通常要到生效点才进 balances
521
+ const pendingParts = parts.filter((p) => p.pending);
522
+ const pendingAt = pendingParts.map((p) => p.startsAt).filter(Boolean).sort()[0] || null;
517
523
  // 套餐有效期卡片上显示在「有效期至」列之外,这里记录最近的套餐结束时间
518
524
  const planEndsAt = plans.map((p) => epochMs(p.ends_at)).filter(Boolean).sort()[0] || null;
519
525
  const sum = (k) => parts.reduce((s, p) => s + (p[k] || 0), 0);
@@ -523,6 +529,8 @@ export function normalizeBalance(balanceResp) {
523
529
  plan: plans[0]?.name || plans[0]?.plan_id || null,
524
530
  planEndsAt,
525
531
  parts,
532
+ pendingTotal: pendingParts.reduce((s, p) => s + (p.total || 0), 0),
533
+ pendingAt,
526
534
  empty: parts.length === 0 && plans.length === 0,
527
535
  exceeded: false,
528
536
  };