coding-tool-x 3.8.5 → 3.8.7

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.
Files changed (51) hide show
  1. package/dist/web/assets/{AgentsDrawer-BG6P3DX-.js → AgentsDrawer-C7ePByZx.js} +1 -1
  2. package/dist/web/assets/{Analytics--z_FmL5P.js → Analytics-BVG6ehFv.js} +1 -1
  3. package/dist/web/assets/{AssetPathField-CrJtRKHy.js → AssetPathField-BZI9NvEr.js} +1 -1
  4. package/dist/web/assets/{ChatHistoryDrawer-BiuEryJj.js → ChatHistoryDrawer-Ccl4rhdz.js} +1 -1
  5. package/dist/web/assets/{CollapseItem-BAYEsL_Q.js → CollapseItem-MxG_UD4o.js} +1 -1
  6. package/dist/web/assets/{CommandsDrawer-YJ-U7BXN.js → CommandsDrawer-DWbaxHRZ.js} +1 -1
  7. package/dist/web/assets/{ConfigExportDrawer-Ce_hAknW.js → ConfigExportDrawer-DrawKwxE.js} +1 -1
  8. package/dist/web/assets/{ConfigTemplates-Bvr1NN6K.js → ConfigTemplates-AiRPv2cd.js} +1 -1
  9. package/dist/web/assets/{ConfigTemplatesDrawer-BjIeHfF-.js → ConfigTemplatesDrawer-CKMFsfEF.js} +1 -1
  10. package/dist/web/assets/{ConfigTemplatesPanel-AV00tm_D.js → ConfigTemplatesPanel-Cvbnkvpt.js} +1 -1
  11. package/dist/web/assets/{FavoritesDrawer-Cz95o4CD.js → FavoritesDrawer-DA-dR_vP.js} +1 -1
  12. package/dist/web/assets/{GatewayConvertDrawer-DLlbMz27.js → GatewayConvertDrawer-402_raGb.js} +1 -1
  13. package/dist/web/assets/{Home-D7CCOFQC.js → Home-CTF8ZWKW.js} +1 -1
  14. package/dist/web/assets/{MarkdownEditor-rEZ-c72L.js → MarkdownEditor-BvkDECwH.js} +1 -1
  15. package/dist/web/assets/{McpDrawer-DfoCyxU2.js → McpDrawer-DZGHkuDm.js} +1 -1
  16. package/dist/web/assets/{McpFormDrawer-SKu8HNUR.js → McpFormDrawer-DPMnyt5L.js} +1 -1
  17. package/dist/web/assets/{PluginManager-BbeK-3rV.js → PluginManager-FKVeugr4.js} +1 -1
  18. package/dist/web/assets/{PluginsDrawer-CWVJ7yjS.js → PluginsDrawer-MvM1R55d.js} +1 -1
  19. package/dist/web/assets/{PluginsPanel-Cm2N3StG.js → PluginsPanel-CKuBs38B.js} +1 -1
  20. package/dist/web/assets/{ProjectList-CsjnsNPv.js → ProjectList-tsjwjXV1.js} +1 -1
  21. package/dist/web/assets/{PromptsDrawer-EGd3djFa.js → PromptsDrawer-B-X9ci8n.js} +1 -1
  22. package/dist/web/assets/{RecentSessionsDrawer-D1vtoLAs.js → RecentSessionsDrawer-wLktMF_c.js} +1 -1
  23. package/dist/web/assets/{SessionCard-BgLg49U3.js → SessionCard-DkJPy0TY.js} +1 -1
  24. package/dist/web/assets/{SessionList-TRiTklWk.js → SessionList-Gzhh4FOZ.js} +1 -1
  25. package/dist/web/assets/SettingsDrawer-Dw5NUgHC.css +1 -0
  26. package/dist/web/assets/SettingsDrawer-lMjttbkD.js +1 -0
  27. package/dist/web/assets/{SkillManager-BTjTUbHq.js → SkillManager-CzFxWxx-.js} +1 -1
  28. package/dist/web/assets/{SkillsDrawer-pN0hbkK3.js → SkillsDrawer-DF6JsSyj.js} +1 -1
  29. package/dist/web/assets/{SkillsPanel-gmBWEoiF.js → SkillsPanel--lLEts9y.js} +1 -1
  30. package/dist/web/assets/{SpeedTestDrawer-DcNzuVJk.js → SpeedTestDrawer-BaZkZqPP.js} +1 -1
  31. package/dist/web/assets/{Upload-v5rV0qTT.js → Upload-BEQ-3Kqq.js} +1 -1
  32. package/dist/web/assets/{WorkspaceDrawer-DxmicM4U.js → WorkspaceDrawer-BFSwZ5yU.js} +1 -1
  33. package/dist/web/assets/{WorkspaceManager-CJHCjARN.js → WorkspaceManager-BHL1uYct.js} +1 -1
  34. package/dist/web/assets/{WorkspacePanel-D8_Zq-du.js → WorkspacePanel-B5PooBtg.js} +1 -1
  35. package/dist/web/assets/{index-DnEocV0M.js → index-BFInlZGI.js} +2 -2
  36. package/dist/web/assets/{index-C0EHRym0.css → index-BohDDAy_.css} +1 -1
  37. package/dist/web/assets/{useResponsiveDrawer-B3uojyvV.js → useResponsiveDrawer-D-DDpbnI.js} +1 -1
  38. package/dist/web/index.html +2 -2
  39. package/package.json +1 -1
  40. package/src/commands/daemon.js +10 -0
  41. package/src/platforms/channel-auth-registry.js +3 -0
  42. package/src/platforms/channel-auth-service.js +33 -8
  43. package/src/platforms/native-oauth-adapters.js +11 -1
  44. package/src/platforms/notification-hooks.js +55 -5
  45. package/src/platforms/oauth-credentials-service.js +170 -41
  46. package/src/server/services/channel-balance.js +122 -4
  47. package/src/server/services/network-access.js +11 -1
  48. package/src/server/services/session-history-index.js +106 -11
  49. package/src/shared/channel-driver.js +80 -5
  50. package/dist/web/assets/SettingsDrawer-d5liXjHq.css +0 -1
  51. package/dist/web/assets/SettingsDrawer-rz2WejGY.js +0 -1
