tokentracker-cli 1.3.8 → 1.3.10

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 (129) hide show
  1. package/README.de.md +7 -6
  2. package/README.ja.md +6 -5
  3. package/README.ko.md +6 -5
  4. package/README.md +7 -6
  5. package/README.zh-CN.md +6 -5
  6. package/dashboard/dist/assets/{AchievementBadge-BqLeP5y3.js → AchievementBadge-vU4Dwu_p.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-BwVkM45R.js → AchievementsPage-C8LspyPK.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CglgOcQ1.js → AchievementsSection-CP8J07fV.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-IurF0cmo.js → BadgeDetailModal-Ci1egHvR.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-C4_NxPuy.js → BotAnimated-CMXILM_h.js} +1 -1
  11. package/dashboard/dist/assets/{Card-C-UuIN42.js → Card-BHHCCnqR.js} +1 -1
  12. package/dashboard/dist/assets/{CloudCheckoutPage-CrMDquLi.js → CloudCheckoutPage-BY9Hsbix.js} +1 -1
  13. package/dashboard/dist/assets/{CloudPage-D6xTPnMs.js → CloudPage-Y6JgOXcJ.js} +2 -2
  14. package/dashboard/dist/assets/{CommandPalette-8LFLY-LQ.js → CommandPalette-BRWQFJwy.js} +1 -1
  15. package/dashboard/dist/assets/CommunityStatsModal-krT4ZZX9.js +1 -0
  16. package/dashboard/dist/assets/{ConfirmModal-St89kP59.js → ConfirmModal-ZvPOJ0he.js} +1 -1
  17. package/dashboard/dist/assets/Controls-CQlxw03i.js +1 -0
  18. package/dashboard/dist/assets/{DashboardPage-BvGqFvP7.js → DashboardPage-Dbi0d2JY.js} +1 -1
  19. package/dashboard/dist/assets/{DateRangePopover-Cbd1idiF.js → DateRangePopover-DJ8RpQfV.js} +1 -1
  20. package/dashboard/dist/assets/DevicePage-C4_cbw3M.js +1 -0
  21. package/dashboard/dist/assets/{DialogClose-Cb1-Hd8P.js → DialogClose-Dx5AqmUG.js} +1 -1
  22. package/dashboard/dist/assets/DialogDescription-LvhfT1Ox.js +1 -0
  23. package/dashboard/dist/assets/{DialogTitle-VXrHY7H0.js → DialogTitle-Cro2S8s8.js} +1 -1
  24. package/dashboard/dist/assets/{ElectricLogo-DmQqYnSD.js → ElectricLogo-gqnBCtSG.js} +1 -1
  25. package/dashboard/dist/assets/{FadeIn-BEieTXMD.js → FadeIn-B9oATcDZ.js} +1 -1
  26. package/dashboard/dist/assets/{HeaderGithubStar-BlYC_PSb.js → HeaderGithubStar-CYa94wna.js} +1 -1
  27. package/dashboard/dist/assets/{HoverTooltip-C5Pe7iBz.js → HoverTooltip-BJ4qLyuS.js} +1 -1
  28. package/dashboard/dist/assets/{IpCheckPage-Diln3EdG.js → IpCheckPage-Clpq3jdY.js} +1 -1
  29. package/dashboard/dist/assets/{LandingPage-BzGYuAO7.js → LandingPage-CuazuOAo.js} +2 -2
  30. package/dashboard/dist/assets/{LeaderboardPage-Bn-ehU5S.js → LeaderboardPage-BSF1Lbtq.js} +2 -2
  31. package/dashboard/dist/assets/{LeaderboardProBadge-SovdGwYs.js → LeaderboardProBadge-K32gY52s.js} +1 -1
  32. package/dashboard/dist/assets/{LeaderboardProfileModal-BhxmTcHh.js → LeaderboardProfileModal-D5gpn7pP.js} +3 -3
  33. package/dashboard/dist/assets/{LeaderboardProfilePage-3iAYvoTb.js → LeaderboardProfilePage-BmO9nIi-.js} +1 -1
  34. package/dashboard/dist/assets/{LimitsPage-DHxFAdqK.js → LimitsPage-D9ZJ1TeK.js} +1 -1
  35. package/dashboard/dist/assets/{LocalOnlyNotice-HlGfiiI3.js → LocalOnlyNotice-BkzysTS7.js} +1 -1
  36. package/dashboard/dist/assets/{LoginCard-CamUYmiF.js → LoginCard-Do3SNJFU.js} +1 -1
  37. package/dashboard/dist/assets/{LoginModal-CcmYUJ45.js → LoginModal-hph1i_YK.js} +1 -1
  38. package/dashboard/dist/assets/{LoginPage-DMNNmZwr.js → LoginPage-DXYAaFjJ.js} +1 -1
  39. package/dashboard/dist/assets/{PetPage-D6xFX8QA.js → PetPage-D1Pk4OKI.js} +2 -2
  40. package/dashboard/dist/assets/{PopoverPopup-CKKgItAT.js → PopoverPopup-CAIuP_a5.js} +1 -1
  41. package/dashboard/dist/assets/{ProviderIcon-B-uS3pJ3.js → ProviderIcon-R6Bsj81X.js} +1 -1
  42. package/dashboard/dist/assets/{ResetPasswordPage-DX4RJjX0.js → ResetPasswordPage-BR3GTF-3.js} +1 -1
  43. package/dashboard/dist/assets/{Select-Dcg7FYAt.js → Select-38Dh-OWv.js} +1 -1
  44. package/dashboard/dist/assets/{SelectItemText-4vuj6xMs.js → SelectItemText-90n7jKbI.js} +1 -1
  45. package/dashboard/dist/assets/{SelfHostPage-ButVBgE7.js → SelfHostPage-BKVYkHI6.js} +1 -1
  46. package/dashboard/dist/assets/{ServiceStatusPage-DJDDO6lV.js → ServiceStatusPage-PBmBn29J.js} +1 -1
  47. package/dashboard/dist/assets/{SessionsPage-CiUirFMD.js → SessionsPage-58MfDHhe.js} +3 -3
  48. package/dashboard/dist/assets/SettingsPage-BNLHbZUQ.js +5 -0
  49. package/dashboard/dist/assets/{SkillsPage-D2tfFFY8.js → SkillsPage-DMMTk4jD.js} +1 -1
  50. package/dashboard/dist/assets/{TokenGalaxy-TQ-eWK8P.js → TokenGalaxy-BdPETjru.js} +1 -1
  51. package/dashboard/dist/assets/{TooltipPopup-BhSCJqFR.js → TooltipPopup-BXl-vM4L.js} +1 -1
  52. package/dashboard/dist/assets/{TrendMonitor-CP6ZZ53c.js → TrendMonitor-wPfTlX-7.js} +1 -1
  53. package/dashboard/dist/assets/{WidgetsPage-BdeR7P8D.js → WidgetsPage-CuwRMWka.js} +1 -1
  54. package/dashboard/dist/assets/{WrappedPage-OqcqupRE.js → WrappedPage-DOpA9kkg.js} +1 -1
  55. package/dashboard/dist/assets/{__vite-browser-external-BXwSXUpN.js → __vite-browser-external-Go6WKt6Z.js} +3 -3
  56. package/dashboard/dist/assets/__vite-browser-external-kAUz3kFs.css +1 -0
  57. package/dashboard/dist/assets/{agent-logos-E6GfkcgD.js → agent-logos-D7eMvAlk.js} +1 -1
  58. package/dashboard/dist/assets/{arrow-left-D_ufthLH.js → arrow-left--eOVP_Dk.js} +1 -1
  59. package/dashboard/dist/assets/{arrow-right-CgV_OrFz.js → arrow-right-DJYhwBMz.js} +1 -1
  60. package/dashboard/dist/assets/{arrow-up-right-CzeEeXRz.js → arrow-up-right-DhDvpd-S.js} +1 -1
  61. package/dashboard/dist/assets/{calendar-days-BQdTF7ec.js → calendar-days-BuOFlNcJ.js} +1 -1
  62. package/dashboard/dist/assets/{check-C--D8Lze.js → check-UztXSVYi.js} +1 -1
  63. package/dashboard/dist/assets/{chevron-down-BzkceD4z.js → chevron-down-CbJ2QGDU.js} +1 -1
  64. package/dashboard/dist/assets/{cloud-checkout-m1Yllbij.js → cloud-checkout-CoRQjKEu.js} +1 -1
  65. package/dashboard/dist/assets/{composite-7DhFi6TA.js → composite-PlKvZfdM.js} +1 -1
  66. package/dashboard/dist/assets/{download-BSyGLtfW.js → download-McE9gFto.js} +1 -1
  67. package/dashboard/dist/assets/{external-link-KzFbueBT.js → external-link-DZWnR9gS.js} +1 -1
  68. package/dashboard/dist/assets/{flame-DlPM_oaj.js → flame-0xjueXt4.js} +1 -1
  69. package/dashboard/dist/assets/{format-tokens-BHBaWyA3.js → format-tokens-CZoG68uJ.js} +1 -1
  70. package/dashboard/dist/assets/{gift-BhWBQnun.js → gift-pKc80Nqb.js} +1 -1
  71. package/dashboard/dist/assets/{host-mode--iIPGrkN.js → host-mode-99_v1j5J.js} +1 -1
  72. package/dashboard/dist/assets/{icons-COEaSBYi.js → icons-BtfYKSGC.js} +1 -1
  73. package/dashboard/dist/assets/{index-Cf4HcmLp.js → index-Cgthm7BO.js} +1 -1
  74. package/dashboard/dist/assets/{index-Bo8CSxCq.js → index-CyZxN1O0.js} +1 -1
  75. package/dashboard/dist/assets/{index-CW6A_Ff_.js → index-DuaQmNZo.js} +1 -1
  76. package/dashboard/dist/assets/{info-D1gOdsM_.js → info-C8vkxwsO.js} +1 -1
  77. package/dashboard/dist/assets/{link-2-CXDnDOK4.js → link-2-CQzIW5b6.js} +1 -1
  78. package/dashboard/dist/assets/{loader-circle-DWeQPecG.js → loader-circle-B9GqzQyC.js} +1 -1
  79. package/dashboard/dist/assets/{modifiers.esm-BljqMT7V.js → modifiers.esm-ChkCUrZp.js} +1 -1
  80. package/dashboard/dist/assets/{monitor-smartphone-DpkdtOjk.js → monitor-smartphone-DQjBY1W3.js} +1 -1
  81. package/dashboard/dist/assets/{play-BlGNrekI.js → play-qC6xc6fe.js} +1 -1
  82. package/dashboard/dist/assets/{plus-CpYVQ_t-.js → plus-CoNHx5Og.js} +1 -1
  83. package/dashboard/dist/assets/{provider-display-D6Blvktj.js → provider-display-x2EG6zAY.js} +1 -1
  84. package/dashboard/dist/assets/{react-BMJ2wuz1.js → react-EctmAw3f.js} +1 -1
  85. package/dashboard/dist/assets/{safePolygon-BFHXICk1.js → safePolygon-DlSbLtZC.js} +1 -1
  86. package/dashboard/dist/assets/{search-0NxXkytY.js → search-CBcpAj9I.js} +1 -1
  87. package/dashboard/dist/assets/{server-GHkrUKSI.js → server-DtMUoEq0.js} +1 -1
  88. package/dashboard/dist/assets/{shield-check-C16cbx1z.js → shield-check-ByUPtUmu.js} +1 -1
  89. package/dashboard/dist/assets/{skills-api-DAm1fFB4.js → skills-api-BD9VNRej.js} +1 -1
  90. package/dashboard/dist/assets/{sortable.esm-CkOax8h0.js → sortable.esm-B6qEViGJ.js} +1 -1
  91. package/dashboard/dist/assets/{store-DR444-5P.js → store-BgW2uBRe.js} +1 -1
  92. package/dashboard/dist/assets/{terminal-BpvNaIz9.js → terminal-DWPNkr2L.js} +1 -1
  93. package/dashboard/dist/assets/{trash-DX8LwOmN.js → trash-DdbRpgD1.js} +1 -1
  94. package/dashboard/dist/assets/{use-community-stats-BFW4-DYG.js → use-community-stats-43ySv9uM.js} +1 -1
  95. package/dashboard/dist/assets/{use-latest-request-guard-Cpcl08EJ.js → use-latest-request-guard-BuezAv4k.js} +1 -1
  96. package/dashboard/dist/assets/{use-limits-display-prefs-BqixyeAa.js → use-limits-display-prefs-Yi0Hznar.js} +1 -1
  97. package/dashboard/dist/assets/{use-native-settings-BSx7lGEK.js → use-native-settings-BaSrhrM8.js} +1 -1
  98. package/dashboard/dist/assets/{use-ordered-list-UCjB0_zK.js → use-ordered-list-CBD-EIBf.js} +1 -1
  99. package/dashboard/dist/assets/use-reduced-motion-ObUuoXUE.js +1 -0
  100. package/dashboard/dist/assets/{use-session-efficiency-pref-BgOJaGEy.js → use-session-efficiency-pref-CwaFIkE5.js} +1 -1
  101. package/dashboard/dist/assets/{use-transform-Y98QifnQ.js → use-transform-Dea4uz8K.js} +1 -1
  102. package/dashboard/dist/assets/{use-usage-limits-DJGO-Kxv.js → use-usage-limits-DxkYcUIR.js} +1 -1
  103. package/dashboard/dist/assets/{useCurrency-DL458pqS.js → useCurrency-CcZktkbA.js} +1 -1
  104. package/dashboard/dist/assets/{usePositioner-DnCQwlVT.js → usePositioner-BLGIGihf.js} +1 -1
  105. package/dashboard/dist/assets/{useTokenFormat-CYLt9pgs.js → useTokenFormat--dhm3nNf.js} +1 -1
  106. package/dashboard/dist/assets/{zap-BW6E_Jv3.js → zap-CWTmP6Zw.js} +1 -1
  107. package/dashboard/dist/index.html +14 -12
  108. package/dashboard/dist/ip-check.html +2 -2
  109. package/dashboard/dist/leaderboard.html +2 -2
  110. package/dashboard/dist/llms.txt +3 -2
  111. package/dashboard/dist/share.html +2 -2
  112. package/package.json +2 -2
  113. package/src/commands/init.js +9 -0
  114. package/src/commands/status.js +11 -0
  115. package/src/commands/sync.js +197 -122
  116. package/src/lib/dumate.js +95 -0
  117. package/src/lib/pricing/seed-snapshot.json +1 -1
  118. package/src/lib/qoder-cn-cli-limits.js +117 -0
  119. package/src/lib/qoder-limits.js +24 -11
  120. package/src/lib/rollout.js +12 -9
  121. package/src/lib/session-analytics.js +210 -19
  122. package/src/lib/upload-queue.js +134 -0
  123. package/dashboard/dist/assets/CommunityStatsModal-DYTjxABN.js +0 -1
  124. package/dashboard/dist/assets/Controls-BJAwM2gB.js +0 -1
  125. package/dashboard/dist/assets/DevicePage-BcSU0xgf.js +0 -1
  126. package/dashboard/dist/assets/DialogDescription-DQvYzWMp.js +0 -1
  127. package/dashboard/dist/assets/SettingsPage-CB4CML8u.js +0 -5
  128. package/dashboard/dist/assets/__vite-browser-external-DvNSoTb8.css +0 -1
  129. package/dashboard/dist/assets/use-reduced-motion-C6zOHbfR.js +0 -1
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs/promises");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+
8
+ const OPEN_API = "https://openapi.qoder.com.cn";
9
+
10
+ async function readQoderCnCliCredentials({ home = os.homedir(), env = process.env } = {}) {
11
+ const root = typeof env.QODERCN_CONFIG_DIR === "string" && env.QODERCN_CONFIG_DIR.trim()
12
+ ? path.resolve(env.QODERCN_CONFIG_DIR.trim())
13
+ : path.join(home, ".qoder-cn");
14
+ try {
15
+ const [encrypted, machineId] = await Promise.all([
16
+ fs.readFile(path.join(root, ".auth", "user"), "utf8"),
17
+ fs.readFile(path.join(root, ".auth", "machine_id"), "utf8"),
18
+ ]);
19
+ // Verified against the official CLI 1.1.68 WASM credential-storage codec.
20
+ const key = Buffer.from(machineId.trim().slice(0, 16), "utf8");
21
+ if (key.length !== 16) return null;
22
+ const decipher = crypto.createDecipheriv("aes-128-cbc", key, key);
23
+ const user = JSON.parse(Buffer.concat([
24
+ decipher.update(Buffer.from(encrypted.trim(), "base64")),
25
+ decipher.final(),
26
+ ]).toString("utf8"));
27
+ const token = user.security_oauth_token || user.access_token;
28
+ if (typeof token !== "string" || !token.trim()) return null;
29
+ return { token, organizationId: user.organization_id || null };
30
+ } catch (_error) {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function cnAuthError() {
36
+ const error = new Error("Qoder CN login expired. Sign in to Qoder CN CLI again.");
37
+ error.code = "AUTH_EXPIRED";
38
+ return error;
39
+ }
40
+
41
+ async function cnRequest(url, options, fetchImpl, { tokenExchange = false } = {}) {
42
+ let response;
43
+ try {
44
+ response = await fetchImpl(url, {
45
+ ...options,
46
+ redirect: "error",
47
+ signal: AbortSignal.timeout(15_000),
48
+ });
49
+ } catch (_error) {
50
+ throw new Error("Qoder CN usage request failed.");
51
+ }
52
+ if (response.status === 401 || response.status === 403 || (tokenExchange && response.status === 400)) throw cnAuthError();
53
+ if (!response.ok) throw new Error(`Qoder CN API returned HTTP ${response.status}.`);
54
+ try {
55
+ return await response.json();
56
+ } catch (_error) {
57
+ throw new Error("Qoder CN API returned invalid JSON.");
58
+ }
59
+ }
60
+
61
+ function normalizeQoderCnCliUsage(response, normalizeRpcUsage) {
62
+ const usage = response?.displayMode === "qoder" ? response.qoderUsage : response;
63
+ const rawQuota = usage?.userQuota ?? usage?.user_quota;
64
+ if (!rawQuota || typeof rawQuota !== "object") {
65
+ throw new Error("Qoder CN usage response is missing the plan quota.");
66
+ }
67
+ const total = Number(rawQuota.total);
68
+ const used = Number(rawQuota.used);
69
+ const remaining = rawQuota.remaining == null ? Math.max(0, total - used) : Number(rawQuota.remaining);
70
+ if (rawQuota.total == null || rawQuota.used == null
71
+ || ![total, used, remaining].every(Number.isFinite) || Math.min(total, used, remaining) < 0
72
+ || (total === 0 && (used !== 0 || remaining !== 0))) {
73
+ throw new Error("Qoder CN usage response has invalid quota values.");
74
+ }
75
+ // The OpenAPI returns fractions; the old desktop IPC already returns percents.
76
+ const result = normalizeRpcUsage({
77
+ userType: usage.userType ?? usage.user_type,
78
+ expiresAt: usage.expiresAt ?? usage.expires_at,
79
+ isQuotaExceeded: usage.isQuotaExceeded ?? usage.is_quota_exceeded,
80
+ userQuota: { used, total, remaining, unit: rawQuota.unit },
81
+ });
82
+ result.source = "cli-api";
83
+ return result;
84
+ }
85
+
86
+ async function fetchQoderCnCliLimits({
87
+ home = os.homedir(),
88
+ env = process.env,
89
+ fetchImpl = fetch,
90
+ normalizeRpcUsage,
91
+ } = {}) {
92
+ const pat = typeof env.QODERCN_PERSONAL_ACCESS_TOKEN === "string"
93
+ ? env.QODERCN_PERSONAL_ACCESS_TOKEN.trim()
94
+ : "";
95
+ let credentials;
96
+ if (pat) {
97
+ const exchanged = await cnRequest(`${OPEN_API}/api/v1/jobToken/exchange`, {
98
+ method: "POST",
99
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
100
+ body: JSON.stringify({ personal_token: pat }),
101
+ }, fetchImpl, { tokenExchange: true });
102
+ const token = exchanged?.token || exchanged?.device_token || exchanged?.access_token;
103
+ if (typeof token !== "string" || !token.trim()) throw cnAuthError();
104
+ credentials = { token };
105
+ } else {
106
+ credentials = await readQoderCnCliCredentials({ home, env });
107
+ }
108
+ if (!credentials) return null;
109
+ const endpoint = credentials.organizationId ? "/sash/api/v2/me/usage" : "/api/v2/quota/usage";
110
+ const response = await cnRequest(`${OPEN_API}${endpoint}`, {
111
+ method: "GET",
112
+ headers: { Accept: "application/json", Authorization: `Bearer ${credentials.token}` },
113
+ }, fetchImpl);
114
+ return normalizeQoderCnCliUsage(response, normalizeRpcUsage);
115
+ }
116
+
117
+ module.exports = { readQoderCnCliCredentials, normalizeQoderCnCliUsage, fetchQoderCnCliLimits };
@@ -5,6 +5,7 @@ const fs = require("node:fs");
5
5
  const net = require("node:net");
6
6
  const os = require("node:os");
7
7
  const path = require("node:path");
8
+ const { fetchQoderCnCliLimits } = require("./qoder-cn-cli-limits");
8
9
 
9
10
  const QODER_SITES = {
10
11
  international: {
@@ -16,10 +17,10 @@ const QODER_SITES = {
16
17
  },
17
18
  china: {
18
19
  id: "china",
19
- origin: "https://qoder.com.cn",
20
- usageUrl: "https://qoder.com.cn/api/v2/me/usages/big_model_credits",
20
+ origin: "https://qoder.cn",
21
+ usageUrl: "https://qoder.cn/api/v2/me/usages/big_model_credits",
21
22
  activityUrl: "https://openapi.qoder.com.cn/algo/api/v2/activity",
22
- domains: new Set(["qoder.com.cn", "www.qoder.com.cn"]),
23
+ domains: new Set(["qoder.cn", "www.qoder.cn", "qoder.com.cn", "www.qoder.com.cn"]),
23
24
  },
24
25
  };
25
26
 
@@ -670,6 +671,7 @@ async function fetchQoderLimits({
670
671
  fetchImpl = fetch,
671
672
  rpcRequest = qoderRpcRequest,
672
673
  nowMs = Date.now(),
674
+ cliLimitsRequest,
673
675
  } = {}) {
674
676
  let rpcUsage = null;
675
677
  let rpcAuth = null;
@@ -714,8 +716,22 @@ async function fetchQoderLimits({
714
716
  return rpcUsage;
715
717
  }
716
718
 
717
- // Match the Antigravity last-good behavior: once the local service is
718
- // unavailable, prefer a bounded disk snapshot before any provider fallback.
719
+ let cliError = null;
720
+ if (cliLimitsRequest) {
721
+ try {
722
+ const cliUsage = await cliLimitsRequest({ home, env, fetchImpl, normalizeRpcUsage: normalizeQoderRpcUsage });
723
+ if (cliUsage) {
724
+ if (activityWindow) cliUsage.secondary_window = activityWindow;
725
+ writeQoderLimitsCache(cliUsage, { home, nowMs, namespace: cacheNamespace });
726
+ return cliUsage;
727
+ }
728
+ } catch (error) {
729
+ cliError = error;
730
+ }
731
+ }
732
+
733
+ // Once native sources are unavailable, prefer a bounded disk snapshot before
734
+ // the manual-cookie or local-log fallback.
719
735
  const cachedLimits = readQoderLimitsCache({ home, nowMs, namespace: cacheNamespace });
720
736
  if (cachedLimits) {
721
737
  if (activityWindow) cachedLimits.secondary_window = activityWindow;
@@ -738,7 +754,7 @@ async function fetchQoderLimits({
738
754
  }]
739
755
  : [];
740
756
 
741
- let lastError = null;
757
+ let lastError = cliError;
742
758
  for (const session of sessions) {
743
759
  try {
744
760
  const result = await fetchQoderUsage(session.cookieHeader, session.site, fetchImpl);
@@ -800,11 +816,7 @@ async function fetchQoderLimits({
800
816
  return { configured: false };
801
817
  }
802
818
 
803
- // Qoder CN (国内版) — quota comes from qoder.com.cn and credentials live in the
804
- // separate QoderCN data directory (its own app install). Same mechanics as the
805
- // international fetch, but with the china site, the QoderCN data root (own
806
- // QODER_CN_HOME / QODER_CN_DB_PATH / QODER_CN_LOG_ROOT overrides), and a
807
- // dedicated cache namespace so the two editions never clobber each other.
819
+ // CN has its own CLI credentials, legacy desktop root and cache namespace.
808
820
  function fetchQoderCnLimits(opts = {}) {
809
821
  return fetchQoderLimits({
810
822
  ...opts,
@@ -813,6 +825,7 @@ function fetchQoderCnLimits(opts = {}) {
813
825
  site: QODER_SITES.china,
814
826
  sitePinned: true,
815
827
  cacheNamespace: "cn",
828
+ cliLimitsRequest: fetchQoderCnCliLimits,
816
829
  });
817
830
  }
818
831
 
@@ -5171,7 +5171,7 @@ function isMissingSqliteTableError(error) {
5171
5171
  return messages.some((m) => typeof m === "string" && m.includes("no such table"));
5172
5172
  }
5173
5173
 
5174
- function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqliteOptions = {}, { propagateReadFailure = false } = {}) {
5174
+ function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqliteOptions = {}, { propagateReadFailure = false, includeZeroMessage } = {}) {
5175
5175
  if (!dbPath || !fssync.existsSync(dbPath)) return { messages: [], cursor: null };
5176
5176
 
5177
5177
  const identity = opencodeDbIdentity(dbPath);
@@ -5215,16 +5215,19 @@ function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqlite
5215
5215
  toNonNegativeInt(tokens.reasoning) > 0 ||
5216
5216
  toNonNegativeInt(tokens.cache?.read) > 0 ||
5217
5217
  toNonNegativeInt(tokens.cache?.write) > 0;
5218
- if (!hasTokens) continue;
5219
- if (isV2 && typeof row.directory === "string" && row.directory.trim()) {
5220
- data.path = { ...(typeof data.path === "object" && data.path ? data.path : {}), cwd: row.directory };
5221
- }
5222
- out.push({
5218
+ const message = {
5223
5219
  id: row.id || data.id,
5224
5220
  sessionID: row.session_id || data.sessionID,
5225
5221
  timeUpdated: row.time_updated || 0,
5226
5222
  data,
5227
- });
5223
+ };
5224
+ // Providers can retain zero corrections for previously counted messages.
5225
+ // New empty records remain excluded unless the caller recognizes them.
5226
+ if (!hasTokens && !(typeof includeZeroMessage === "function" && includeZeroMessage(message))) continue;
5227
+ if (isV2 && typeof row.directory === "string" && row.directory.trim()) {
5228
+ data.path = { ...(typeof data.path === "object" && data.path ? data.path : {}), cwd: row.directory };
5229
+ }
5230
+ out.push(message);
5228
5231
  }
5229
5232
  };
5230
5233
 
@@ -5360,8 +5363,8 @@ function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqlite
5360
5363
  }
5361
5364
  }
5362
5365
 
5363
- function readOpencodeDbMessages(dbPath, sqliteOptions = {}, { propagateReadFailure = false } = {}) {
5364
- return readOpencodeDbMessagesIncremental(dbPath, null, sqliteOptions, { propagateReadFailure }).messages;
5366
+ function readOpencodeDbMessages(dbPath, sqliteOptions = {}, { propagateReadFailure = false, includeZeroMessage } = {}) {
5367
+ return readOpencodeDbMessagesIncremental(dbPath, null, sqliteOptions, { propagateReadFailure, includeZeroMessage }).messages;
5365
5368
  }
5366
5369
 
5367
5370
  // mimocode mirrors the user's Claude Code + claude-mem history into its own
@@ -28,12 +28,12 @@ const fsp = require("node:fs/promises");
28
28
  const os = require("node:os");
29
29
  const path = require("node:path");
30
30
  const readline = require("node:readline");
31
- const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage, readZcodeDbMessages, coerceEpochMs, normalizeOpencodeModelFields, deriveOpencodeMessageFingerprint } = require("./rollout");
31
+ const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage, readZcodeDbMessages, coerceEpochMs, normalizeOpencodeModelFields, normalizeOpencodeTokens, deriveOpencodeMessageFingerprint } = require("./rollout");
32
32
  const { parseCodexRolloutFile } = require("./codex-rollout-parser");
33
33
  const { computeRowCost, getModelPricingInfo } = require("./pricing");
34
34
  const { normalizePerformance, mergePerformance, createClaudePerformanceCollector, createCodexPerformanceCollector, createZcodePerformanceCollector } = require("./session-performance");
35
- const { readSqliteJsonRows } = require("./sqlite-reader");
36
- const { resolveZcodeNativeDbPath } = require("./install-resolver");
35
+ const { readSqliteJsonRows, readSqliteJsonRowsAsync } = require("./sqlite-reader");
36
+ const { resolveZcodeNativeDbPath, resolveInstallPaths } = require("./install-resolver");
37
37
  const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
38
38
  const wsl = require("./wsl-probe");
39
39
  const {
@@ -81,7 +81,10 @@ const {
81
81
  // provider retries from repeated user requests.
82
82
  // v20 also invalidates unchanged-stat caches after an observed database lock.
83
83
  // v21 retains the request count when several ZCode events share a minute.
84
- const SIDECAR_VERSION = 21;
84
+ // v22 adds metadata-only OpenCode database sessions.
85
+ // v23 excludes database updates and user records from request durations.
86
+ // v24 keeps OpenCode usage dates at event time and fork history on its ancestor.
87
+ const SIDECAR_VERSION = 24;
85
88
  const EDIT_TOOLS = new Set([
86
89
  "apply_patch",
87
90
  "edit",
@@ -1173,6 +1176,174 @@ async function scanGrokSession(filePath) {
1173
1176
  });
1174
1177
  }
1175
1178
 
1179
+ // Project and title metadata stay local. SQL projects only numeric usage and
1180
+ // identity fields; user/assistant bodies and tool arguments never enter JS.
1181
+ async function readOpencodeSessionInputs(dbPath) {
1182
+ const options = { label: "OpenCode", timeout: 30_000, maxBuffer: 50 * 1024 * 1024, readOnly: true, throwOnReadFailure: true };
1183
+ const query = (sql) => readSqliteJsonRowsAsync(dbPath, sql, options);
1184
+ const tables = new Set((await query("SELECT name FROM sqlite_master WHERE name IN ('session','session_v2','message','session_message')")).map((row) => row.name));
1185
+ const sessionRows = [];
1186
+ for (const table of ["session_v2", "session"]) {
1187
+ if (!tables.has(table)) continue;
1188
+ const columns = new Set((await query(`SELECT name FROM pragma_table_info('${table}')`)).map((row) => row.name));
1189
+ if (!columns.has("id")) continue;
1190
+ const selected = ["id", "parent_id", "directory", "title", "time_created", "time_updated"].filter((column) => columns.has(column));
1191
+ sessionRows.push(...await query(`SELECT ${selected.join(", ")} FROM ${table} ORDER BY id ASC`));
1192
+ }
1193
+ const messages = [];
1194
+ for (const table of ["message", "session_message"]) {
1195
+ if (!tables.has(table)) continue;
1196
+ const role = table === "session_message" ? "type" : "json_extract(data, '$.role')";
1197
+ messages.push(...await query(`SELECT id, session_id, time_created, time_updated, ${role} AS role,
1198
+ CASE WHEN ${role} = 'assistant' THEN json_object(
1199
+ 'tokens', json_extract(data, '$.tokens'),
1200
+ 'modelID', json_extract(data, '$.modelID'),
1201
+ 'providerID', json_extract(data, '$.providerID'),
1202
+ 'model', json_extract(data, '$.model'),
1203
+ 'time', json_extract(data, '$.time'),
1204
+ 'path', json_object('cwd', json_extract(data, '$.path.cwd'))
1205
+ ) END AS metadata
1206
+ FROM ${table} WHERE ${role} IN ('user', 'assistant') ORDER BY time_created ASC, id ASC`));
1207
+ }
1208
+ return { sessionRows, messages };
1209
+ }
1210
+
1211
+ async function scanOpencodeSessions(dbPaths) {
1212
+ const paths = [...new Set((Array.isArray(dbPaths) ? dbPaths : [dbPaths]).filter(Boolean))];
1213
+ const snapshots = [];
1214
+ const inputs = [];
1215
+ try {
1216
+ for (const dbPath of paths) {
1217
+ let effectivePath = dbPath;
1218
+ if (wsl.isUncPath(dbPath)) {
1219
+ try {
1220
+ const snapshot = wsl.snapshotSqliteDb(dbPath);
1221
+ snapshots.push(snapshot);
1222
+ effectivePath = snapshot.path;
1223
+ } catch { /* Direct read determines whether this mount supports SQLite. */ }
1224
+ }
1225
+ inputs.push(await readOpencodeSessionInputs(effectivePath));
1226
+ }
1227
+ const sessionRows = new Map();
1228
+ const messages = new Map();
1229
+ for (const input of inputs) {
1230
+ for (const row of input.sessionRows) if (!sessionRows.has(row.id)) sessionRows.set(row.id, row);
1231
+ for (const row of input.messages) {
1232
+ const key = `${row.session_id}\0${row.id}`;
1233
+ const previous = messages.get(key);
1234
+ if (!previous || Number(row.time_updated) >= Number(previous.time_updated)) messages.set(key, row);
1235
+ }
1236
+ }
1237
+ const groups = new Map();
1238
+ const ancestors = new Map();
1239
+ const ancestorsOf = (id) => {
1240
+ if (ancestors.has(id)) return ancestors.get(id);
1241
+ const known = [];
1242
+ const seen = new Set([id]);
1243
+ let current = sessionRows.get(id);
1244
+ while (current?.parent_id && sessionRows.has(current.parent_id)) {
1245
+ if (seen.has(current.parent_id)) {
1246
+ ancestors.set(id, []);
1247
+ return [];
1248
+ }
1249
+ seen.add(current.parent_id);
1250
+ known.push(current.parent_id);
1251
+ current = sessionRows.get(current.parent_id);
1252
+ }
1253
+ ancestors.set(id, known);
1254
+ return known;
1255
+ };
1256
+ const sortedMessages = [...messages.values()]
1257
+ .sort((a, b) => Number(a.time_created) - Number(b.time_created))
1258
+ .map((row) => {
1259
+ const data = row.metadata ? JSON.parse(row.metadata) : {};
1260
+ const delta = normalizeOpencodeTokens(data.tokens);
1261
+ const fingerprint = delta && deriveOpencodeMessageFingerprint({ msg: data, totals: delta, source: "opencode" });
1262
+ return { row, data, delta, fingerprint };
1263
+ });
1264
+ // A fork may sort before its original when message creation times tie.
1265
+ // Select a known ancestor before aggregating; unknown/cyclic lineage keeps
1266
+ // the existing first-observed owner and never invents a parent connection.
1267
+ const owners = new Map();
1268
+ for (const { row, fingerprint } of sortedMessages) {
1269
+ if (!fingerprint) continue;
1270
+ const owner = owners.get(fingerprint);
1271
+ if (!owner || ancestorsOf(owner).includes(row.session_id)) owners.set(fingerprint, row.session_id);
1272
+ }
1273
+ for (const { row, data, delta, fingerprint } of sortedMessages) {
1274
+ const sessionId = String(row.session_id || "").trim();
1275
+ if (!sessionId) continue;
1276
+ let group = groups.get(sessionId);
1277
+ if (!group) {
1278
+ group = { bounds: emptyBounds(), tokens: emptyTotals(), models: new Map(), buckets: new Map(), spans: [], turns: 0, calls: 0, cwd: null };
1279
+ groups.set(sessionId, group);
1280
+ }
1281
+ if (delta && delta.total_tokens > 0) {
1282
+ const owner = fingerprint && owners.get(fingerprint);
1283
+ if (owner && owner !== sessionId) continue;
1284
+ const model = normalizeSessionModel(normalizeOpencodeModelFields(data).modelId) || "unknown";
1285
+ addTotals(group.tokens, delta);
1286
+ const usage = group.models.get(model) || { model, ...emptyTotals(), usage_events: 0 };
1287
+ addTotals(usage, delta);
1288
+ usage.usage_events += 1;
1289
+ group.models.set(model, usage);
1290
+ group.calls += 1;
1291
+ const timestampMs = coerceEpochMs(data.time?.completed) || coerceEpochMs(data.time?.created) || coerceEpochMs(row.time_created);
1292
+ if (timestampMs) {
1293
+ const timestamp = new Date(Math.floor(timestampMs / 60_000) * 60_000).toISOString();
1294
+ const key = `${timestamp}\0${model}`;
1295
+ const bucket = group.buckets.get(key) || { timestamp, model, ...emptyTotals(), usage_events: 0 };
1296
+ addTotals(bucket, delta);
1297
+ bucket.usage_events += 1;
1298
+ group.buckets.set(key, bucket);
1299
+ }
1300
+ if (!group.cwd && typeof data.path?.cwd === "string") group.cwd = data.path.cwd.trim() || null;
1301
+ }
1302
+ if (row.role === "user") group.turns += 1;
1303
+ const start = coerceEpochMs(data.time?.created) || coerceEpochMs(row.time_created);
1304
+ // DB time_updated can reflect a migration months after the request.
1305
+ // User records are instants; only an explicit assistant completion proves
1306
+ // execution time. Incomplete requests contribute their start point alone.
1307
+ const completed = row.role === "assistant" ? coerceEpochMs(data.time?.completed) : 0;
1308
+ const end = completed >= start ? completed : start;
1309
+ if (start) updateBounds(group.bounds, new Date(start).toISOString());
1310
+ if (end) updateBounds(group.bounds, new Date(end).toISOString());
1311
+ if (start && end >= start) group.spans.push([start, end]);
1312
+ }
1313
+ const records = [];
1314
+ for (const [sessionId, group] of groups) {
1315
+ const row = sessionRows.get(sessionId) || {};
1316
+ const cwd = typeof row.directory === "string" && row.directory.trim() ? row.directory.trim() : group.cwd;
1317
+ const modelUsage = [...group.models.values()];
1318
+ let activeMs = 0;
1319
+ let interval = null;
1320
+ for (const span of group.spans.sort((a, b) => a[0] - b[0])) {
1321
+ if (!interval) interval = [...span];
1322
+ else if (span[0] <= interval[1] + IDLE_GAP_MS) interval[1] = Math.max(interval[1], span[1]);
1323
+ else { activeMs += interval[1] - interval[0]; interval = [...span]; }
1324
+ }
1325
+ if (interval) activeMs += interval[1] - interval[0];
1326
+ records.push(finalizeRecord({
1327
+ version: SIDECAR_VERSION,
1328
+ session_hash: sessionHash("opencode", sessionId), session_id: sessionId,
1329
+ parent_session_id: typeof row.parent_id === "string" && row.parent_id.trim() ? row.parent_id.trim() : null,
1330
+ title: cleanSessionTitle(row.title), source: "opencode",
1331
+ project_key: projectKey(cwd && cwd.replace(/\\/g, "/"), paths[0]), project_ref: cwd || null,
1332
+ model: modelUsage.length > 1 ? "mixed" : (modelUsage[0]?.model || "unknown"),
1333
+ model_usage: modelUsage, usage_buckets: [...group.buckets.values()],
1334
+ ...group.bounds, active_ms: activeMs, tokens: group.tokens,
1335
+ turns: group.turns, edit_turns: 0, retry_turns: 0,
1336
+ usage_events: group.calls, model_calls: group.calls,
1337
+ usage_precision: group.calls ? "reported" : "unavailable", cost_source: "model_pricing",
1338
+ provenance: { source: "local-session-log", confidence: "observed", retry_confidence: "unavailable", content_retained: false, usage: "opencode-db-metadata" },
1339
+ }));
1340
+ }
1341
+ return records;
1342
+ } finally {
1343
+ for (const snapshot of snapshots) snapshot.cleanup();
1344
+ }
1345
+ }
1346
+
1176
1347
  // ZCode (Z.ai's CLI, an OpenCode fork) keeps everything in one SQLite DB at
1177
1348
  // ~/.zcode/cli/db/db.sqlite: a `session` table (id/parent_id/directory/title),
1178
1349
  // a `model_usage` table (per-request counters) and OpenCode's legacy `message`
@@ -1829,7 +2000,13 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
1829
2000
  })
1830
2001
  : { native: zcodeExisting(zcodeNativeDbPath), wsl: null };
1831
2002
  const zcode = [...new Set(Object.values(zcodePaths).filter(Boolean))];
1832
- return { claude, codex: groupCodexFiles([...codex, ...archived]), grok, zcode, incomplete };
2003
+ const opencodeNativeDir = env.OPENCODE_HOME || path.join(env.XDG_DATA_HOME || path.join(home, ".local", "share"), "opencode");
2004
+ const opencodeWslDir = zcodePlatform === "win32" && zcodeProbeWsl && wsl.shouldProbeWsl(env)
2005
+ ? (deps.discoverWslHome || wsl.discoverWslHome)(".local/share/opencode", { env }) : null;
2006
+ const opencodePaths = resolveInstallPaths({ nativeValue: opencodeNativeDir, wslValue: opencodeWslDir }, env, { platform: zcodePlatform });
2007
+ const opencode = [...new Set(Object.values(opencodePaths).filter(Boolean)
2008
+ .map((dir) => path.join(dir, "opencode.db")).filter((dbPath) => fs.existsSync(dbPath)))];
2009
+ return { claude, codex: groupCodexFiles([...codex, ...archived]), grok, zcode, opencode, incomplete };
1833
2010
  }
1834
2011
 
1835
2012
  // Warn once per unreadable directory per process: the dashboard polls this
@@ -1934,7 +2111,7 @@ function analyticsEntryStatKey(source, filePath) {
1934
2111
  const primary = filePaths[0];
1935
2112
  return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(primary)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(primary)) || "missing"}`;
1936
2113
  }
1937
- if (source === "zcode") {
2114
+ if (source === "zcode" || source === "opencode") {
1938
2115
  // A WAL-mode db can absorb writes without touching the main file's mtime,
1939
2116
  // so the sidecar files are part of the db's identity for cache checks.
1940
2117
  return `${sessionStat}|sidecars:${filePaths.map((filePath) =>
@@ -1967,13 +2144,15 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1967
2144
  // sidecar and metadata. TTL, signature and per-file reuse must all retry it.
1968
2145
  const incompleteZcodePath = `${metaPath}.zcode-incomplete`;
1969
2146
  const retryZcodeScan = fs.existsSync(incompleteZcodePath);
2147
+ const incompleteOpencodePath = `${metaPath}.opencode-incomplete`;
2148
+ const retryOpencodeScan = fs.existsSync(incompleteOpencodePath);
1970
2149
  let previousMeta = null;
1971
2150
  if (!force) {
1972
2151
  try {
1973
2152
  previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
1974
2153
  const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
1975
2154
  if (
1976
- !retryZcodeScan && previousMeta.version === SIDECAR_VERSION &&
2155
+ !retryZcodeScan && !retryOpencodeScan && previousMeta.version === SIDECAR_VERSION &&
1977
2156
  Number.isFinite(checkedAt) &&
1978
2157
  Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
1979
2158
  ) {
@@ -2007,9 +2186,9 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2007
2186
  ...discovered.grok.map(grokSummaryPathFor),
2008
2187
  ...discovered.grok.map(grokSignalsPathFor),
2009
2188
  // db + its WAL sidecars: the trio is one logical file for change detection.
2010
- ...(discovered.zcode || []).flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
2189
+ ...[...(discovered.zcode || []), ...(discovered.opencode || [])].flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
2011
2190
  ]);
2012
- if (!force && !retryZcodeScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
2191
+ if (!force && !retryZcodeScan && !retryOpencodeScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
2013
2192
  await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
2014
2193
  return readSidecar(sidecarPath);
2015
2194
  }
@@ -2030,26 +2209,29 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2030
2209
  ...discovered.codex.map((filePaths) => ({ source: "codex", filePath: filePaths, scan: scanCodexSession })),
2031
2210
  ...discovered.grok.map((filePath) => ({ source: "grok", filePath, scan: scanGrokSession })),
2032
2211
  ...(discovered.zcode.length ? [{ source: "zcode", filePath: discovered.zcode, scan: scanZcodeSessions }] : []),
2212
+ ...(discovered.opencode.length ? [{ source: "opencode", filePath: discovered.opencode, scan: scanOpencodeSessions }] : []),
2033
2213
  ];
2034
2214
  // Files we could not turn into a row (permission denied, half-written line,
2035
2215
  // vanished mid-scan). Swallowing these silently made sessions disappear with
2036
2216
  // no signal at all; count them so the API can say so.
2037
2217
  let skippedFiles = 0;
2038
2218
  let incompleteZcodeScan = false;
2219
+ let incompleteOpencodeScan = false;
2039
2220
  for (const entry of entries) {
2040
2221
  const cacheKey = sessionFileCacheKey(entry.source, entry.filePath);
2041
2222
  const statKey = analyticsEntryStatKey(entry.source, entry.filePath);
2042
2223
  if (!statKey) {
2043
2224
  skippedFiles += 1;
2044
2225
  if (entry.source === "zcode") incompleteZcodeScan = true;
2226
+ if (entry.source === "opencode") incompleteOpencodeScan = true;
2045
2227
  continue;
2046
2228
  }
2047
2229
  let rows = null;
2048
- if (!force && !(entry.source === "zcode" && retryZcodeScan) && previousFiles[cacheKey]?.stat_key === statKey) {
2049
- if (entry.source === "zcode") {
2230
+ if (!force && !(entry.source === "zcode" && retryZcodeScan) && !(entry.source === "opencode" && retryOpencodeScan) && previousFiles[cacheKey]?.stat_key === statKey) {
2231
+ if (entry.source === "zcode" || entry.source === "opencode") {
2050
2232
  // One db scans to many cached rows; recover them all from the previous
2051
2233
  // pass. An empty recovery means the sidecar lost its rows — rescan.
2052
- const cached = previousRows.filter((row) => row?.source === "zcode");
2234
+ const cached = previousRows.filter((row) => row?.source === entry.source);
2053
2235
  rows = cached.length ? cached : null;
2054
2236
  } else {
2055
2237
  const row = previousRowsByFile.get(cacheKey) || null;
@@ -2065,6 +2247,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2065
2247
  // pretend it never existed either.
2066
2248
  skippedFiles += 1;
2067
2249
  if (entry.source === "zcode") incompleteZcodeScan = true;
2250
+ if (entry.source === "opencode") incompleteOpencodeScan = true;
2068
2251
  if (!process.env.NODE_TEST_CONTEXT) {
2069
2252
  console.warn(`[session-analytics] skipped ${entry.source} session file: ${error?.message || error}`);
2070
2253
  }
@@ -2078,8 +2261,8 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2078
2261
  // so key each by the db path plus its session id; a bare session id as a
2079
2262
  // separate path element would be path.resolve()d against the process cwd
2080
2263
  // and never match across runs.
2081
- row._cache_key = entry.source === "zcode" && row.session_id
2082
- ? `${cacheKey}#${sessionHash("zcode", row.session_id)}`
2264
+ row._cache_key = (entry.source === "zcode" || entry.source === "opencode") && row.session_id
2265
+ ? `${cacheKey}#${sessionHash(entry.source, row.session_id)}`
2083
2266
  : cacheKey;
2084
2267
  sessions.push(row);
2085
2268
  }
@@ -2093,10 +2276,15 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2093
2276
  sessions.push(...readSidecar(sidecarPath).filter((row) =>
2094
2277
  row.source === "zcode" && row.version === SIDECAR_VERSION));
2095
2278
  }
2279
+ if (incompleteOpencodeScan) {
2280
+ await writeAtomic(incompleteOpencodePath, "1\n");
2281
+ sessions.push(...readSidecar(sidecarPath).filter((row) =>
2282
+ row.source === "opencode" && row.version === SIDECAR_VERSION));
2283
+ }
2096
2284
  sessions.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
2097
2285
  // A failed database scan is an incomplete inventory. Keep the last complete
2098
2286
  // snapshot and signature so an unchanged database is retried next build.
2099
- if (incompleteDirs.length === 0 && !incompleteZcodeScan) {
2287
+ if (incompleteDirs.length === 0 && !incompleteZcodeScan && !incompleteOpencodeScan) {
2100
2288
  const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
2101
2289
  await writeAtomic(sidecarPath, content);
2102
2290
  const generatedAt = new Date().toISOString();
@@ -2108,6 +2296,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2108
2296
  files: nextFiles,
2109
2297
  })}\n`);
2110
2298
  await fsp.rm(incompleteZcodePath, { force: true });
2299
+ await fsp.rm(incompleteOpencodePath, { force: true });
2111
2300
  }
2112
2301
  // Non-enumerable so the array still behaves exactly like a plain row list
2113
2302
  // for every existing caller (map/filter/JSON of the rows is unaffected).
@@ -2285,7 +2474,7 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
2285
2474
  if (key === primaryModel) agg.retries += finite(row.retry_turns);
2286
2475
  byModel.set(key, agg);
2287
2476
  }
2288
- if ((row.source === "codex" || row.source === "zcode") && row.parent_session_hash) {
2477
+ if ((row.source === "codex" || row.source === "zcode" || row.source === "opencode") && row.parent_session_hash) {
2289
2478
  const name = row.agent_role || row.agent_nickname || "subagent";
2290
2479
  const sub = subagents.get(name) || {
2291
2480
  name,
@@ -2304,7 +2493,7 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
2304
2493
  }
2305
2494
  // Codex and ZCode children are first-class rows. A spawn call in the parent is
2306
2495
  // a control-plane event, not token usage, so do not mix in an estimate.
2307
- if (row.source === "codex" || row.source === "zcode") continue;
2496
+ if (row.source === "codex" || row.source === "zcode" || row.source === "opencode") continue;
2308
2497
  for (const [name, calls] of Object.entries(row.subagent_types || {})) {
2309
2498
  const sub = subagents.get(name) || { name, calls: 0, sessions: 0, total_tokens: 0, cost_usd: 0 };
2310
2499
  sub.calls += finite(calls);
@@ -2388,6 +2577,7 @@ function resumeCommandFor(source, sessionId) {
2388
2577
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(id)) return null;
2389
2578
  if (source === "claude") return `claude --resume ${id}`;
2390
2579
  if (source === "codex") return `codex resume ${id}`;
2580
+ if (source === "opencode") return `opencode --session ${id}`;
2391
2581
  // Grok Build: `grok --resume <session-id>` (also accepts a title). The
2392
2582
  // command must still be run from the session's project_ref cwd.
2393
2583
  if (source === "grok") return `grok --resume ${id}`;
@@ -2578,7 +2768,7 @@ function annotateThreadUsage(rows) {
2578
2768
  const identity = (row, id = row.session_id) => `${row.source}\u0000${id}`;
2579
2769
 
2580
2770
  for (const row of rows || []) {
2581
- if ((row?.source !== "codex" && row?.source !== "zcode") || typeof row.session_id !== "string" || !row.session_id) continue;
2771
+ if ((row?.source !== "codex" && row?.source !== "zcode" && row?.source !== "opencode") || typeof row.session_id !== "string" || !row.session_id) continue;
2582
2772
  const existing = byId.get(identity(row));
2583
2773
  if (existing && existing !== row) {
2584
2774
  existing.parent_link_conflict = true;
@@ -2656,7 +2846,7 @@ function annotateThreadUsage(rows) {
2656
2846
  }
2657
2847
 
2658
2848
  for (const row of rows || []) {
2659
- const threadAware = (row.source === "codex" || row.source === "zcode")
2849
+ const threadAware = (row.source === "codex" || row.source === "zcode" || row.source === "opencode")
2660
2850
  && typeof row.session_id === "string" && row.session_id;
2661
2851
  row.own_total_tokens = finite(row.total_tokens);
2662
2852
  row.own_cost_usd = finite(row.cost_usd);
@@ -2730,6 +2920,7 @@ module.exports = {
2730
2920
  scanCodexSession,
2731
2921
  scanGrokSession,
2732
2922
  scanZcodeSessions,
2923
+ scanOpencodeSessions,
2733
2924
  buildSessionAnalytics,
2734
2925
  summarizeSessions,
2735
2926
  listSessionsForBrowser,