diff --git a/catalog.json b/catalog.json index dfed967..b8bcd86 100644 --- a/catalog.json +++ b/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "providerId": "official", "name": "PI-Desktop Official Plugins", - "updatedAt": "2026-09-14T04:38:39Z", + "updatedAt": "2026-09-17T17:21:16Z", "homepage": "https://github.com/vastsa/pi-desktop-plugins", "plugins": [ { @@ -1027,16 +1027,17 @@ "safetyNotes": "Reads only local metadata, always read-only, always on this device. Usage comes from the tool metadata files PI-Desktop, Claude Code, Codex and OpenCode already write; appearance (theme, language, active plugin theme CSS) and provider display names come from PI-Desktop's own local records. It never reads message text, tool arguments, project paths or credentials, never keeps a full session id (only an 8-character prefix), never writes anywhere except its own plugin settings, and makes no network request.", "versions": [ { - "version": "0.4.8", - "publishedAt": "2026-09-12T17:13:25Z", - "changelog": "Release 0.4.8: Fold PI-Desktop completed-turn totals from the host database into the dashboard, so subagent spend that never lands on parent message.usage still appears without double-counting transcript rows. Settings no longer has a Usage page; this panel is the heatmap. Release 0.4.8\uff1a\u628a\u5bbf\u4e3b\u5df2\u5b8c\u6210\u56de\u5408\u7684 token \u5408\u8ba1\u6298\u5165\u4eea\u8868\u76d8\uff0c\u8f6c\u5f55\u672c message.usage \u672a\u8ba1\u5165\u7684\u5b50\u667a\u80fd\u4f53\u7528\u91cf\u4f1a\u8865\u4e0a\u4e14\u4e0d\u4e0e JSONL \u91cd\u590d\u3002\u8bbe\u7f6e\u9875\u4e0d\u518d\u6709\u7528\u91cf\u5165\u53e3\uff0c\u70ed\u529b\u56fe\u4ee5\u672c\u9762\u677f\u4e3a\u51c6\u3002 Release 0.4.7: Fix the extra empty right-side rail on Windows by keeping the stable scrollbar gutter on the dashboard scroller instead of reserving it again on the root viewport. Release 0.4.7\uff1a\u4fee\u590d Windows \u53f3\u4fa7\u591a\u51fa\u7a7a\u767d\u4fa7\u680f\u7684\u95ee\u9898\uff1a\u4ec5\u5728\u4eea\u8868\u76d8\u6eda\u52a8\u5bb9\u5668\u4fdd\u7559\u7a33\u5b9a\u6eda\u52a8\u6761\u69fd\u4f4d\uff0c\u4e0d\u518d\u5728\u6839\u89c6\u53e3\u91cd\u590d\u9884\u7559\u3002 Release 0.4.6: Fix the titlebar's appearance and refresh buttons overlapping the host window-control capsule. The panel paints through the 46px band under v3 chrome, but the capsule still floats over the top-right corner, so both buttons sat under it and stopped being clickable. The titlebar now reserves the capsule's 104px corner (kept at narrow widths too, where the gutter override previously reset it), the appearance menu stays anchored to the buttons that open it, and a long title truncates instead of running under the reserve. Release 0.4.6\uff1a\u4fee\u590d\u6807\u9898\u680f\u7684\u5916\u89c2\u4e0e\u5237\u65b0\u6309\u94ae\u4e0e\u5bbf\u4e3b\u7a97\u53e3\u63a7\u5236\u80f6\u56ca\u91cd\u53e0\u7684\u95ee\u9898\u3002v3 chrome \u4e0b\u9762\u677f\u53ef\u7ed8\u5236\u5230 46px \u9876\u680f\uff0c\u4f46\u80f6\u56ca\u4ecd\u6d6e\u4e8e\u53f3\u4e0a\u89d2\uff0c\u5bfc\u81f4\u4e24\u4e2a\u6309\u94ae\u88ab\u906e\u6321\u4e14\u65e0\u6cd5\u70b9\u51fb\u3002\u6807\u9898\u680f\u73b0\u4e3a\u80f6\u56ca\u4fdd\u7559 104px \u53f3\u4fa7\u7a7a\u95f4\uff08\u7a84\u5bbd\u5ea6\u4e0b\u7684 gutter \u8986\u76d6\u6b64\u524d\u4f1a\u91cd\u7f6e\u8be5\u4fdd\u7559\u503c\uff0c\u73b0\u5df2\u4fdd\u7559\uff09\uff0c\u5916\u89c2\u83dc\u5355\u7ee7\u7eed\u5bf9\u9f50\u89e6\u53d1\u6309\u94ae\uff0c\u8fc7\u957f\u6807\u9898\u4ee5\u7701\u7565\u53f7\u622a\u65ad\u3002 Release 0.4.5: Migrates the panel to v3 paint-through chrome so top-band controls remain clickable while blank space remains draggable. Release 0.4.4: Fix the window-control capsule being drawn with default black-on-white colors on a panel's very first open \u2014 with no cached appearance, data-theme stayed unset until the host answered, so the host's one-shot color snapshot at DOMContentLoaded missed; boot now falls back to the OS palette synchronously while content stays cloaked. Also stop startAppearanceWatch from resetting the appearance fingerprint, which made every open-command run rewrite hostAppearance. Release 0.4.4\uff1a\u4fee\u590d\u9996\u6b21\u6253\u5f00\u9762\u677f\u65f6\u7a97\u53e3\u63a7\u5236\u80f6\u56ca\u914d\u8272\u9519\u8bef\u7684\u95ee\u9898\u2014\u2014\u65e0\u7f13\u5b58\u5916\u89c2\u65f6 data-theme \u672a\u8bbe\u7f6e\uff0c\u5bbf\u4e3b\u5728 DOMContentLoaded \u7684\u53d6\u8272\u5feb\u7167\u62ff\u5230\u6d4f\u89c8\u5668\u9ed8\u8ba4\u9ed1/\u767d\uff0c\u73b0\u540c\u6b65\u56de\u9000 OS \u914d\u8272\uff08\u5185\u5bb9\u4ecd\u6309 data-booting \u9690\u85cf\u81f3\u771f\u5b9e\u5916\u89c2\u5230\u8fbe\uff09\uff1b\u4fee\u590d startAppearanceWatch \u91cd\u7f6e\u5916\u89c2\u6307\u7eb9\u5bfc\u81f4\u6bcf\u6b21\u6253\u5f00\u547d\u4ee4\u90fd\u91cd\u590d\u5199\u5165 hostAppearance \u7684\u95ee\u9898\u3002 Release 0.4.3: Align the panel with PI-Desktop's cross-platform 46px host drag band and minimal three-button window-control capsule. Fixes two visible faults: elements toggled with the hidden attribute were being kept on screen by their own CSS display, so the loading skeleton stayed over real data, an empty chip strip and the custom date inputs were always drawn, and the last scan sentence lingered; and the from/to fields now always show the window the selected range covers, so picking 7D/90D/ALL keeps them in step and switching to Custom inherits what you were looking at. Includes 0.4.1's scan progress with a real file count, stale-scan recovery, visibility-based polling and debounced source watching, on top of 0.4.0's theme/language following, filters and streak layer.", + "version": "0.5.0", + "publishedAt": "2026-09-17T17:21:16Z", + "changelog": "Release 0.5.0: The PI-Desktop data source now goes through the official pi.usage.listTurns contract (requires a PI-Desktop build with that API; declare the usage.read permission) instead of reading the host's pi.sqlite directly, which also fixes the counting gap where soft-deleted sessions were still included. Hosts without the contract automatically fall back to the previous database path. Release 0.5.0\uff1aPI-Desktop \u6570\u636e\u6e90\u8fc1\u79fb\u5230\u6b63\u5f0f\u7684 pi.usage.listTurns \u5951\u7ea6\uff08\u9700\u5bbf\u4e3b\u63d0\u4f9b\u8be5 API\uff1b\u58f0\u660e usage.read \u6743\u9650\uff09\uff0c\u4e0d\u518d\u76f4\u8bfb\u5bbf\u4e3b pi.sqlite\uff0c\u540c\u65f6\u4fee\u590d\u8f6f\u5220\u9664\u4f1a\u8bdd\u4ecd\u88ab\u8ba1\u5165\u7684\u53e3\u5f84\u7f3a\u53e3\uff1b\u65e0\u8be5\u5951\u7ea6\u7684\u65e7\u5bbf\u4e3b\u81ea\u52a8\u56de\u9000\u539f\u6570\u636e\u5e93\u8def\u5f84\u3002 Release 0.4.8: Fold PI-Desktop completed-turn totals from the host database into the dashboard, so subagent spend that never lands on parent message.usage still appears without double-counting transcript rows. Settings no longer has a Usage page; this panel is the heatmap. Release 0.4.8\uff1a\u628a\u5bbf\u4e3b\u5df2\u5b8c\u6210\u56de\u5408\u7684 token \u5408\u8ba1\u6298\u5165\u4eea\u8868\u76d8\uff0c\u8f6c\u5f55\u672c message.usage \u672a\u8ba1\u5165\u7684\u5b50\u667a\u80fd\u4f53\u7528\u91cf\u4f1a\u8865\u4e0a\u4e14\u4e0d\u4e0e JSONL \u91cd\u590d\u3002\u8bbe\u7f6e\u9875\u4e0d\u518d\u6709\u7528\u91cf\u5165\u53e3\uff0c\u70ed\u529b\u56fe\u4ee5\u672c\u9762\u677f\u4e3a\u51c6\u3002 Release 0.4.7: Fix the extra empty right-side rail on Windows by keeping the stable scrollbar gutter on the dashboard scroller instead of reserving it again on the root viewport. Release 0.4.7\uff1a\u4fee\u590d Windows \u53f3\u4fa7\u591a\u51fa\u7a7a\u767d\u4fa7\u680f\u7684\u95ee\u9898\uff1a\u4ec5\u5728\u4eea\u8868\u76d8\u6eda\u52a8\u5bb9\u5668\u4fdd\u7559\u7a33\u5b9a\u6eda\u52a8\u6761\u69fd\u4f4d\uff0c\u4e0d\u518d\u5728\u6839\u89c6\u53e3\u91cd\u590d\u9884\u7559\u3002 Release 0.4.6: Fix the titlebar's appearance and refresh buttons overlapping the host window-control capsule. The panel paints through the 46px band under v3 chrome, but the capsule still floats over the top-right corner, so both buttons sat under it and stopped being clickable. The titlebar now reserves the capsule's 104px corner (kept at narrow widths too, where the gutter override previously reset it), the appearance menu stays anchored to the buttons that open it, and a long title truncates instead of running under the reserve. Release 0.4.6\uff1a\u4fee\u590d\u6807\u9898\u680f\u7684\u5916\u89c2\u4e0e\u5237\u65b0\u6309\u94ae\u4e0e\u5bbf\u4e3b\u7a97\u53e3\u63a7\u5236\u80f6\u56ca\u91cd\u53e0\u7684\u95ee\u9898\u3002v3 chrome \u4e0b\u9762\u677f\u53ef\u7ed8\u5236\u5230 46px \u9876\u680f\uff0c\u4f46\u80f6\u56ca\u4ecd\u6d6e\u4e8e\u53f3\u4e0a\u89d2\uff0c\u5bfc\u81f4\u4e24\u4e2a\u6309\u94ae\u88ab\u906e\u6321\u4e14\u65e0\u6cd5\u70b9\u51fb\u3002\u6807\u9898\u680f\u73b0\u4e3a\u80f6\u56ca\u4fdd\u7559 104px \u53f3\u4fa7\u7a7a\u95f4\uff08\u7a84\u5bbd\u5ea6\u4e0b\u7684 gutter \u8986\u76d6\u6b64\u524d\u4f1a\u91cd\u7f6e\u8be5\u4fdd\u7559\u503c\uff0c\u73b0\u5df2\u4fdd\u7559\uff09\uff0c\u5916\u89c2\u83dc\u5355\u7ee7\u7eed\u5bf9\u9f50\u89e6\u53d1\u6309\u94ae\uff0c\u8fc7\u957f\u6807\u9898\u4ee5\u7701\u7565\u53f7\u622a\u65ad\u3002 Release 0.4.5: Migrates the panel to v3 paint-through chrome so top-band controls remain clickable while blank space remains draggable. Release 0.4.4: Fix the window-control capsule being drawn with default black-on-white colors on a panel's very first open \u2014 with no cached appearance, data-theme stayed unset until the host answered, so the host's one-shot color snapshot at DOMContentLoaded missed; boot now falls back to the OS palette synchronously while content stays cloaked. Also stop startAppearanceWatch from resetting the appearance fingerprint, which made every open-command run rewrite hostAppearance. Release 0.4.4\uff1a\u4fee\u590d\u9996\u6b21\u6253\u5f00\u9762\u677f\u65f6\u7a97\u53e3\u63a7\u5236\u80f6\u56ca\u914d\u8272\u9519\u8bef\u7684\u95ee\u9898\u2014\u2014\u65e0\u7f13\u5b58\u5916\u89c2\u65f6 data-theme \u672a\u8bbe\u7f6e\uff0c\u5bbf\u4e3b\u5728 DOMContentLoaded \u7684\u53d6\u8272\u5feb\u7167\u62ff\u5230\u6d4f\u89c8\u5668\u9ed8\u8ba4\u9ed1/\u767d\uff0c\u73b0\u540c\u6b65\u56de\u9000 OS \u914d\u8272\uff08\u5185\u5bb9\u4ecd\u6309 data-booting \u9690\u85cf\u81f3\u771f\u5b9e\u5916\u89c2\u5230\u8fbe\uff09\uff1b\u4fee\u590d startAppearanceWatch \u91cd\u7f6e\u5916\u89c2\u6307\u7eb9\u5bfc\u81f4\u6bcf\u6b21\u6253\u5f00\u547d\u4ee4\u90fd\u91cd\u590d\u5199\u5165 hostAppearance \u7684\u95ee\u9898\u3002 Release 0.4.3: Align the panel with PI-Desktop's cross-platform 46px host drag band and minimal three-button window-control capsule. Fixes two visible faults: elements toggled with the hidden attribute were being kept on screen by their own CSS display, so the loading skeleton stayed over real data, an empty chip strip and the custom date inputs were always drawn, and the last scan sentence lingered; and the from/to fields now always show the window the selected range covers, so picking 7D/90D/ALL keeps them in step and switching to Custom inherits what you were looking at. Includes 0.4.1's scan progress with a real file count, stale-scan recovery, visibility-based polling and debounced source watching, on top of 0.4.0's theme/language following, filters and streak layer.", "minPiDesktop": ">=0.2.9", - "shasum": "220506deb22c680074fb65856ad0b769435246a7a850184e8226f602bc9eb427", - "url": "packages/pi.token-insights-0.4.8.piplug", - "sizeBytes": 171505, + "shasum": "f252d06f15a7c8f1a5acb1ac78edf2a276fcc385cfec1a888bcf5a2bfb0b0db0", + "url": "packages/pi.token-insights-0.5.0.piplug", + "sizeBytes": 177205, "permissions": [ "ui.panel", - "agent.tool.register" + "agent.tool.register", + "usage.read" ], "fs": {} } diff --git a/packages/pi.token-insights-0.5.0.piplug b/packages/pi.token-insights-0.5.0.piplug new file mode 100644 index 0000000..cdbd699 Binary files /dev/null and b/packages/pi.token-insights-0.5.0.piplug differ diff --git a/plugins/pi.token-insights/host-read.js b/plugins/pi.token-insights/host-read.js index cba1e77..63a4853 100644 --- a/plugins/pi.token-insights/host-read.js +++ b/plugins/pi.token-insights/host-read.js @@ -10,9 +10,11 @@ * them here and hands the result to the panel through plugin settings. * * Everything here is strictly read-only and stays on this device: + * pi.usage.listTurns (official host contract) → completed-turn usage * ~/.pi-desktop/pi.sqlite kv(ns='app', key='app') → { theme, language } * providers(id, name) → display names - * turns (completed usage) → subagent-inclusive remainders + * turns (completed usage) → subagent-inclusive + * remainders, only on hosts without the API * ~/.pi-desktop/plugins/registry.json + the theme plugin's manifest + CSS * * No message text, tool arguments, session ids or project paths are touched, no @@ -288,12 +290,135 @@ function readHostAppearance(hostRoot) { return value; } +/** + * Completed-turn usage from the host. Prefers the official read-only + * `pi.usage.listTurns` contract (which already excludes soft-deleted sessions + * and carries no message text); hosts without it fall back to the direct + * database read below. Either way only token counts and ids leave this module. + */ +async function readCompletedTurnUsage(hostRoot, pi) { + const listTurns = typeof pi !== "undefined" ? pi?.usage?.listTurns : undefined; + if (typeof listTurns === "function") { + try { + return await readCompletedTurnUsageViaApi(pi); + } catch (error) { + // An older or momentarily unhappy host must never cost the panel its + // PI-Desktop numbers, so the database read stays as a safety net. + warnApiFallbackOnce(error); + } + } + return readCompletedTurnUsageFromDb(hostRoot); +} + +/** The largest window the contract accepts, so walks never exceed one window. */ +const API_WINDOW_MS = 365 * 24 * 60 * 60 * 1000; +/** A window with no completed turns means everything older is done; 8 windows ≈ 8 years. */ +const API_MAX_WINDOWS = 8; +/** The contract's page ceiling; the fewest round trips per window. */ +const API_PAGE_LIMIT = 500; + +let apiFallbackWarned = false; + +function warnApiFallbackOnce(error) { + if (apiFallbackWarned) return; + apiFallbackWarned = true; + try { + console.warn( + `[pi.token-insights] pi.usage.listTurns failed (${String(error?.message || error)}); reading the host database instead.`, + ); + } catch { + /* a broken console must not break the scan */ + } +} + +/** + * Walks completed turns backward through the official `pi.usage.listTurns` + * contract: at most 365 days per window, every page of each window via the + * keyset cursor, and the walk stops at the first window with no rows at all. + * A `turnId` set dedupes across windows so a turn straddling a boundary can + * never be counted twice. Returns the same `{ events, diagnostics }` shape as + * the database reader, with `filesScanned` counting pages fetched. + */ +async function readCompletedTurnUsageViaApi(pi) { + const result = { + events: [], + diagnostics: { sourceId: "pi-desktop", filesScanned: 0, filesSkipped: 0, malformedLines: 0, usageMessages: 0 }, + }; + const seenTurnIds = new Set(); + let toMs = Date.now(); + for (let windowIndex = 0; windowIndex < API_MAX_WINDOWS; windowIndex += 1) { + // Inclusive endpoints, so the span stays within the contract's 365-day cap. + const fromMs = toMs - API_WINDOW_MS + 1; + let cursor = null; + let rowsInWindow = 0; + do { + const input = { fromMs, toMs, limit: API_PAGE_LIMIT }; + // `cursor` is optional in the contract; a strict host may reject null. + if (cursor) input.cursor = cursor; + const page = await pi.usage.listTurns(input); + result.diagnostics.filesScanned += 1; + for (const row of page?.turns || []) { + rowsInWindow += 1; + const turnId = String(row?.turnId || ""); + if (turnId) { + if (seenTurnIds.has(turnId)) continue; + seenTurnIds.add(turnId); + } + const event = turnEventFromApiRow(row); + if (!event) continue; + result.events.push(event); + result.diagnostics.usageMessages += 1; + } + cursor = page?.nextCursor || null; + } while (cursor); + if (rowsInWindow === 0) break; + toMs = fromMs - 1; + } + return result; +} + +/** + * One contract row → the event shape the aggregator already consumes. + * The contract carries no `total`, so it is the sum of the five components; + * rows with no tokens at all are skipped, exactly like the database reader. + */ +function turnEventFromApiRow(row) { + const n = (value) => { + const parsedNumber = Number(value); + return Number.isFinite(parsedNumber) ? Math.max(0, parsedNumber) : 0; + }; + const input = n(row?.inputTokens); + const output = n(row?.outputTokens); + const cacheRead = n(row?.cacheReadTokens); + const cacheWrite = n(row?.cacheWriteTokens); + const reasoning = n(row?.reasoningTokens); + if (!input && !output && !cacheRead && !cacheWrite && !reasoning) return null; + const timestamp = Number(row?.endedAt); + const sessionId = String(row?.sessionId || "").trim(); + if (!sessionId || !Number.isFinite(timestamp)) return null; + return { + sourceId: "pi-desktop", + sessionId: `pi-desktop:${sessionId}`, + timestamp, + modelId: String(row.modelId || "Unknown model"), + providerId: String(row.providerId || "Unknown provider"), + tokens: { + input, + output, + cacheRead, + cacheWrite, + reasoning, + total: input + output + cacheRead + cacheWrite + reasoning, + }, + }; +} + /** * Completed-turn usage from the host database. Only token columns and ids; - * no message text. Used to fold subagent spend that never landed on - * transcript `message.usage` into the PI-Desktop scan. + * no message text. Fallback for hosts without `pi.usage.listTurns` — the API + * path is preferred because it also excludes soft-deleted sessions. */ -function readCompletedTurnUsage(hostRoot) { +function readCompletedTurnUsageFromDb(hostRoot) { const result = { events: [], diagnostics: { sourceId: "pi-desktop", filesScanned: 0, filesSkipped: 0, malformedLines: 0, usageMessages: 0 }, @@ -362,6 +487,8 @@ function tokensFromTurnRow(row) { module.exports = { hostRootFromDataPath, readCompletedTurnUsage, + readCompletedTurnUsageFromDb, + readCompletedTurnUsageViaApi, readHostAppearance, readProviderLabels, __test: { diff --git a/plugins/pi.token-insights/main.js b/plugins/pi.token-insights/main.js index d378c8b..bc264dd 100644 --- a/plugins/pi.token-insights/main.js +++ b/plugins/pi.token-insights/main.js @@ -93,7 +93,9 @@ async function scanEvents(progress) { scanCodexDirectory(roots.codex, progress), scanOpenCodeDirectory(roots.openCode, progress), ]); - const turns = readCompletedTurnUsage(host); + // The official pi.usage.listTurns contract when the host offers it, with the + // direct database read kept inside as the fallback for older hosts. + const turns = await readCompletedTurnUsage(host, pi); const piDesktop = mergePiDesktopTurnRemainder(piJsonl, turns.events); if (turns.diagnostics?.filesScanned) { piDesktop.diagnostics.filesScanned += turns.diagnostics.filesScanned; diff --git a/plugins/pi.token-insights/manifest.json b/plugins/pi.token-insights/manifest.json index bf5e8c1..a102ec2 100644 --- a/plugins/pi.token-insights/manifest.json +++ b/plugins/pi.token-insights/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "pi.token-insights", "name": "Token Insights", - "version": "0.4.8", + "version": "0.5.0", "description": "A private local dashboard for the tokens you spend across PI-Desktop, Claude Code, Codex and OpenCode: follows the app's theme and language, filters by tool, model, provider, date range or keyword, and keeps a streak and milestone worth looking at.", "i18n": { "en": { @@ -23,7 +23,7 @@ "developer-tools", "official" ], - "changelog": "Release 0.4.8: Fold PI-Desktop completed-turn totals from the host database into the dashboard, so subagent spend that never lands on parent message.usage still appears without double-counting transcript rows. Settings no longer has a Usage page; this panel is the heatmap. Release 0.4.8:把宿主已完成回合的 token 合计折入仪表盘,转录本 message.usage 未计入的子智能体用量会补上且不与 JSONL 重复。设置页不再有用量入口,热力图以本面板为准。 Release 0.4.7: Fix the extra empty right-side rail on Windows by keeping the stable scrollbar gutter on the dashboard scroller instead of reserving it again on the root viewport. Release 0.4.7:修复 Windows 右侧多出空白侧栏的问题:仅在仪表盘滚动容器保留稳定滚动条槽位,不再在根视口重复预留。 Release 0.4.6: Fix the titlebar's appearance and refresh buttons overlapping the host window-control capsule. The panel paints through the 46px band under v3 chrome, but the capsule still floats over the top-right corner, so both buttons sat under it and stopped being clickable. The titlebar now reserves the capsule's 104px corner (kept at narrow widths too, where the gutter override previously reset it), the appearance menu stays anchored to the buttons that open it, and a long title truncates instead of running under the reserve. Release 0.4.6:修复标题栏的外观与刷新按钮与宿主窗口控制胶囊重叠的问题。v3 chrome 下面板可绘制到 46px 顶栏,但胶囊仍浮于右上角,导致两个按钮被遮挡且无法点击。标题栏现为胶囊保留 104px 右侧空间(窄宽度下的 gutter 覆盖此前会重置该保留值,现已保留),外观菜单继续对齐触发按钮,过长标题以省略号截断。 Release 0.4.5: Migrates the panel to v3 paint-through chrome so top-band controls remain clickable while blank space remains draggable. Release 0.4.4: Fix the window-control capsule being drawn with default black-on-white colors on a panel's very first open — with no cached appearance, data-theme stayed unset until the host answered, so the host's one-shot color snapshot at DOMContentLoaded missed; boot now falls back to the OS palette synchronously while content stays cloaked. Also stop startAppearanceWatch from resetting the appearance fingerprint, which made every open-command run rewrite hostAppearance. Release 0.4.4:修复首次打开面板时窗口控制胶囊配色错误的问题——无缓存外观时 data-theme 未设置,宿主在 DOMContentLoaded 的取色快照拿到浏览器默认黑/白,现同步回退 OS 配色(内容仍按 data-booting 隐藏至真实外观到达);修复 startAppearanceWatch 重置外观指纹导致每次打开命令都重复写入 hostAppearance 的问题。 Release 0.4.3: Align the panel with PI-Desktop's cross-platform 46px host drag band and minimal three-button window-control capsule. Fixes two visible faults: elements toggled with the hidden attribute were being kept on screen by their own CSS display, so the loading skeleton stayed over real data, an empty chip strip and the custom date inputs were always drawn, and the last scan sentence lingered; and the from/to fields now always show the window the selected range covers, so picking 7D/90D/ALL keeps them in step and switching to Custom inherits what you were looking at. Includes 0.4.1's scan progress with a real file count, stale-scan recovery, visibility-based polling and debounced source watching, on top of 0.4.0's theme/language following, filters and streak layer.", + "changelog": "Release 0.5.0: The PI-Desktop data source now goes through the official pi.usage.listTurns contract (requires a PI-Desktop build with that API; declare the usage.read permission) instead of reading the host's pi.sqlite directly, which also fixes the counting gap where soft-deleted sessions were still included. Hosts without the contract automatically fall back to the previous database path. Release 0.5.0:PI-Desktop 数据源迁移到正式的 pi.usage.listTurns 契约(需宿主提供该 API;声明 usage.read 权限),不再直读宿主 pi.sqlite,同时修复软删除会话仍被计入的口径缺口;无该契约的旧宿主自动回退原数据库路径。 Release 0.4.8: Fold PI-Desktop completed-turn totals from the host database into the dashboard, so subagent spend that never lands on parent message.usage still appears without double-counting transcript rows. Settings no longer has a Usage page; this panel is the heatmap. Release 0.4.8:把宿主已完成回合的 token 合计折入仪表盘,转录本 message.usage 未计入的子智能体用量会补上且不与 JSONL 重复。设置页不再有用量入口,热力图以本面板为准。 Release 0.4.7: Fix the extra empty right-side rail on Windows by keeping the stable scrollbar gutter on the dashboard scroller instead of reserving it again on the root viewport. Release 0.4.7:修复 Windows 右侧多出空白侧栏的问题:仅在仪表盘滚动容器保留稳定滚动条槽位,不再在根视口重复预留。 Release 0.4.6: Fix the titlebar's appearance and refresh buttons overlapping the host window-control capsule. The panel paints through the 46px band under v3 chrome, but the capsule still floats over the top-right corner, so both buttons sat under it and stopped being clickable. The titlebar now reserves the capsule's 104px corner (kept at narrow widths too, where the gutter override previously reset it), the appearance menu stays anchored to the buttons that open it, and a long title truncates instead of running under the reserve. Release 0.4.6:修复标题栏的外观与刷新按钮与宿主窗口控制胶囊重叠的问题。v3 chrome 下面板可绘制到 46px 顶栏,但胶囊仍浮于右上角,导致两个按钮被遮挡且无法点击。标题栏现为胶囊保留 104px 右侧空间(窄宽度下的 gutter 覆盖此前会重置该保留值,现已保留),外观菜单继续对齐触发按钮,过长标题以省略号截断。 Release 0.4.5: Migrates the panel to v3 paint-through chrome so top-band controls remain clickable while blank space remains draggable. Release 0.4.4: Fix the window-control capsule being drawn with default black-on-white colors on a panel's very first open — with no cached appearance, data-theme stayed unset until the host answered, so the host's one-shot color snapshot at DOMContentLoaded missed; boot now falls back to the OS palette synchronously while content stays cloaked. Also stop startAppearanceWatch from resetting the appearance fingerprint, which made every open-command run rewrite hostAppearance. Release 0.4.4:修复首次打开面板时窗口控制胶囊配色错误的问题——无缓存外观时 data-theme 未设置,宿主在 DOMContentLoaded 的取色快照拿到浏览器默认黑/白,现同步回退 OS 配色(内容仍按 data-booting 隐藏至真实外观到达);修复 startAppearanceWatch 重置外观指纹导致每次打开命令都重复写入 hostAppearance 的问题。 Release 0.4.3: Align the panel with PI-Desktop's cross-platform 46px host drag band and minimal three-button window-control capsule. Fixes two visible faults: elements toggled with the hidden attribute were being kept on screen by their own CSS display, so the loading skeleton stayed over real data, an empty chip strip and the custom date inputs were always drawn, and the last scan sentence lingered; and the from/to fields now always show the window the selected range covers, so picking 7D/90D/ALL keeps them in step and switching to Custom inherits what you were looking at. Includes 0.4.1's scan progress with a real file count, stale-scan recovery, visibility-based polling and debounced source watching, on top of 0.4.0's theme/language following, filters and streak layer.", "safetyNotes": "Reads only local metadata, always read-only, always on this device. Usage comes from the tool metadata files PI-Desktop, Claude Code, Codex and OpenCode already write; appearance (theme, language, active plugin theme CSS) and provider display names come from PI-Desktop's own local records. It never reads message text, tool arguments, project paths or credentials, never keeps a full session id (only an 8-character prefix), never writes anywhere except its own plugin settings, and makes no network request.", "ui": { "panel": "renderer/index.html", @@ -104,7 +104,8 @@ }, "permissions": [ "ui.panel", - "agent.tool.register" + "agent.tool.register", + "usage.read" ], "engines": { "piDesktop": ">=0.2.9" diff --git a/tests/token-insights.test.mjs b/tests/token-insights.test.mjs index 31406c3..2b45047 100644 --- a/tests/token-insights.test.mjs +++ b/tests/token-insights.test.mjs @@ -12,6 +12,7 @@ const plugin = require("../plugins/pi.token-insights/main.js"); const manifest = JSON.parse( readFileSync(join(here, "../plugins/pi.token-insights/manifest.json"), "utf8"), ); +const hostReadSource = readFileSync(join(here, "../plugins/pi.token-insights/host-read.js"), "utf8"); const panelSource = readFileSync(join(here, "../plugins/pi.token-insights/renderer/panel.js"), "utf8"); const panelCss = readFileSync(join(here, "../plugins/pi.token-insights/renderer/panel.css"), "utf8"); const panelPolishCss = readFileSync( @@ -45,19 +46,34 @@ function usageRecord({ createdAt, modelId = "alpha", providerId = "local", usage }; } -function waitForBackgroundScan() { - return new Promise((resolve) => setTimeout(resolve, 10)); +/** + * The load scan finishes in the background, and a fixed sleep races it on slow + * filesystems, so poll for the terminal scanState the scan publishes instead. + */ +async function waitForBackgroundScan(scanStatus) { + const deadline = Date.now() + 5_000; + while (scanStatus() !== "ready" && scanStatus() !== "failed" && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + const status = scanStatus(); + if (status !== "ready" && status !== "failed") throw new Error("background scan did not settle"); } -test("manifest declares the independent scanner and minimal host permissions", () => { - assert.equal(manifest.version, "0.4.8"); - assert.deepEqual(manifest.permissions, ["ui.panel", "agent.tool.register"]); +test("manifest declares the official usage contract permission and minimal host access", () => { + assert.equal(manifest.version, "0.5.0"); + assert.deepEqual(manifest.permissions, ["ui.panel", "agent.tool.register", "usage.read"]); assert.equal(manifest.engines.piDesktop, ">=0.2.9"); assert.deepEqual( manifest.contributes.agentTools[0].schema.properties.groupBy.enum, ["model", "provider", "source", "day", "session"], ); - assert.doesNotMatch(JSON.stringify(manifest), /usage\.read|project rankings|price table/i); + // The July 2025 snapshot banned usage.read because the host's old usage API + // had been removed. The official pi.usage.listTurns contract is back, so the + // manifest must declare it and the host reader must go through that contract + // instead of reading pi.sqlite directly. + assert.match(JSON.stringify(manifest), /usage\.read/); + assert.match(hostReadSource, /pi\.usage\.listTurns/); + assert.doesNotMatch(JSON.stringify(manifest), /project rankings|price table/i); assert.match(panelSource, /function compact\(value\)/); assert.match(panelSource, /sourcesTitle: "Tools"/); assert.match(panelSource, /sourcesTitle: "工具"/); @@ -231,7 +247,7 @@ test("completed-turn rows fill a session-day with no transcript", () => { assert.equal(merged.events[0].tokens.total, 10); }); -test("readCompletedTurnUsage maps turn rows without message text", () => { +test("readCompletedTurnUsage falls back to the host database and maps turn rows without message text", async () => { let sqlite; try { sqlite = require("node:sqlite"); @@ -278,7 +294,8 @@ test("readCompletedTurnUsage maps turn rows without message text", () => { endedAt, ); db.close(); - const result = plugin.__test.readCompletedTurnUsage(fixture.root); + // No `pi` argument: the wrapper must route to the database reader. + const result = await plugin.__test.readCompletedTurnUsage(fixture.root); assert.equal(result.events.length, 1); assert.equal(result.events[0].sessionId, "pi-desktop:sess-uuid"); assert.deepEqual(result.events[0].tokens, { @@ -295,6 +312,216 @@ test("readCompletedTurnUsage maps turn rows without message text", () => { } }); +function apiTurn({ + turnId, + sessionId, + endedAt, + modelId, + providerId, + input = 0, + output = 0, + cacheRead = 0, + cacheWrite = 0, + reasoning = 0, +}) { + return { + turnId, + sessionId, + sessionTitle: "must not leak", + projectId: "must-not-leak", + providerId, + modelId, + startedAt: endedAt - 1, + endedAt, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, + reasoningTokens: reasoning, + }; +} + +/** A fake host whose listTurns replays pages in call order and records inputs. */ +function createFakeUsageHost(pages) { + const calls = []; + return { + calls, + pi: { + usage: { + listTurns: async (input) => { + calls.push({ ...input }); + const page = pages[calls.length - 1]; + if (!page) throw new Error(`unexpected listTurns call #${calls.length}`); + return page; + }, + }, + }, + }; +} + +test("readCompletedTurnUsage walks listTurns pages, dedupes, and stops at an empty window", async () => { + const endedAt = Date.now() - 1_000; + const host = createFakeUsageHost([ + // Window 1, page 1: one normal row, one all-zero row, then a cursor. + { + turns: [ + apiTurn({ + turnId: "t1", + sessionId: "sess-1", + endedAt, + modelId: "alpha", + providerId: "local", + input: 10, + output: 5, + cacheRead: 2, + cacheWrite: 1, + reasoning: 3, + }), + apiTurn({ turnId: "t-zero", sessionId: "sess-zero", endedAt }), + ], + nextCursor: "page-2", + }, + // Window 1, page 2: t1 repeats (as if straddling a boundary) and must be + // dropped; a row without a session id is unmappable and dropped too. + { + turns: [ + apiTurn({ + turnId: "t1", + sessionId: "sess-1", + endedAt, + modelId: "alpha", + providerId: "local", + input: 10, + output: 5, + cacheRead: 2, + cacheWrite: 1, + reasoning: 3, + }), + apiTurn({ turnId: "t2", sessionId: "sess-2", endedAt: endedAt - 5, input: 1 }), + apiTurn({ turnId: "t-nosess", sessionId: "", endedAt, input: 7 }), + ], + nextCursor: null, + }, + // Window 2 comes back empty: the backward walk stops here. + { turns: [], nextCursor: null }, + ]); + + const result = await plugin.__test.readCompletedTurnUsage("/nonexistent-host", host.pi); + assert.equal(result.events.length, 2); + assert.equal(result.events[0].sourceId, "pi-desktop"); + assert.equal(result.events[0].sessionId, "pi-desktop:sess-1"); + assert.equal(result.events[0].timestamp, endedAt); + assert.equal(result.events[0].modelId, "alpha"); + assert.equal(result.events[0].providerId, "local"); + // The contract carries no total, so it is the sum of the five components. + assert.deepEqual(result.events[0].tokens, { + input: 10, + output: 5, + cacheRead: 2, + cacheWrite: 1, + reasoning: 3, + total: 21, + }); + assert.equal(result.events[1].sessionId, "pi-desktop:sess-2"); + assert.equal(result.events[1].modelId, "Unknown model"); + assert.equal(result.events[1].providerId, "Unknown provider"); + assert.equal(result.events[1].tokens.total, 1); + // Contract-only fields (session titles, project ids) never reach the events. + assert.doesNotMatch(JSON.stringify(result), /must not leak|must-not-leak/i); + + // Pagination: 500-row pages within a contract-legal window, the cursor + // threaded back, the next window abutting the previous one, and no calls + // after the empty window. + const dayMs = 24 * 60 * 60 * 1000; + assert.equal(host.calls.length, 3); + for (const call of host.calls) { + assert.equal(call.limit, 500); + assert.ok(call.toMs - call.fromMs < 365 * dayMs); + } + assert.equal(host.calls[0].cursor, undefined); + assert.equal(host.calls[1].cursor, "page-2"); + assert.equal(host.calls[1].fromMs, host.calls[0].fromMs); + assert.equal(host.calls[1].toMs, host.calls[0].toMs); + assert.equal(host.calls[2].toMs, host.calls[0].fromMs - 1); + assert.equal(host.calls[2].cursor, undefined); + + assert.deepEqual(result.diagnostics, { + sourceId: "pi-desktop", + filesScanned: 3, + filesSkipped: 0, + malformedLines: 0, + usageMessages: 2, + }); +}); + +test("the listTurns walk stops after eight windows even when every window has rows", async () => { + const pages = []; + for (let windowIndex = 0; windowIndex < 12; windowIndex += 1) { + pages.push({ + turns: [ + apiTurn({ turnId: `t-${windowIndex}`, sessionId: `sess-${windowIndex}`, endedAt: 1_000, input: 1 }), + ], + nextCursor: null, + }); + } + const host = createFakeUsageHost(pages); + const result = await plugin.__test.readCompletedTurnUsage("/nonexistent-host", host.pi); + assert.equal(host.calls.length, 8); + assert.equal(result.events.length, 8); +}); + +test("readCompletedTurnUsage recovers through the database when the usage contract is missing or throws", async () => { + let sqlite; + try { + sqlite = require("node:sqlite"); + } catch { + return; + } + if (!sqlite?.DatabaseSync) return; + const fixture = createFixture(); + try { + const db = new sqlite.DatabaseSync(join(fixture.root, "pi.sqlite")); + db.exec(`CREATE TABLE turns ( + id TEXT PRIMARY KEY, + session_id TEXT, + status TEXT, + provider_id TEXT, + model_id TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + usage_json TEXT, + started_at INTEGER, + ended_at INTEGER + )`); + const endedAt = new Date(2026, 6, 30, 12).getTime(); + db.prepare( + `INSERT INTO turns (id, session_id, status, provider_id, model_id, input_tokens, output_tokens, usage_json, started_at, ended_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run("t1", "sess-fallback", "completed", "local", "alpha", 10, 5, JSON.stringify({ totalTokens: 17 }), endedAt - 1, endedAt); + db.close(); + + // A host without pi.usage at all routes straight to the database reader. + const withoutUsage = await plugin.__test.readCompletedTurnUsage(fixture.root, {}); + assert.equal(withoutUsage.events.length, 1); + assert.equal(withoutUsage.events[0].sessionId, "pi-desktop:sess-fallback"); + assert.equal(withoutUsage.events[0].tokens.total, 17); + + // A host whose listTurns throws must never cost the panel its numbers. + const broken = { + usage: { + listTurns: async () => { + throw new Error("no contract on this host"); + }, + }, + }; + const recovered = await plugin.__test.readCompletedTurnUsage(fixture.root, broken); + assert.equal(recovered.events.length, 1); + assert.equal(recovered.events[0].sessionId, "pi-desktop:sess-fallback"); + } finally { + fixture.cleanup(); + } +}); + test("adapters normalize Claude Code, Codex, and OpenCode without cumulative Codex double-counting", async () => { const fixture = createFixture(); const claude = join(fixture.root, "claude-projects", "project-a"); @@ -398,6 +625,7 @@ test("summary groups models, providers, sessions, time buckets, and streaks", () test("on-load writes a snapshot before opening the panel and the tool groups by provider", async () => { const fixture = createFixture(); const calls = { registered: [], unregistered: [], timeline: [], facts: [] }; + let scanStatus = null; const previousPi = globalThis.pi; try { mkdirSync(fixture.dataPath, { recursive: true }); @@ -422,6 +650,7 @@ test("on-load writes a snapshot before opening the panel and the tool groups by setSettings: async (value) => { calls.timeline.push("settings"); if (value.usageFacts) calls.facts.push(value.usageFacts); + if (value.scanState?.status) scanStatus = value.scanState.status; }, }, commands: { @@ -439,7 +668,7 @@ test("on-load writes a snapshot before opening the panel and the tool groups by }; await plugin.onLoad(); - await waitForBackgroundScan(); + await waitForBackgroundScan(() => scanStatus); const command = calls.registered.find((item) => item.type === "command").value; const tool = calls.registered.find((item) => item.type === "tool").value; calls.timeline.length = 0;