@@ -359,6 +359,14 @@ function httpPost(url, body, headers = {}) {
359
359
  });
360
360
  }
361
361
 
362
+ function parseJsonBody(body) {
363
+ try {
364
+ return JSON.parse(body);
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+
362
370
  async function fetchClaudeUsage(accessToken) {
363
371
  try {
364
372
  const result = await httpGet('https://api.anthropic.com/api/oauth/usage', {
@@ -367,38 +375,40 @@ async function fetchClaudeUsage(accessToken) {
367
375
  'anthropic-version': '2023-06-01',
368
376
  'User-Agent': 'claude-cli/1.0'
369
377
  });
370
- const data = JSON.parse(result.body);
378
+ const data = parseJsonBody(result.body);
371
379
  return { raw: data, provider: 'claude', statusCode: result.statusCode };
372
380
  } catch (err) {
373
381
  return { error: err.message, provider: 'claude' };
374
382
  }
375
383
  }
376
384
 
377
- async function fetchCodexUsage(accessToken) {
378
- // Codex uses JWT id_token; decode it to extract user info directly
379
- try {
380
- const { decodeJwtPayload } = require('../server/services/oauth-utils');
381
- const payload = decodeJwtPayload(accessToken);
382
- if (payload && (payload.email || payload.sub)) {
383
- return {
384
- raw: {
385
- email: payload.email || '',
386
- accountId: payload.sub || '',
387
- name: payload.name || ''
388
- },
389
- provider: 'codex',
390
- statusCode: 200
391
- };
385
+ function extractCodexAccountId(...tokens) {
386
+ const { decodeJwtPayload } = require('../server/services/oauth-utils');
387
+ for (const token of tokens) {
388
+ if (!token) continue;
389
+ try {
390
+ const payload = decodeJwtPayload(token) || {};
391
+ const accountId = payload.chatgpt_account_id
392
+ || payload['https://api.openai.com/auth.chatgpt_account_id']
393
+ || payload['https://api.openai.com/auth']?.chatgpt_account_id
394
+ || payload.organizations?.[0]?.id;
395
+ if (accountId) return String(accountId);
396
+ } catch (_) {
397
+ // Try the next token; malformed JWTs should not prevent the usage request.
392
398
  }
393
- } catch (_) {
394
- // fall through to API call
395
399
  }
400
+ return '';
401
+ }
402
+
403
+ async function fetchCodexUsage(accessToken, accountId = '') {
396
404
  try {
397
- const result = await httpGet('https://api.openai.com/v1/me', {
405
+ const headers = {
398
406
  'Authorization': `Bearer ${accessToken}`,
399
- 'User-Agent': 'openai-node/4.0.0'
400
- });
401
- const data = JSON.parse(result.body);
407
+ 'User-Agent': 'codex-cli/0.1'
408
+ };
409
+ if (accountId) headers['ChatGPT-Account-Id'] = accountId;
410
+ const result = await httpGet('https://chatgpt.com/backend-api/wham/usage', headers);
411
+ const data = parseJsonBody(result.body);
402
412
  return { raw: data, provider: 'codex', statusCode: result.statusCode };
403
413
  } catch (err) {
404
414
  return { error: err.message, provider: 'codex' };
@@ -409,7 +419,7 @@ async function fetchGeminiUsage(accessToken) {
409
419
  try {
410
420
  const body = JSON.stringify({
411
421
  metadata: {
412
- ideType: 'ANTIGRAVITY',
422
+ ideType: 'IDE_UNSPECIFIED',
413
423
  platform: 'PLATFORM_UNSPECIFIED',
414
424
  pluginType: 'GEMINI'
415
425
  }
@@ -421,8 +431,30 @@ async function fetchGeminiUsage(accessToken) {
421
431
  'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
422
432
  'Client-Metadata': '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'
423
433
  });
424
- const data = JSON.parse(result.body);
425
- return { raw: data, provider: 'gemini', statusCode: result.statusCode };
434
+ const loadData = parseJsonBody(result.body) || {};
435
+ if (result.statusCode < 200 || result.statusCode >= 300) {
436
+ return { raw: loadData, provider: 'gemini', statusCode: result.statusCode };
437
+ }
438
+
439
+ const project = typeof loadData.cloudaicompanionProject === 'string'
440
+ ? loadData.cloudaicompanionProject
441
+ : loadData.cloudaicompanionProject?.id || '';
442
+ const quotaResult = await httpPost(
443
+ 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota',
444
+ JSON.stringify(project ? { project } : {}),
445
+ {
446
+ 'Authorization': `Bearer ${accessToken}`,
447
+ 'Content-Type': 'application/json',
448
+ 'User-Agent': 'google-api-nodejs-client/9.15.1',
449
+ 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
450
+ 'Client-Metadata': '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'
451
+ }
452
+ );
453
+ return {
454
+ raw: parseJsonBody(quotaResult.body),
455
+ provider: 'gemini',
456
+ statusCode: quotaResult.statusCode
457
+ };
426
458
  } catch (err) {
427
459
  return { error: err.message, provider: 'gemini' };
428
460
  }
@@ -435,31 +467,122 @@ function clampPercent(value) {
435
467
  return Math.max(0, Math.min(100, percent));
436
468
  }
437
469
 
438
- function normalizeWindow(window, label) {
470
+ function normalizeResetAt(value, resetAfterSeconds) {
471
+ const numeric = Number(value);
472
+ if (Number.isFinite(numeric) && numeric > 0) {
473
+ const milliseconds = numeric < 1e12 ? numeric * 1000 : numeric;
474
+ const date = new Date(milliseconds);
475
+ if (!Number.isNaN(date.getTime())) return date.toISOString();
476
+ }
477
+ if (value) {
478
+ const date = new Date(value);
479
+ if (!Number.isNaN(date.getTime())) return date.toISOString();
480
+ }
481
+ const afterSeconds = Number(resetAfterSeconds);
482
+ if (Number.isFinite(afterSeconds) && afterSeconds >= 0) {
483
+ return new Date(Date.now() + afterSeconds * 1000).toISOString();
484
+ }
485
+ return null;
486
+ }
487
+
488
+ function normalizeWindow(window, label, id = '') {
439
489
  if (!window || typeof window !== 'object') return null;
440
- const usedPercent = clampPercent(window.used_percent ?? window.utilization);
441
- if (usedPercent === null) return null;
490
+ const usedPercent = clampPercent(window.used_percent ?? window.usedPercent ?? window.utilization);
491
+ const remainingPercent = clampPercent(
492
+ window.remaining_percent
493
+ ?? window.remainingPercent
494
+ ?? window.remaining_fraction
495
+ ?? window.remainingFraction
496
+ );
497
+ if (usedPercent === null && remainingPercent === null) return null;
498
+ const normalizedUsedPercent = usedPercent === null ? 100 - remainingPercent : usedPercent;
499
+ const normalizedRemainingPercent = remainingPercent === null ? 100 - usedPercent : remainingPercent;
442
500
  return {
501
+ ...(id ? { id } : {}),
443
502
  label,
444
- remainingPercent: Math.max(0, Math.min(100, 100 - usedPercent)),
445
- usedPercent,
446
- resetsAt: window.reset_at || window.resetAt || null
503
+ remainingPercent: Math.max(0, Math.min(100, normalizedRemainingPercent)),
504
+ usedPercent: Math.max(0, Math.min(100, normalizedUsedPercent)),
505
+ resetsAt: normalizeResetAt(
506
+ window.resets_at
507
+ ?? window.resetsAt
508
+ ?? window.reset_at
509
+ ?? window.resetAt
510
+ ?? window.reset_time
511
+ ?? window.resetTime,
512
+ window.reset_after_seconds ?? window.resetAfterSeconds
513
+ )
447
514
  };
448
515
  }
449
516
 
517
+ function normalizeGeminiQuota(value) {
518
+ const buckets = Array.isArray(value?.buckets) ? value.buckets : [];
519
+ const windows = buckets.map((bucket, index) => {
520
+ const remainingPercent = clampPercent(bucket?.remainingFraction ?? bucket?.remaining_fraction);
521
+ if (remainingPercent === null) return null;
522
+ const modelId = String(bucket?.modelId || bucket?.model_id || '').trim();
523
+ return {
524
+ id: `gemini:${modelId || index}`,
525
+ label: modelId || 'Gemini',
526
+ remainingPercent,
527
+ usedPercent: Math.max(0, Math.min(100, 100 - remainingPercent)),
528
+ resetsAt: normalizeResetAt(bucket?.resetTime || bucket?.reset_time),
529
+ scope: modelId ? { modelId } : null
530
+ };
531
+ }).filter(Boolean);
532
+ return windows.length ? { status: 'available', windows } : null;
533
+ }
534
+
450
535
  function normalizeOAuthQuota(tool, raw) {
451
536
  const value = raw && typeof raw === 'object' ? raw : {};
452
- let primary = normalizeWindow(value.five_hour || value.fiveHour, '5h');
453
- let secondary = normalizeWindow(value.seven_day || value.sevenDay, '7d');
537
+ if (tool === 'gemini') {
538
+ const geminiQuota = normalizeGeminiQuota(value);
539
+ if (geminiQuota) return { quota: geminiQuota, status: 'available' };
540
+ }
541
+
542
+ let primary = normalizeWindow(value.five_hour || value.fiveHour, '5h', 'primary');
543
+ let secondary = normalizeWindow(value.seven_day || value.sevenDay, '7d', 'secondary');
454
544
  const rateLimit = value.rate_limit || value.rateLimit;
455
545
  if (rateLimit) {
456
- primary = primary || normalizeWindow(rateLimit.primary_window || rateLimit.primaryWindow, '5h');
457
- secondary = secondary || normalizeWindow(rateLimit.secondary_window || rateLimit.secondaryWindow, '7d');
546
+ primary = primary || normalizeWindow(rateLimit.primary_window || rateLimit.primaryWindow, '5h', 'primary');
547
+ secondary = secondary || normalizeWindow(rateLimit.secondary_window || rateLimit.secondaryWindow, '7d', 'secondary');
458
548
  }
459
- if (!primary && !secondary) {
460
- return { quota: null, status: 'unsupported', warning: `${tool}: provider did not return 5h/7d quota windows` };
549
+
550
+ const additional = [];
551
+ const additionalLimits = Array.isArray(value.additional_rate_limits)
552
+ ? value.additional_rate_limits
553
+ : Array.isArray(value.additionalRateLimits) ? value.additionalRateLimits : [];
554
+ additionalLimits.forEach((limit, index) => {
555
+ const additionalRateLimit = limit?.rate_limit || limit?.rateLimit || limit;
556
+ const name = String(
557
+ limit?.limit_name || limit?.limitName || limit?.metered_feature || limit?.meteredFeature || '附加额度'
558
+ ).trim();
559
+ const additionalPrimary = normalizeWindow(
560
+ additionalRateLimit?.primary_window || additionalRateLimit?.primaryWindow,
561
+ `${name} 5h`,
562
+ `additional:${index}:primary`
563
+ );
564
+ const additionalSecondary = normalizeWindow(
565
+ additionalRateLimit?.secondary_window || additionalRateLimit?.secondaryWindow,
566
+ `${name} 7d`,
567
+ `additional:${index}:secondary`
568
+ );
569
+ if (additionalPrimary) additional.push(additionalPrimary);
570
+ if (additionalSecondary) additional.push(additionalSecondary);
571
+ });
572
+
573
+ if (!primary && !secondary && !additional.length) {
574
+ return { quota: null, status: 'unsupported', warning: `${tool}: provider did not return usable quota windows` };
461
575
  }
462
- return { quota: { status: 'available', primary, secondary }, status: 'available' };
576
+ return {
577
+ quota: {
578
+ status: 'available',
579
+ primary,
580
+ secondary,
581
+ additional,
582
+ windows: [primary, secondary, ...additional].filter(Boolean)
583
+ },
584
+ status: 'available'
585
+ };
463
586
  }
464
587
 
465
588
  async function fetchCredentialUsage(tool, credentialId) {
@@ -474,7 +597,10 @@ async function fetchCredentialUsage(tool, credentialId) {
474
597
  response = await fetchClaudeUsage(accessToken);
475
598
  break;
476
599
  case 'codex':
477
- response = await fetchCodexUsage(secrets.idToken || accessToken);
600
+ response = await fetchCodexUsage(
601
+ accessToken,
602
+ extractCodexAccountId(secrets.idToken, accessToken) || entry.accountId || secrets.accountId
603
+ );
478
604
  break;
479
605
  case 'gemini':
480
606
  response = await fetchGeminiUsage(accessToken);
@@ -485,7 +611,7 @@ async function fetchCredentialUsage(tool, credentialId) {
485
611
  ? await fetchClaudeUsage(accessToken)
486
612
  : providerId.includes('gemini') || providerId.includes('google')
487
613
  ? await fetchGeminiUsage(accessToken)
488
- : await fetchCodexUsage(accessToken);
614
+ : await fetchCodexUsage(accessToken, extractCodexAccountId(accessToken) || entry.accountId || secrets.accountId);
489
615
  break;
490
616
  }
491
617
  default:
@@ -502,5 +628,8 @@ module.exports = {
502
628
  SUPPORTED_TOOLS,
503
629
  syncLocalCredential,
504
630
  normalizeOAuthQuota,
505
- fetchCredentialUsage
631
+ fetchCredentialUsage,
632
+ extractCodexAccountId,
633
+ normalizeResetAt,
634
+ normalizeWindow
506
635
  };
@@ -120,6 +120,22 @@ function resolveBalanceToken(channel = {}) {
120
120
  );
121
121
  }
122
122
 
123
+ function isOAuthChannel(channel = {}) {
124
+ return channel?.authMode === 'oauth';
125
+ }
126
+
127
+ function resolveOAuthCacheIdentity(channel = {}) {
128
+ const ref = channel.authRef && typeof channel.authRef === 'object' ? channel.authRef : {};
129
+ return [
130
+ channel.oauthProviderId,
131
+ ref.credentialId,
132
+ ref.providerId,
133
+ ref.accountId,
134
+ ref.identityKey,
135
+ ref.accountEmail
136
+ ].map(value => String(value || '').trim()).filter(Boolean).join('|');
137
+ }
138
+
123
139
  function resolveBalanceUserId(channel = {}) {
124
140
  const raw = channel.balanceUserId
125
141
  ?? channel.platformUserId
@@ -250,6 +266,64 @@ function makeVisibleSnapshot(platform, input, { stale = false, updatedAt = nowIs
250
266
  return snapshot;
251
267
  }
252
268
 
269
+ function formatPercent(value) {
270
+ const number = parseFiniteNumber(value);
271
+ if (number == null) return '';
272
+ return Number.isInteger(number) ? String(number) : number.toFixed(1).replace(/\.0$/, '');
273
+ }
274
+
275
+ function normalizeOAuthWindows(quota = {}) {
276
+ const source = quota && typeof quota === 'object' ? quota : {};
277
+ const candidates = [];
278
+ if (source.primary) candidates.push({ id: 'primary', ...source.primary });
279
+ if (source.secondary) candidates.push({ id: 'secondary', ...source.secondary });
280
+ if (Array.isArray(source.windows)) candidates.push(...source.windows);
281
+ if (Array.isArray(source.additional)) candidates.push(...source.additional);
282
+
283
+ const seen = new Set();
284
+ return candidates.map((window, index) => {
285
+ const remainingPercent = parseFiniteNumber(window?.remainingPercent);
286
+ const usedPercent = parseFiniteNumber(window?.usedPercent);
287
+ if (remainingPercent == null && usedPercent == null) return null;
288
+ const id = String(window?.id || `window-${index}`);
289
+ if (seen.has(id)) return null;
290
+ seen.add(id);
291
+ return {
292
+ id,
293
+ label: String(window?.label || id),
294
+ remainingPercent: remainingPercent != null
295
+ ? Math.max(0, Math.min(100, remainingPercent))
296
+ : Math.max(0, Math.min(100, 100 - usedPercent)),
297
+ usedPercent: usedPercent != null
298
+ ? Math.max(0, Math.min(100, usedPercent))
299
+ : Math.max(0, Math.min(100, 100 - remainingPercent)),
300
+ resetsAt: window?.resetsAt || null,
301
+ scope: window?.scope || null
302
+ };
303
+ }).filter(Boolean);
304
+ }
305
+
306
+ function buildOAuthBalanceSnapshot(platform, quota, { stale = false, updatedAt = nowIso() } = {}) {
307
+ const windows = normalizeOAuthWindows(quota);
308
+ if (!windows.length) return makeHiddenSnapshot(platform, { stale, updatedAt });
309
+
310
+ const primaryWindows = windows.filter(window => window.id === 'primary' || window.id === 'secondary');
311
+ const displayWindows = primaryWindows.length ? primaryWindows : windows;
312
+ const label = displayWindows
313
+ .map(window => `${window.label} 剩余 ${formatPercent(window.remainingPercent)}%`)
314
+ .join(' · ');
315
+
316
+ return {
317
+ visible: true,
318
+ kind: 'oauth-quota',
319
+ platform,
320
+ label,
321
+ windows,
322
+ updatedAt,
323
+ stale
324
+ };
325
+ }
326
+
253
327
  function normalizeBaseUrl(value) {
254
328
  const raw = String(value || '').trim();
255
329
  if (!raw) return '';
@@ -1483,14 +1557,16 @@ async function probeBalanceFromBase(baseUrl, channel, token) {
1483
1557
 
1484
1558
  function buildCacheKey(source, channel) {
1485
1559
  const token = resolveBalanceToken(channel);
1486
- const tokenHash = token
1487
- ? crypto.createHash('sha256').update(token).digest('hex').slice(0, 16)
1488
- : 'no-token';
1560
+ const identity = isOAuthChannel(channel)
1561
+ ? `oauth:${resolveOAuthCacheIdentity(channel) || channel.id || channel.name || 'unknown'}`
1562
+ : token
1563
+ ? crypto.createHash('sha256').update(token).digest('hex').slice(0, 16)
1564
+ : 'no-token';
1489
1565
  return [
1490
1566
  source,
1491
1567
  channel.id || channel.name || 'unknown',
1492
1568
  normalizeBaseUrl(channel.baseUrl || ''),
1493
- tokenHash
1569
+ identity
1494
1570
  ].join(':');
1495
1571
  }
1496
1572
 
@@ -1512,6 +1588,44 @@ async function refreshChannelBalanceSnapshot(source, channel, options = {}) {
1512
1588
  return cached.snapshot;
1513
1589
  }
1514
1590
 
1591
+ if (isOAuthChannel(channel)) {
1592
+ try {
1593
+ const { fetchChannelAuthQuota } = require('../../platforms/channel-auth-service');
1594
+ const result = await fetchChannelAuthQuota(source, channel.id, { refresh: force });
1595
+ const snapshot = buildOAuthBalanceSnapshot(source, result?.quota, {
1596
+ updatedAt: result?.checkedAt || nowIso(currentTime)
1597
+ });
1598
+ if (snapshot.visible) {
1599
+ balanceCache.set(key, {
1600
+ snapshot,
1601
+ expiresAt: currentTime + CACHE_TTL_MS
1602
+ });
1603
+ return snapshot;
1604
+ }
1605
+ } catch {
1606
+ // OAuth providers are best-effort; preserve a previous successful quota below.
1607
+ }
1608
+
1609
+ if (cached?.snapshot?.visible) {
1610
+ const staleSnapshot = {
1611
+ ...cached.snapshot,
1612
+ stale: true
1613
+ };
1614
+ balanceCache.set(key, {
1615
+ snapshot: staleSnapshot,
1616
+ expiresAt: currentTime + CACHE_TTL_MS
1617
+ });
1618
+ return staleSnapshot;
1619
+ }
1620
+
1621
+ const hiddenSnapshot = makeHiddenSnapshot(source, { updatedAt: nowIso(currentTime) });
1622
+ balanceCache.set(key, {
1623
+ snapshot: hiddenSnapshot,
1624
+ expiresAt: currentTime + CACHE_TTL_MS
1625
+ });
1626
+ return hiddenSnapshot;
1627
+ }
1628
+
1515
1629
  const bases = buildBaseCandidates(channel.baseUrl);
1516
1630
  if (!bases.length || !resolveBalanceToken(channel)) {
1517
1631
  return makeHiddenSnapshot(null, { updatedAt: nowIso(currentTime) });
@@ -1766,6 +1880,10 @@ module.exports = {
1766
1880
  buildSub2ApiUsageSnapshot,
1767
1881
  resolveStatusPlatform,
1768
1882
  resolveBalanceToken,
1883
+ isOAuthChannel,
1884
+ resolveOAuthCacheIdentity,
1885
+ normalizeOAuthWindows,
1886
+ buildOAuthBalanceSnapshot,
1769
1887
  resolveBalanceUserId,
1770
1888
  buildCookieCandidates,
1771
1889
  build88CodeApiBaseCandidates,
@@ -44,7 +44,17 @@ function isSameOriginRequest(req) {
44
44
 
45
45
  try {
46
46
  const originUrl = new URL(origin);
47
- return originUrl.host === host;
47
+ if (originUrl.host === host) {
48
+ return true;
49
+ }
50
+
51
+ // The Vite dev UI runs on a separate local port and proxies API calls to
52
+ // the backend. Treat that as local-only traffic, but do not extend this
53
+ // exception to requests arriving from a LAN or public address.
54
+ const originHost = String(originUrl.hostname || '').replace(/^\[|\]$/g, '');
55
+ return (originUrl.protocol === 'http:' || originUrl.protocol === 'https:')
56
+ && isLoopbackRequest(req)
57
+ && isLoopbackAddress(originHost);
48
58
  } catch (error) {
49
59
  return false;
50
60
  }
@@ -19,6 +19,8 @@ const INDEX_INVENTORY_TTL_MS = 30000;
19
19
  const INDEX_COLD_WAIT_MS = 1500;
20
20
  /** @type {number} Maximum age for a lock whose owner cannot be inspected */
21
21
  const INVENTORY_LOCK_STALE_MS = 30 * 60 * 1000;
22
+ /** @type {number} Maximum time a competing inventory waits for the database writer */
23
+ const INVENTORY_LOCK_WAIT_MS = 30000;
22
24
  /** @type {number} Number of files parsed before results are released */
23
25
  const INVENTORY_PARSE_BATCH_SIZE = 8;
24
26
  /** @type {number} Maximum number of files parsed concurrently */
@@ -34,9 +36,8 @@ function _isUsableRuntime(runtime) {
34
36
  return !!runtime && typeof runtime === 'object' && typeof runtime.getDriver === 'function';
35
37
  }
36
38
 
37
- function _inventoryLockPath(dbPath, source) {
38
- const sourceKey = Buffer.from(String(source), 'utf8').toString('hex');
39
- return `${dbPath}.${sourceKey}.inventory.lock`;
39
+ function _inventoryLockPath(dbPath) {
40
+ return `${path.resolve(String(dbPath))}.inventory.lock`;
40
41
  }
41
42
 
42
43
  function _isProcessAlive(pid) {
@@ -50,19 +51,19 @@ function _isProcessAlive(pid) {
50
51
  }
51
52
 
52
53
  /**
53
- * Acquire a durable per-database/source lock. The in-memory single-flight
54
+ * Acquire a durable database-wide inventory lock. The in-memory single-flight
54
55
  * map only protects requests inside one Node process; production indexing is
55
56
  * deliberately performed in child processes, so it cannot prevent two
56
- * workers from rebuilding the same source after a parent restart.
57
+ * workers from writing the same database after a parent restart.
57
58
  */
58
59
  function _tryAcquireInventoryLock(dbPath, source) {
59
- const lockPath = _inventoryLockPath(dbPath, source);
60
+ const lockPath = _inventoryLockPath(dbPath);
60
61
 
61
62
  for (let attempt = 0; attempt < 2; attempt++) {
62
63
  let fd;
63
64
  try {
64
65
  fd = fs.openSync(lockPath, 'wx');
65
- fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), 'utf8');
66
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, source, startedAt: Date.now() }), 'utf8');
66
67
  return { fd, lockPath };
67
68
  } catch (error) {
68
69
  if (fd !== undefined) {
@@ -81,7 +82,14 @@ function _tryAcquireInventoryLock(dbPath, source) {
81
82
  }
82
83
 
83
84
  const ownerPid = Number(owner?.pid);
84
- if (_isProcessAlive(ownerPid) || (!Number.isFinite(ageMs) || ageMs < INVENTORY_LOCK_STALE_MS)) {
85
+ if (_isProcessAlive(ownerPid)) {
86
+ return {
87
+ busy: true,
88
+ sameSource: owner?.source && String(owner.source) === String(source)
89
+ };
90
+ }
91
+
92
+ if ((!Number.isInteger(ownerPid) || ownerPid <= 0) && ageMs < INVENTORY_LOCK_STALE_MS) {
85
93
  return null;
86
94
  }
87
95
 
@@ -95,12 +103,91 @@ function _tryAcquireInventoryLock(dbPath, source) {
95
103
  return null;
96
104
  }
97
105
 
106
+ async function _acquireInventoryLock(dbPath, source) {
107
+ const deadline = Date.now() + INVENTORY_LOCK_WAIT_MS;
108
+ while (true) {
109
+ const lock = _tryAcquireInventoryLock(dbPath, source);
110
+ if (lock?.busy) {
111
+ if (lock.sameSource) return null;
112
+ } else if (lock) {
113
+ return lock;
114
+ }
115
+ if (Date.now() >= deadline) return null;
116
+ await new Promise(resolve => setTimeout(resolve, 50));
117
+ }
118
+ }
119
+
98
120
  function _releaseInventoryLock(lock) {
99
121
  if (!lock) return;
100
122
  try { fs.closeSync(lock.fd); } catch (_err) {}
101
123
  try { fs.unlinkSync(lock.lockPath); } catch (_err) {}
102
124
  }
103
125
 
126
+ /**
127
+ * Remove inventory locks left by workers that are no longer alive.
128
+ *
129
+ * A normal worker releases its lock in `_runInventory`'s finally block. This
130
+ * fallback is needed for `ctx stop` when a worker is terminated before that
131
+ * block can run. Locks owned by a live PID are intentionally retained.
132
+ *
133
+ * @param {string} [dbPath]
134
+ * @returns {{ removed: string[], retained: string[] }}
135
+ */
136
+ function cleanupStaleInventoryLocks(dbPath = PATHS?.sessionHistoryIndex) {
137
+ const targetPath = dbPath ? path.resolve(String(dbPath)) : '';
138
+ if (!targetPath) return { removed: [], retained: [] };
139
+
140
+ const directory = path.dirname(targetPath);
141
+ const prefix = `${path.basename(targetPath)}.`;
142
+ let entries;
143
+ try {
144
+ entries = fs.readdirSync(directory);
145
+ } catch (_) {
146
+ return { removed: [], retained: [] };
147
+ }
148
+
149
+ const removed = [];
150
+ const retained = [];
151
+ for (const entry of entries) {
152
+ if (!entry.startsWith(prefix) || !entry.endsWith('.inventory.lock')) continue;
153
+ const lockPath = path.join(directory, entry);
154
+ let owner = null;
155
+ let ageMs = 0;
156
+ try {
157
+ ageMs = Math.max(0, Date.now() - fs.statSync(lockPath).mtimeMs);
158
+ } catch (_) {
159
+ retained.push(lockPath);
160
+ continue;
161
+ }
162
+ try {
163
+ owner = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
164
+ } catch (_) {
165
+ // Keep malformed young locks; an old one is handled by the stale rule.
166
+ }
167
+
168
+ const ownerPid = Number(owner?.pid);
169
+ if (_isProcessAlive(ownerPid)) {
170
+ retained.push(lockPath);
171
+ continue;
172
+ }
173
+
174
+ // A valid but dead owner can be cleaned immediately. Unreadable lock
175
+ // files remain protected by the normal acquisition path's stale check.
176
+ if ((Number.isInteger(ownerPid) && ownerPid > 0) || ageMs >= INVENTORY_LOCK_STALE_MS) {
177
+ try {
178
+ fs.unlinkSync(lockPath);
179
+ removed.push(lockPath);
180
+ } catch (_) {
181
+ retained.push(lockPath);
182
+ }
183
+ } else {
184
+ retained.push(lockPath);
185
+ }
186
+ }
187
+
188
+ return { removed, retained };
189
+ }
190
+
104
191
 
105
192
  // ---------------------------------------------------------------------------
106
193
  // Schema
@@ -597,18 +684,23 @@ function createSessionHistoryIndex(opts = {}) {
597
684
  }
598
685
 
599
686
  async function _runInventory(source, { force = false, config = indexConfig } = {}) {
600
- const db = _getDb();
601
- const inventoryLock = _tryAcquireInventoryLock(dbPath, source);
687
+ // SQLite's writer lock is database-wide, so source-specific lock files do
688
+ // not prevent two different sources from entering BEGIN IMMEDIATE at the
689
+ // same time. Acquire one lock for the whole index before opening/schema
690
+ // initializing the writer connection.
691
+ const inventoryLock = await _acquireInventoryLock(dbPath, source);
602
692
  if (!inventoryLock) {
603
693
  // Another process is already doing the expensive work. Existing rows
604
694
  // remain readable, and the next scheduled inventory will observe the
605
695
  // new source_state timestamp once that worker finishes.
606
696
  return;
607
697
  }
698
+ let db = null;
608
699
  let errorMsg = null;
609
700
  let stateToRecord = null;
610
701
 
611
702
  try {
703
+ db = _getDb();
612
704
  if (!force && _isSourceFresh(source)) {
613
705
  return;
614
706
  }
@@ -798,7 +890,9 @@ function createSessionHistoryIndex(opts = {}) {
798
890
  } finally {
799
891
  if (stateToRecord) {
800
892
  try {
801
- _recordSourceState(db, source, stateToRecord.lastInventoryMs, stateToRecord.lastError);
893
+ if (db) {
894
+ _recordSourceState(db, source, stateToRecord.lastInventoryMs, stateToRecord.lastError);
895
+ }
802
896
  } catch (_err) {}
803
897
  }
804
898
  _releaseInventoryLock(inventoryLock);
@@ -1494,4 +1588,5 @@ module.exports = {
1494
1588
  invalidateSource,
1495
1589
  getSourceIndexMeta,
1496
1590
  closeSessionHistoryIndex,
1591
+ cleanupStaleInventoryLocks,
1497
1592
  };