From 127da516ac25c936e94b6234754701b22390bb03 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 23 Oct 2026 13:44:37 +0700 Subject: [PATCH] chore(quota): drop the Kimi Code quota reader (Kimi already left the catalog) Redone on v2.8.22 from 3179d25 (feat/grok-usage, never merged). --- CLAUDE.md | 2 +- client/src/components/StatusBar.tsx | 1 - client/src/lib/types.ts | 2 +- docs/API.md | 2 +- scripts/test-server.js | 3 +- server/__tests__/quota.test.js | 65 +----------------------- server/__tests__/server-s3-quota.test.js | 15 ++---- server/lib/quota.js | 61 ++-------------------- 8 files changed, 14 insertions(+), 137 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b7f8ddbe..e2b1d570 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.23 (2026-10-26) — OpenCode and Grok Build left the catalog (`opencode-usage.js` gone, a Grok payload on the Claude hook route is dropped)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.23 (2026-10-26) — OpenCode and Grok Build left the catalog, the Kimi quota reader too (`opencode-usage.js` gone, a Grok payload on the Claude hook route is dropped)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. diff --git a/client/src/components/StatusBar.tsx b/client/src/components/StatusBar.tsx index 774299af..f7c5cdd2 100644 --- a/client/src/components/StatusBar.tsx +++ b/client/src/components/StatusBar.tsx @@ -50,7 +50,6 @@ const QUOTA_TOOL_LABEL: Record = { codex: "Codex", antigravity: "Antigravity", cursor: "Cursor", - kimi: "Kimi", }; function localDate(d = new Date()): string { diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index fa2cf7f3..42324fdc 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1216,7 +1216,7 @@ export interface CostResult { /** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */ export interface ToolQuota { - id: "claude" | "codex" | "antigravity" | "cursor" | "kimi"; + id: "claude" | "codex" | "antigravity" | "cursor"; /** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100. * `group` names the model pool a window belongs to (Antigravity's). */ windows: Array<{ diff --git a/docs/API.md b/docs/API.md index 172c32b0..5efbb0a1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2578,7 +2578,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2 GET /api/pricing/quota[?force=1] ``` -How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor"|"kimi", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=::` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). **Kimi Code** reads `$KIMI_CODE_HOME/credentials/kimi-code.json` (default `~/.kimi-code`) and sends it only to `api.kimi.com`'s `GET /coding/v1/usages` (`5h`/`wk`/`mo` from `limit_5h`/`limit_7d`/`limit_month_total`). It is used only while valid and never refreshed (`error: "ETOKENEXPIRED"`; a Kimi refresh would rotate the CLI's refresh token and sign it out); no file, no entry. This reader follows Orca's (MIT), cached like Claude. `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `d`/`h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`, `~/.kimi-code`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before. +How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=::` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `d`/`h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before. #### Analytics ```http diff --git a/scripts/test-server.js b/scripts/test-server.js index bba69a3f..25fe66b1 100644 --- a/scripts/test-server.js +++ b/scripts/test-server.js @@ -51,13 +51,12 @@ const env = { // No suite may fetch LiteLLM's price list (reset-pricing syncs at once). AGENTHUB_PRICING_SYNC: "0", // Nor run the developer's real agy (it may update itself) or send their - // real Cursor or Kimi login for the quota. + // real Cursor login for the quota. AGENTHUB_AGY_USAGE: "0", // Nor refresh (spend) a real Claude pool login: its refresh token is single use. DASHBOARD_POOL_REFRESH_MS: "0", XDG_CONFIG_HOME: tmuxDir, XDG_DATA_HOME: tmuxDir, - KIMI_CODE_HOME: tmuxDir, DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"), // Never this machine's server-mode S3 install: its system unit would become // "the" service manager and its server.conf would send user changes to sudo. diff --git a/server/__tests__/quota.test.js b/server/__tests__/quota.test.js index a65b68f9..ccb49da8 100644 --- a/server/__tests__/quota.test.js +++ b/server/__tests__/quota.test.js @@ -6,9 +6,7 @@ * with its own token only while it is valid, never refreshed, and never shown * with the usage of a token that belongs to a different account; each * tool's plan name; the Cursor CLI's login asked of cursor.com only while - * valid; Antigravity's `agy --print /usage` pools, never on an old agy; and - * the Kimi Code login, sent only to its own host while valid, never - * refreshed. + * valid; and Antigravity's `agy --print /usage` pools, never on an old agy. * @author Nguyễn Ngọc Trí Vĩ */ @@ -22,12 +20,6 @@ process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync( ); // Nor run the machine's real agy: only the Antigravity test's fake one runs. process.env.AGENTHUB_AGY_USAGE = "0"; -// Nor its real Kimi login. -for (const k of ["KIMI_CODE_HOME"]) { - process.env[k] = require("node:fs").mkdtempSync( - require("node:path").join(require("node:os").tmpdir(), "quota-home-") - ); -} test("windowLabel names the plan windows", () => { assert.strictEqual(windowLabel(300), "5h"); @@ -415,58 +407,3 @@ function stubFetch(answers) { }; return { asked, restore: () => (global.fetch = prev) }; } - -test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => { - const fs = require("node:fs"); - const path = require("node:path"); - const { quotas, __resetCache } = require("../lib/quota"); - const dir = path.join(process.env.KIMI_CODE_HOME, "credentials"); - fs.mkdirSync(dir, { recursive: true }); - const file = path.join(dir, "kimi-code.json"); - const f = stubFetch({ - "https://api.kimi.com/coding/v1/usages": [ - 200, - { - usages: { - limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" }, - limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" }, - limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" }, - limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" }, - }, - }, - ], - }); - const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi"); - try { - __resetCache(); - assert.strictEqual(await kimi(), undefined); - const exp = Math.floor(Date.now() / 1000) + 600; - fs.writeFileSync( - file, - JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp }) - ); - __resetCache(); - const row = await kimi(); - assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString()); - assert.deepStrictEqual( - row.windows.map((w) => [w.label, Math.round(w.usedPercent)]), - [ - ["5h", 30], - ["wk", 20], - ["mo", 40], - ] - ); - assert.deepStrictEqual( - f.asked.filter((a) => a.includes("kimi")), - ["https://api.kimi.com/coding/v1/usages Bearer tok-k"] - ); - fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 })); - __resetCache(); - assert.strictEqual((await kimi()).error, "ETOKENEXPIRED"); - assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1); - } finally { - f.restore(); - fs.rmSync(file, { force: true }); - __resetCache(); - } -}); diff --git a/server/__tests__/server-s3-quota.test.js b/server/__tests__/server-s3-quota.test.js index 88fdb7c9..48b7b55a 100644 --- a/server/__tests__/server-s3-quota.test.js +++ b/server/__tests__/server-s3-quota.test.js @@ -1,7 +1,7 @@ /** * @file server-s3-quota.test.js * @description S3 plan, P4: plan quota per member home. `quotas({home})` - * reads Claude, Codex, Cursor and Kimi Code from that + * reads Claude, Codex and Cursor from that * Linux account's home only — never the host's logins, never agy — and only * files the member owns (review fix F3: a FIFO or another account's file is * not read). `GET /api/pricing/quota` gives a developer or an analyst their @@ -32,7 +32,6 @@ process.env.CLAUDE_HOME = path.join(HOST, ".claude"); process.env.CODEX_HOME = path.join(HOST, ".codex"); process.env.XDG_CONFIG_HOME = path.join(HOST, ".config"); process.env.AGENTHUB_CURSOR_DIR = path.join(HOST, ".cursor"); -process.env.KIMI_CODE_HOME = path.join(HOST, ".kimi-code"); const { createApp, startServer } = require("../index"); const auth = require("../lib/auth"); @@ -73,10 +72,6 @@ function seedHome(home, who, { codexPercent }) { write(path.join(home, ".cursor", "cli-config.json"), { authInfo: { email: `${who}@cursor`, userId: "u1" }, }); - write(path.join(home, ".kimi-code", "credentials", "kimi-code.json"), { - access_token: `${who}-kimi`, - expires_at: Math.floor(LATER / 1000), - }); } let asked = []; @@ -136,12 +131,12 @@ describe("quotas({home}) reads a member's own logins", () => { __resetCache(); asked = []; const { tools } = await quotas({ home: MIA }); - assert.deepEqual(tools.map((t) => t.id).sort(), ["claude", "codex", "cursor", "kimi"]); + assert.deepEqual(tools.map((t) => t.id).sort(), ["claude", "codex", "cursor"]); const codex = tools.find((t) => t.id === "codex"); assert.equal(codex.account, "mia@codex"); assert.equal(codex.windows[0].usedPercent, 21); assert.equal(tools.find((t) => t.id === "cursor").account, "mia@cursor"); - assert.ok(asked.length >= 4, asked.join(",")); + assert.ok(asked.length >= 3, asked.join(",")); assert.ok( asked.every((a) => a.includes("mia-")), `only mia's tokens leave: ${asked.join(",")}` @@ -161,9 +156,9 @@ describe("quotas({home}) reads a member's own logins", () => { }); it("reads no file the member does not own, and never blocks on a FIFO", async () => { - fs.mkdirSync(path.join(BOB, ".kimi-code", "credentials"), { recursive: true }); + fs.mkdirSync(path.join(BOB, ".config", "cursor"), { recursive: true }); require("node:child_process").execFileSync("mkfifo", [ - path.join(BOB, ".kimi-code", "credentials", "kimi-code.json"), + path.join(BOB, ".config", "cursor", "auth.json"), ]); __resetCache(); assert.deepEqual((await quotas({ home: BOB })).tools, []); diff --git a/server/lib/quota.js b/server/lib/quota.js index 4788c2a3..f0293fe8 100644 --- a/server/lib/quota.js +++ b/server/lib/quota.js @@ -34,12 +34,6 @@ * names no account and no plan. Cached like Claude; a failed run shows * the last figure with its `asOf`. Running agy may let it update itself; * `AGENTHUB_AGY_USAGE=0` never runs it. - * - **Kimi Code**: `$KIMI_CODE_HOME/credentials/kimi-code.json` (default - * `~/.kimi-code`), sent only to `api.kimi.com`'s `GET /coding/v1/usages`: - * `5h`, `wk`, `mo` from `limit_5h`/`limit_7d`/`limit_month_total`. - * Kimi's token is used only while valid and never refreshed (Kimi rotates - * its refresh token: a refresh here would sign the CLI out). This reader is - * Orca's (github.com/stablyai/orca, MIT), trimmed. * * Each tool also carries its signed-in `account` (an email), its `plan` (the * subscription's name as the tool reports it: Claude's `subscriptionType`, @@ -50,8 +44,8 @@ * * Server mode with S3 (`quotas({home})`): a member's plan is read from THEIR * Linux account's home only — `.claude`, `.codex`, `.config/cursor` + - * `.cursor`, `.kimi-code`, never the host's env overrides — through `run-as`'s owner-checked reads (F3). No - * saved Claude profiles (the Profiles modal is the host's) and no agy: its + * `.cursor`, never the host's env overrides — through `run-as`'s + * owner-checked reads (F3). No saved Claude profiles (the Profiles modal is the host's) and no agy: its * `/usage` needs the self-updating binary, so it stays host-only. * * Only percentages, window lengths, reset/expiry times, the plan's name and @@ -79,7 +73,6 @@ const TAIL_BYTES = 512 * 1024; const CODEX_FILES = 5; const CURSOR_USAGE_URL = "https://cursor.com/api/usage-summary"; const TIMEOUT_MS = 10_000; -const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; const AGY_TIMEOUT_MS = 60_000; const AGY_MIN_VERSION = [1, 1, 11]; const AGY_WINDOW = { "5h": "5h", weekly: "wk" }; @@ -350,7 +343,6 @@ function toolDirs(home) { codex: home ? path.join(home, ".codex") : getCodexHome(), cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"), cursor: home ? path.join(home, ".cursor") : getCursorDir(), - kimi: env("KIMI_CODE_HOME") || path.join(h, ".kimi-code"), }; } @@ -464,50 +456,6 @@ async function antigravityQuota() { } } -const isoOf = (v) => { - const ms = Date.parse(v); - return Number.isFinite(ms) ? new Date(ms).toISOString() : null; -}; - -/** A Bearer GET to a tool's own host: its windows, or the row's error. */ -async function bearerWindows(who, url, token, headers, toWindows) { - try { - const res = await fetch(url, { - headers: { Accept: "application/json", Authorization: `Bearer ${token}`, ...headers }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!res.ok) { - const expired = res.status === 401 || res.status === 403; - return { ...who, windows: [], error: expired ? "ETOKENEXPIRED" : "EUPSTREAM" }; - } - return { ...who, ...toWindows(await res.json()) }; - } catch { - return { ...who, windows: [], error: "EUPSTREAM" }; - } -} - -/** Kimi Code's OAuth token and its managed-plan usage. */ -async function kimiQuota(dirs = toolDirs()) { - const cred = readJson(path.join(dirs.kimi, "credentials", "kimi-code.json")); - if (typeof cred?.access_token !== "string" || !cred.access_token) return null; - const exp = Number(cred.expires_at) * 1000; - const who = { id: "kimi", active: true, expiresAt: exp ? iso(exp) : null }; - if (exp && !fresh(exp)) return { ...who, windows: [], error: "ETOKENEXPIRED" }; - return bearerWindows(who, KIMI_USAGE_URL, cred.access_token, {}, (body) => ({ - windows: [ - ["5h", body?.usages?.limit_5h], - ["wk", body?.usages?.limit_7d], - ["mo", body?.usages?.limit_month_total], - ] - .filter(([, u]) => u && Number.isFinite(Number(u.used_ratio))) - .map(([label, u]) => ({ - label, - usedPercent: Number(u.used_ratio) * 100, - resetsAt: isoOf(u.reset_time), - })), - })); -} - /** * Every tool's quota row. `home`: a member's Linux home (S3) — that account's * logins only, and no agy; omitted, the host's. @@ -515,13 +463,12 @@ async function kimiQuota(dirs = toolDirs()) { async function quotas({ force = false, home = null } = {}) { const dirs = toolDirs(home); const key = (k) => (home ? `${home}|${k}` : k); - const [claude, cursor, antigravity, kimi] = await Promise.all([ + const [claude, cursor, antigravity] = await Promise.all([ claudeQuotas(force, home), cached(key("cursor"), force, () => cursorQuota(dirs)), home ? null : cached("antigravity", force, antigravityQuota), - cached(key("kimi"), force, () => kimiQuota(dirs)), ]); - const tools = [...claude, codexQuota(dirs.codex), antigravity, cursor, kimi].filter(Boolean); + const tools = [...claude, codexQuota(dirs.codex), antigravity, cursor].filter(Boolean); return { tools, checkedAt: new Date().toISOString() }; }