From 5defdd3e3f659a877081584ea89f285a4a329120 Mon Sep 17 00:00:00 2001 From: Jan Heinsohn Date: Mon, 28 Sep 2026 12:09:48 +0200 Subject: [PATCH 1/2] feat(hoosage): sync linked projects across hosts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 + README.md | 6 +- package.json | 10 ++ src/core/shared-usage-sync.ts | 252 +++++++++++++++++++++++++++++++++ src/extension.ts | 168 +++++++++++++++++++++- src/webview/app.ts | 143 ++++++++++++++----- test/shared-usage-sync.test.ts | 95 +++++++++++++ 7 files changed, 632 insertions(+), 48 deletions(-) create mode 100644 src/core/shared-usage-sync.ts create mode 100644 test/shared-usage-sync.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5546425..ef7dd3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- **hoosage:** opt-in shared-folder sync for linking usage across local and remote hosts + ## 0.12.1 (2026-09-23) ### Bug Fixes diff --git a/README.md b/README.md index 0b7628f..2e878d1 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,9 @@ A VS Code project appears when its workspace is open in a trusted window with ho ### Imported Chat history On startup in a local window, hoosage scans this profile's VS Code chat transcripts (`workspaceStorage//chatSessions/*.jsonl|*.json` and `globalStorage/emptyWindowChatSessions`) in the background and imports per-request usage it can recover: start time, duration, model, output tokens, Copilot-reported credits (shown as reported cost) and error state. Input tokens are only imported when a request made a single model call, because transcripts only store the last round's prompt size. Older transcripts without token data are imported with unknown tokens and cost. Requests copied between sessions are deduplicated by model message ID. Since transcripts and live OTel spans do not share request IDs, the dashboard counts imported requests only before the first day of live Chat capture for that project. Later imported entries remain stored but are excluded from totals to avoid double counting. Hoosage transiently reads complete transcript files to extract usage, then persists only allowlisted metadata in `projects//chat-history.json`; prompts, responses, tool data and file content are never saved or exported by hoosage. Transcript files larger than 64 MiB are skipped. Chats opened without a folder appear under **Copilot Chat (no folder)**. Chats VS Code has already deleted, and workspaces never opened in this profile, cannot be recovered. -A project is a VS Code folder workspace, identified by a hash of its full workspace URI. All windows share an authenticated local collector. Each extension host registers its `vscode.env.sessionId` against that workspace; incoming OTLP resource `session.id` selects the registered project. Unknown windows are discarded, never assigned to the active editor. Registrations cannot be rebound to another workspace. Identically named folders, clones and worktrees remain separate. A Windows `file://` workspace and a WSL `vscode-remote://` workspace have different identities, even if their folder names match; hoosage shows this distinction in the dashboard and does not merge by name. All registered projects on the same host and profile can be compared. +A project is a VS Code folder workspace, identified by a hash of its full workspace URI. All windows share an authenticated local collector. Each extension host registers its `vscode.env.sessionId` against that workspace; incoming OTLP resource `session.id` selects the registered project. Unknown windows are discarded, never assigned to the active editor. Registrations cannot be rebound to another workspace. Identically named folders, clones and worktrees remain separate. A Windows `file://` workspace and a WSL `vscode-remote://` workspace have different identities, even if their folder names match; hoosage never merges them by name. All registered projects on the same host and profile can be compared. + +To aggregate one known checkout across Windows and WSL, run **hoosage: Configure Shared Project Sync** in each host window. Enter the absolute path to the same physical shared folder using each host's path format (for example, `C:\Users\you\hoosage-share` on Windows and `/mnt/c/Users/you/hoosage-share` in WSL), then enter the same group ID. The folder must be reachable from both hosts and outside all repositories. This is opt-in per workspace; without it, all usage remains on its original host. The shared folder contains only validated, allowlisted usage fields, with no prompts, responses, code, paths or repository URLs. Group IDs are hashed for folder names. The sync is local file exchange, not a network service; changes appear during the regular five-second refresh. Use **hoosage: Disable Shared Project Sync** on each host to stop future exchange. Previously shared records are not deleted automatically and remain readable until removed from the shared folder. WSL must still be installed/configured to collect Chat spans on its own host; sync cannot recover spans the remote collector never received. A saved or multi-root workspace is one **Workspace group**. Copilot's telemetry cannot reliably divide a single request across roots, so hoosage does not invent that precision. Open roots in separate VS Code windows to track them independently. @@ -129,7 +131,7 @@ On explicit setup, hoosage changes these **user-level** Copilot settings: The listener binds only to `127.0.0.1`. It accepts OTLP JSON/protobuf and gzip, requires a random collector URL token, rejects browser-origin requests and limits request size. Metrics and logs are acknowledged and discarded. Only completed `chat` spans are retained, after an explicit allowlist removes prompts, responses, code, tool arguments, repository URLs and other attributes. -History lives under `globalStorageUri/projects//`; shared collector configuration and hashed window registrations live alongside `projects/`, outside your repository. Opening the same workspace after an update retains the project record and appends to the existing usage file. Each VS Code profile and extension host has separate storage; opening a remote workspace or a different profile does not migrate local history. If a previously registered project has lost its Chat history file on this host, hoosage reports that condition instead of silently creating an empty replacement. **hoosage: Diagnose Tracking** reports the current project's opaque directory ID and workspace-derived name, a fingerprint of this extension storage location, file size and modification time, bytes and lines read, separate Chat and CLI indexing states, endpoint match and collector health. It omits the raw storage path, endpoint, token and usage content. The project name lets you confirm you are diagnosing the intended window when several projects are open across windows. +History lives under `globalStorageUri/projects//`; shared collector configuration and hashed window registrations live alongside `projects/`, outside your repository. Opening the same workspace after an update retains the project record and appends to the existing usage file. Each VS Code profile and extension host has separate storage; opening a remote workspace or a different profile does not migrate local history. Shared Project Sync is separately opt-in and writes its sanitized exchange files only to the folder selected by the user. If a previously registered project has lost its Chat history file on this host, hoosage reports that condition instead of silently creating an empty replacement. **hoosage: Diagnose Tracking** reports the current project's opaque directory ID and workspace-derived name, a fingerprint of this extension storage location, file size and modification time, bytes and lines read, separate Chat and CLI indexing states, endpoint match and collector health. It omits the raw storage path, endpoint, token and usage content. The project name lets you confirm you are diagnosing the intended window when several projects are open across windows. Saved usage is indexed in the background when the extension starts. The dashboard and status bar show an indexing state until totals are complete; large local histories are read in bounded batches without delaying extension activation. diff --git a/package.json b/package.json index 6b4ad0a..71ea90a 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,16 @@ "command": "hoosage.export", "title": "Export Usage", "category": "hoosage" + }, + { + "command": "hoosage.configureSharedSync", + "title": "Configure Shared Project Sync", + "category": "hoosage" + }, + { + "command": "hoosage.disableSharedSync", + "title": "Disable Shared Project Sync", + "category": "hoosage" } ], "viewsContainers": { diff --git a/src/core/shared-usage-sync.ts b/src/core/shared-usage-sync.ts new file mode 100644 index 0000000..bdfd2f9 --- /dev/null +++ b/src/core/shared-usage-sync.ts @@ -0,0 +1,252 @@ +import { createHash } from "node:crypto"; +import { + appendFile, + mkdir, + open, + readdir, + readFile, + stat, +} from "node:fs/promises"; +import { join } from "node:path"; +import type { UsageCall } from "./types"; + +const MAX_FILE_BYTES = 64 * 1024 * 1024; +const sources = new Set(["chat", "cli", "jetbrains", "chat-history"]); + +interface SharedEntry { + version: 1; + hostId: string; + call: UsageCall; +} + +interface PeerFile { + offset: number; + calls: Map; +} + +const hash = (value: string) => + createHash("sha256").update(value).digest("hex"); + +function validCall(value: unknown): value is UsageCall { + if (typeof value !== "object" || value === null) return false; + const call = value as Record; + const allowed = new Set([ + "id", + "projectId", + "timestamp", + "model", + "sessionId", + "source", + "input", + "output", + "cacheRead", + "cacheWrite", + "nanoAiu", + "requests", + "durationMs", + "failed", + ]); + return ( + Object.keys(call).every((key) => allowed.has(key)) && + typeof call.id === "string" && + call.id.length > 0 && + call.id.length <= 512 && + typeof call.projectId === "string" && + /^[a-f0-9]{24}$/.test(call.projectId) && + typeof call.timestamp === "number" && + Number.isFinite(call.timestamp) && + typeof call.model === "string" && + call.model.length <= 256 && + typeof call.failed === "boolean" && + (call.source === undefined || + (typeof call.source === "string" && sources.has(call.source))) && + (call.sessionId === undefined || + (typeof call.sessionId === "string" && call.sessionId.length <= 512)) && + ( + [ + "input", + "output", + "cacheRead", + "cacheWrite", + "nanoAiu", + "requests", + "durationMs", + ] as const + ).every( + (key) => + call[key] === undefined || + (typeof call[key] === "number" && + Number.isFinite(call[key]) && + call[key] >= 0), + ) + ); +} + +function parseEntry(line: string, expectedHostId?: string): SharedEntry { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new Error("A shared usage file contains malformed data."); + } + if ( + typeof value !== "object" || + value === null || + Object.keys(value).some( + (key) => !["version", "hostId", "call"].includes(key), + ) || + (value as Record).version !== 1 || + typeof (value as Record).hostId !== "string" || + !/^[a-f0-9]{24}$/.test( + (value as Record).hostId as string, + ) || + (expectedHostId !== undefined && + (value as Record).hostId !== expectedHostId) || + !validCall((value as Record).call) + ) + throw new Error("A shared usage file contains an invalid entry."); + return value as SharedEntry; +} + +/** Exchanges sanitized usage records through a user-selected local directory. + * Each VS Code session appends to its own file; host and call IDs deduplicate + * records without exposing workspace paths or repository URLs. */ +export class SharedUsageSync { + private readonly directory: string; + private readonly ownFile: string; + private readonly written = new Set(); + private readonly peerFiles = new Map(); + private loadedOwnFile = false; + + constructor( + sharedDirectory: string, + groupId: string, + private readonly hostId: string, + sessionId: string, + ) { + if (!/^[a-f0-9]{24}$/.test(hostId)) + throw new Error("Invalid shared usage host identity."); + if (!groupId.trim() || groupId.length > 128) + throw new Error("Invalid shared usage group."); + this.directory = join(sharedDirectory, hash(groupId.trim())); + this.ownFile = join(this.directory, `${hostId}-${hash(sessionId)}.jsonl`); + } + + async poll( + localCalls: UsageCall[], + currentProjectId: string, + ): Promise { + try { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + if (!this.loadedOwnFile) { + await this.loadWritten(); + this.loadedOwnFile = true; + } + const pending: SharedEntry[] = []; + const pendingKeys: string[] = []; + for (const call of localCalls) { + if (!validCall(call)) + throw new Error("Invalid usage entry cannot be shared."); + const key = `${call.projectId}:${call.id}`; + if (this.written.has(key)) continue; + pending.push({ version: 1, hostId: this.hostId, call }); + pendingKeys.push(key); + } + if (pending.length) + await appendFile( + this.ownFile, + pending.map((entry) => JSON.stringify(entry)).join("\n") + "\n", + { + encoding: "utf8", + mode: 0o600, + }, + ); + for (const key of pendingKeys) this.written.add(key); + + const entries = await readdir(this.directory, { withFileTypes: true }); + const activeFiles = new Set(); + for (const file of entries) { + if ( + !file.isFile() || + !/^[a-f0-9]{24}-[a-f0-9]{64}\.jsonl$/.test(file.name) + ) + continue; + const sourceHostId = file.name.slice(0, 24); + if (sourceHostId === this.hostId) continue; + activeFiles.add(file.name); + const path = join(this.directory, file.name); + const fileStat = await stat(path); + if (fileStat.size > MAX_FILE_BYTES) + throw new Error("A shared usage file exceeds the 64 MiB limit."); + let peer = this.peerFiles.get(file.name); + if (!peer || fileStat.size < peer.offset) { + peer = { offset: 0, calls: new Map() }; + this.peerFiles.set(file.name, peer); + } + const length = fileStat.size - peer.offset; + if (length) { + const handle = await open(path, "r"); + let buffer: Buffer; + try { + buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read( + buffer, + 0, + length, + peer.offset, + ); + buffer = buffer.subarray(0, bytesRead); + } finally { + await handle.close(); + } + const newline = buffer.lastIndexOf(10); + if (newline < 0) continue; + const contents = buffer.subarray(0, newline).toString("utf8"); + for (const line of contents.split("\n")) { + if (!line) continue; + const entry = parseEntry(line, sourceHostId); + const key = `${entry.hostId}:${entry.call.projectId}:${entry.call.id}`; + peer.calls.set(key, entry.call); + } + peer.offset += newline + 1; + } + } + for (const name of this.peerFiles.keys()) + if (!activeFiles.has(name)) this.peerFiles.delete(name); + const seen = new Set(); + const imported: UsageCall[] = []; + for (const peer of this.peerFiles.values()) + for (const [key, call] of peer.calls) + if (!seen.has(key)) { + seen.add(key); + imported.push({ ...call, projectId: currentProjectId }); + } + return imported; + } catch (error) { + if ( + error instanceof Error && + (error.message.startsWith("A shared usage file") || + error.message.startsWith("Invalid shared usage") || + error.message.startsWith("Invalid usage entry")) + ) + throw error; + throw new Error("Could not access the configured shared usage folder."); + } + } + + private async loadWritten() { + try { + if ((await stat(this.ownFile)).size > MAX_FILE_BYTES) + throw new Error("A shared usage file exceeds the 64 MiB limit."); + const contents = await readFile(this.ownFile, "utf8"); + for (const line of contents.split("\n")) { + if (!line) continue; + const entry = parseEntry(line, this.hostId); + this.written.add(`${entry.call.projectId}:${entry.call.id}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 786a8c4..66d0007 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,7 +1,14 @@ import * as vscode from "vscode"; import { createHash, randomBytes } from "node:crypto"; -import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { + mkdir, + readdir, + readFile, + realpath, + stat, + writeFile, +} from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { UsageTailer } from "./core/tailer"; import { CliUsageScanner, @@ -45,6 +52,7 @@ import { import type { Project, Snapshot, UsageCall } from "./core/types"; import { registerWindow, routeWindow } from "./core/routing"; import { hasTelemetryEnvironmentConflict } from "./core/environment"; +import { SharedUsageSync } from "./core/shared-usage-sync"; const KEYS = [ "enabled", @@ -54,7 +62,9 @@ const KEYS = [ "otlpEndpoint", ] as const; const BACKUP = "copilotSettingsBackup"; +const SHARED_SYNC = "sharedUsageSync"; type Connection = { port: number; token: string }; +type SharedSyncConfig = { directory: string; groupId: string }; const parseConnection = (value: unknown): Connection | undefined => { if (typeof value !== "object" || value === null) return undefined; const saved = value as Record; @@ -126,6 +136,27 @@ export async function activate(context: vscode.ExtensionContext) { pathHashes: folders.map((f) => folderPathHash(f.uri.fsPath)), } : undefined; + let sharedSync: SharedUsageSync | undefined; + const syncSettings = context.globalState.get< + Record + >(SHARED_SYNC, {}); + const syncConfig = current ? syncSettings[current.id] : undefined; + if ( + current && + syncConfig && + typeof syncConfig.directory === "string" && + isAbsolute(syncConfig.directory) && + typeof syncConfig.groupId === "string" && + syncConfig.groupId.trim().length > 0 && + syncConfig.groupId.length <= 128 + ) + sharedSync = new SharedUsageSync( + syncConfig.directory, + syncConfig.groupId, + storeId, + vscode.env.sessionId, + ); + let sharedUsageCount = 0; const capture = (id: string) => join(root, id, "copilot.jsonl"); const tailers = new Map(); const discovering = new Set(); @@ -484,7 +515,8 @@ export async function activate(context: vscode.ExtensionContext) { .filter((c) => !c.source || c.source === "chat") .map((c) => c.timestamp) : [], - )) calls.push(call); + )) + calls.push(call); } projects.push( ...projectIndex @@ -505,9 +537,28 @@ export async function activate(context: vscode.ExtensionContext) { name: bucketName, kind, folderCount: 0, - createdAt: bucketCalls.reduce((first, call) => Math.min(first, call.timestamp), Infinity), + createdAt: bucketCalls.reduce( + (first, call) => Math.min(first, call.timestamp), + Infinity, + ), }); } + if (current && sharedSync) + try { + const sharedCalls = await sharedSync.poll( + calls.filter((call) => call.projectId === current.id), + current.id, + ); + sharedUsageCount = sharedCalls.length; + calls.push(...sharedCalls); + } catch (error) { + sharedUsageCount = 0; + errors.push( + error instanceof Error + ? `Shared project sync: ${error.message}` + : "Shared project sync failed. Check the configured folder and refresh.", + ); + } const problem = blocker() ?? collectorError; const connected = current && @@ -543,8 +594,7 @@ export async function activate(context: vscode.ExtensionContext) { ? `Collector ready on the ${remoteLabel} host. Run Copilot Chat, then Diagnose Tracking to confirm a Chat span arrives here.` : "This project is registered automatically. Use Copilot Chat to record usage; reload if you just enabled tracking."); const indexing = - [...tailers.values()].some((t) => !t.caughtUp) || - !cliScanner.caughtUp; + [...tailers.values()].some((t) => !t.caughtUp) || !cliScanner.caughtUp; return { projects, calls, @@ -651,6 +701,8 @@ export async function activate(context: vscode.ExtensionContext) { `Current project ID: ${current?.id ?? "none"}`, `Current project name: ${current?.name ?? "none"}`, `Current project registered here: ${registered ? "yes" : "no"}`, + `Shared project sync: ${sharedSync ? "enabled" : "disabled"}`, + `Usage entries received from other hosts: ${sharedUsageCount}`, `Project record created here: ${registered && Number.isFinite(registered.createdAt) ? new Date(registered.createdAt).toISOString() : "unknown"}`, `Saved Chat entries for this project on this host: ${chatCalls.length}`, `Imported earlier Chat requests for this project: ${current ? (chatHistory.get(current.id)?.length ?? 0) : 0}`, @@ -834,6 +886,99 @@ export async function activate(context: vscode.ExtensionContext) { } } + async function configureSharedSync() { + if (!current) { + await vscode.window.showWarningMessage( + "Open a trusted project before configuring shared usage sync.", + ); + return; + } + const sharedPath = await vscode.window.showInputBox({ + title: "Configure shared project sync", + prompt: + "Enter the absolute path to a shared folder visible to this host, outside every repository. Use this host's path format.", + placeHolder: + process.platform === "win32" + ? "C:\\Users\\you\\hoosage-share" + : "/mnt/c/Users/you/hoosage-share", + value: syncSettings[current.id]?.directory, + ignoreFocusOut: true, + validateInput: (value) => + isAbsolute(value.trim()) + ? undefined + : "Enter an absolute path visible to this extension host.", + }); + if (sharedPath === undefined) return; + let directory: string; + let workspaceRoots: string[]; + try { + directory = await realpath(resolve(sharedPath.trim())); + if (!(await stat(directory)).isDirectory()) + throw new Error("The shared path is not a folder."); + workspaceRoots = await Promise.all( + (vscode.workspace.workspaceFolders ?? []).map((folder) => + realpath(folder.uri.fsPath), + ), + ); + } catch { + await vscode.window.showErrorMessage( + "hoosage could not verify the selected shared folder. Check access and try again.", + ); + return; + } + const insideWorkspace = (folder: string) => { + const path = relative(resolve(folder), directory); + return ( + path === "" || + (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path)) + ); + }; + if (workspaceRoots.some(insideWorkspace)) { + await vscode.window.showErrorMessage( + "Choose a shared folder outside this repository and all open workspace folders.", + ); + return; + } + const groupId = await vscode.window.showInputBox({ + title: "Configure shared project sync", + prompt: + "Enter the same private group ID on each host for this checkout. It is hashed before being used as a folder name.", + placeHolder: "for example: vrkb", + value: syncSettings[current.id]?.groupId, + ignoreFocusOut: true, + validateInput: (value) => + value.trim().length > 0 && value.trim().length <= 128 + ? undefined + : "Enter a group ID from 1 to 128 characters.", + }); + if (groupId === undefined) return; + syncSettings[current.id] = { directory, groupId: groupId.trim() }; + await context.globalState.update(SHARED_SYNC, syncSettings); + sharedSync = new SharedUsageSync( + directory, + groupId, + storeId, + vscode.env.sessionId, + ); + sharedUsageCount = 0; + await vscode.window.showInformationMessage( + "Shared project sync is enabled for this workspace. Configure the same folder and group ID on the other host.", + ); + await refresh(); + } + + async function disableSharedSync() { + if (!current || !syncSettings[current.id]) return; + delete syncSettings[current.id]; + await context.globalState.update(SHARED_SYNC, syncSettings); + sharedSync = undefined; + sharedUsageCount = 0; + await vscode.window.showInformationMessage( + "Shared project sync is disabled for this workspace. Existing shared files are not deleted.", + ); + await refresh(); + } + async function exportUsage( projectId = "all", days = 30, @@ -946,7 +1091,8 @@ export async function activate(context: vscode.ExtensionContext) { periodEnd(Date.now(), m.endDate) !== undefined) : typeof m.startDate === "string" && typeof m.endDate === "string" && - customDateBounds(Date.now(), m.startDate, m.endDate) !== undefined) && + customDateBounds(Date.now(), m.startDate, m.endDate) !== + undefined) && ["csv", "json"].includes(String(m.format)) ) await exportUsage( @@ -998,6 +1144,14 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("hoosage.refresh", refresh), vscode.commands.registerCommand("hoosage.diagnose", showDiagnostics), vscode.commands.registerCommand("hoosage.export", () => exportUsage()), + vscode.commands.registerCommand( + "hoosage.configureSharedSync", + configureSharedSync, + ), + vscode.commands.registerCommand( + "hoosage.disableSharedSync", + disableSharedSync, + ), vscode.window.registerWebviewViewProvider("hoosage.overview", { resolveWebviewView(view) { const dispose = attach(view.webview); diff --git a/src/webview/app.ts b/src/webview/app.ts index 2d55321..25d9af9 100644 --- a/src/webview/app.ts +++ b/src/webview/app.ts @@ -115,7 +115,8 @@ const paths: Record = { "M20 7v5h-5 M4 17v-5h5 M6 7a7 7 0 0 1 12-2l2 2 M4 17l2 2a7 7 0 0 0 12-2", bolt: "M13 2 4 14h7l-1 8 10-13h-7l1-7Z", chevron: "m9 5 7 7-7 7", - calendar: "M8 2v4 M16 2v4 M3 9h18 M5 4h14a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z", + calendar: + "M8 2v4 M16 2v4 M3 9h18 M5 4h14a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z", expand: "M15 3h6v6 M21 3l-7 7 M9 21H3v-6 M3 21l7-7", }; const icon = (name: string) => @@ -237,7 +238,12 @@ function render() { const calls = selectedCalls(); const nameCounts = new Map(); for (const project of data.projects) { - if (project.kind === "cli" || project.kind === "jetbrains" || project.kind === "chat") continue; + if ( + project.kind === "cli" || + project.kind === "jetbrains" || + project.kind === "chat" + ) + continue; const name = project.name.toLocaleLowerCase(); nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1); } @@ -334,7 +340,10 @@ function modelMix(calls: UsageCall[]) { )}${models.length > 4 ? `

+ ${models.length - 4} more models · see Activity

` : ""}`; } -function projectDescription(project: Snapshot["projects"][number], hasUsage: boolean) { +function projectDescription( + project: Snapshot["projects"][number], + hasUsage: boolean, +) { if (project.kind === "cli") return "Unmatched CLI sessions"; if (project.kind === "jetbrains") return "Unmatched JetBrains sessions"; if (project.kind === "chat") return "Chats opened without a folder"; @@ -359,34 +368,70 @@ function projects(calls: UsageCall[], embedded = false) { row.usage.length && all ? `${Math.round((row.tokens / all) * 100)}%` : "—"; const callTitle = (row: (typeof rows)[number]) => row.usage.length ? h(callCount(row)) : "No usage in selected period"; - return `${!embedded ? `
${icon("projects")}
PROJECT INDEX

Every project at a glance

Usage stays with its own workspace or local session source. Open a project to inspect its overview.

${number(rows.length)}indexed
${number(rows.filter((row) => row.usage.length).length)}with records
` : ""}
${embedded ? `

Projects

` : `
SELECTED PERIOD

Project breakdown

Most tokens first
`}
${rows.map((row, i) => ``).join("")}
ProjectModel callsObserved tokensCost (USD)View project
${row.usage.length ? measuredCalls(row) : "—"}${row.usage.length ? knownTokenLabel(row.usage, row.tokens, "total") : "—"}${h(costLabel(row.cost))}
${rows.map((row, i) => `
Model calls
${row.usage.length ? measuredCalls(row) : "—"}
Observed tokens
${row.usage.length ? knownTokenLabel(row.usage, row.tokens, "total") : "—"}
Cost (USD)
${h(costLabel(row.cost))}
Token share${share(row)}
`).join("")}
${!rows.length ? `
${icon("projects")}No projects indexed yetOpen a workspace or finish a local Copilot CLI or JetBrains session to see it here.
` : ""}
${!embedded && !demo ? '
Projects appear when opened with hoosage, found in this profile’s previously opened local folders, found in VS Code’s stored Chat history, or found in completed local CLI/JetBrains sessions. A new project card does not imply earlier Chat usage.
' : ""}`; + return `${!embedded ? `
${icon("projects")}
PROJECT INDEX

Every project at a glance

Usage stays with its own workspace or local session source. Open a project to inspect its overview. To aggregate a known Windows/WSL checkout, run hoosage: Configure Shared Project Sync on both hosts.

${number(rows.length)}indexed
${number(rows.filter((row) => row.usage.length).length)}with records
` : ""}
${embedded ? `

Projects

` : `
SELECTED PERIOD

Project breakdown

Most tokens first
`}
${rows.map((row, i) => ``).join("")}
ProjectModel callsObserved tokensCost (USD)View project
${row.usage.length ? measuredCalls(row) : "—"}${row.usage.length ? knownTokenLabel(row.usage, row.tokens, "total") : "—"}${h(costLabel(row.cost))}
${rows.map((row, i) => `
Model calls
${row.usage.length ? measuredCalls(row) : "—"}
Observed tokens
${row.usage.length ? knownTokenLabel(row.usage, row.tokens, "total") : "—"}
Cost (USD)
${h(costLabel(row.cost))}
Token share${share(row)}
`).join("")}
${!rows.length ? `
${icon("projects")}No projects indexed yetOpen a workspace or finish a local Copilot CLI or JetBrains session to see it here.
` : ""}
${!embedded && !demo ? '
Projects appear when opened with hoosage, found in this profile’s previously opened local folders, found in VS Code’s stored Chat history, or found in completed local CLI/JetBrains sessions. A new project card does not imply earlier Chat usage.
' : ""}`; } function activity(calls: UsageCall[]) { const sorted = groupSessions(calls); let previousDay = ""; - const sessions = sorted.slice(0, visibleSessions).map(([key, list]) => { - const t = totals(list); - const first = list[0]!; - const project = data!.projects.find((entry) => entry.id === first.projectId); - const latest = list.reduce((time, call) => Math.max(time, call.timestamp), 0); - const sessionCost = costs(list); - const day = localDateKey(latest); - const dayHeading = day === previousDay ? "" : `

${h(dateLabel(day))}${day === localDateKey(data!.updatedAt) ? 'Today' : ""}

`; - previousDay = day; - const source = first.source === "cli" ? "CLI" : first.source === "jetbrains" ? "JetBrains" : first.source === "chat-history" ? "Imported" : "Chat"; - const time = new Intl.DateTimeFormat("en", { hour: "numeric", minute: "2-digit" }).format(new Date(latest)); - const detail = expandedSession === key - ? `
${byModel(list).map((model) => { - const modelCalls = list.filter((call) => call.model === model.model); - const input = modelCalls.every((call) => call.input === undefined) ? "—" : number(model.input); - const output = modelCalls.every((call) => call.output === undefined) ? "—" : number(model.output); - const modelCost = costs(modelCalls); - return `
${h(model.model)}${h(callCount(model))} · ${input} in · ${output} out${h(costLabel(modelCost))}
`; - }).join("")}

${[t.failed ? count(t.failed, "failed entry") : "", t.missingUsage ? `${count(t.missingUsage, "entry")} missing token data` : "", costDescription(sessionCost)].filter(Boolean).map(h).join(" · ")}

` - : ""; - return `${dayHeading}
${detail}
`; - }).join(""); + const sessions = sorted + .slice(0, visibleSessions) + .map(([key, list]) => { + const t = totals(list); + const first = list[0]!; + const project = data!.projects.find( + (entry) => entry.id === first.projectId, + ); + const latest = list.reduce( + (time, call) => Math.max(time, call.timestamp), + 0, + ); + const sessionCost = costs(list); + const day = localDateKey(latest); + const dayHeading = + day === previousDay + ? "" + : `

${h(dateLabel(day))}${day === localDateKey(data!.updatedAt) ? 'Today' : ""}

`; + previousDay = day; + const source = + first.source === "cli" + ? "CLI" + : first.source === "jetbrains" + ? "JetBrains" + : first.source === "chat-history" + ? "Imported" + : "Chat"; + const time = new Intl.DateTimeFormat("en", { + hour: "numeric", + minute: "2-digit", + }).format(new Date(latest)); + const detail = + expandedSession === key + ? `
${byModel(list) + .map((model) => { + const modelCalls = list.filter( + (call) => call.model === model.model, + ); + const input = modelCalls.every( + (call) => call.input === undefined, + ) + ? "—" + : number(model.input); + const output = modelCalls.every( + (call) => call.output === undefined, + ) + ? "—" + : number(model.output); + const modelCost = costs(modelCalls); + return `
${h(model.model)}${h(callCount(model))} · ${input} in · ${output} out${h(costLabel(modelCost))}
`; + }) + .join( + "", + )}

${[t.failed ? count(t.failed, "failed entry") : "", t.missingUsage ? `${count(t.missingUsage, "entry")} missing token data` : "", costDescription(sessionCost)].filter(Boolean).map(h).join(" · ")}

` + : ""; + return `${dayHeading}
${detail}
`; + }) + .join(""); return `
SESSION HISTORY

Recent sessions

${callCount(totals(calls))}
${calls.length ? `
${sessions}
` : `
${icon("activity")}No activity in this periodChoose another date range to explore your saved usage.
`}${sorted.length > visibleSessions ? `
${Math.min(visibleSessions, sorted.length)} of ${sorted.length} shown
` : ""}
`; } @@ -396,7 +441,7 @@ function about() {
${icon("projects")}DISCOVERY

VS Code projects

Trusted workspaces register when opened with hoosage active. Setup is needed only once per profile; earlier registrations remain visible.

Saved records of previously opened local single-folder workspaces are scanned in the background. A discovered project may have no captured usage; discovery alone does not import past Chat calls. Stored VS Code Chat history can also add cards for workspaces, remote folders and deleted folders.

Clones and worktrees stay separate. An opened multi-root workspace counts as one workspace group.

${icon("bolt")}MEASUREMENT

What counts

Completed Copilot Chat calls are deduplicated. Missing values stay unknown; cache reads are already part of input tokens. Inline completions and usage on other hosts are excluded.

CLI session-state entries summarize model requests after a session ends. Imported Chat entries summarize one Chat request each: VS Code stores summed output tokens, model-call count and reported credits, but input tokens only when that request made a single model call.

${icon("activity")}HISTORY

Saved usage

Live Chat collection starts after setup and reload. Earlier completed requests still stored by VS Code are imported, marked “Imported” and not double-counted once live collection starts for a project. Chats VS Code already deleted cannot be recovered. Existing local CLI history is read separately.

Stopping tracking keeps your history. To delete it, stop tracking, reload, then delete hoosage’s project storage.

-
${icon("arrow")}ATTRIBUTION

Local sessions

Completed CLI and JetBrains sessions discover projects from their recorded working directory, even if those projects were never opened in VS Code. Ambiguous locations stay unassigned.

Windows, WSL and container paths remain separate workspace identities, even when project names match.

+
${icon("arrow")}ATTRIBUTION

Local sessions

Completed CLI and JetBrains sessions discover projects from their recorded working directory, even if those projects were never opened in VS Code. Ambiguous locations stay unassigned.

Windows, WSL and container paths remain separate by default. To aggregate one known checkout across hosts, explicitly configure hoosage: Configure Shared Project Sync on each host with the same shared folder and group ID.

USAGE VALUE

Costs in USD

Reported Copilot credits take priority; 1 credit = $0.01.≈ Estimated from the price table.+ Partial excludes unpriced usage.

Estimates use the Copilot price table from ${PRICING_DATE}, including cache rates and long-context tiers. These rates also apply to older calls. Missing cache details are assumed zero. Unknown models, incomplete token counts and CLI aggregates that could cross long-context tiers stay unpriced.

Usage value excludes subscription fees, allowances, discounts and taxes. It is not your bill.

${h(costDescription(costs(selectedCalls())))}

CONTROL

Tracking

${demo ? "Preview · Sample data" : h(data!.statusDetail)}

${data!.skippedLines ? `

${count(data!.skippedLines, "invalid record")} skipped.

` : ""}

Chat setup applies to all trusted VS Code projects in this profile. Local CLI and JetBrains projects are discovered without setup. Stop tracking before uninstalling to restore the previous Copilot settings.

${demo ? action("Exit preview", "exitDemo") : data!.status === "reload" ? action("Reload window", "reload", true) : data!.canStopTracking || data!.status === "active" || data!.status === "waiting" ? action("Stop tracking", "disable") : action("Enable Chat tracking once", "enable", true)}
`; @@ -404,7 +449,9 @@ function about() { function focusCalendarDay() { root - .querySelector(`[data-focus="calendar-day-${calendarFocusDate}"]`) + .querySelector( + `[data-focus="calendar-day-${calendarFocusDate}"]`, + ) ?.focus({ preventScroll: true }); } @@ -434,8 +481,7 @@ function shiftCalendarMonth(months: number, focusKey: string) { const next = new Date(year!, month! - 1 + months, 1); const today = localDateKey(data!.updatedAt); const key = localDateKey(next.getTime()); - if (next.getFullYear() < 100 || key.slice(0, 7) > today.slice(0, 7)) - return; + if (next.getFullYear() < 100 || key.slice(0, 7) > today.slice(0, 7)) return; calendarMonth = key; calendarFocusDate = key; render(); @@ -451,8 +497,10 @@ function moveCalendarFocus(key: string) { else if (key === "ArrowRight") date.setDate(date.getDate() + 1); else if (key === "ArrowUp") date.setDate(date.getDate() - 7); else if (key === "ArrowDown") date.setDate(date.getDate() + 7); - else if (key === "Home") date.setDate(date.getDate() - ((date.getDay() + 6) % 7)); - else if (key === "End") date.setDate(date.getDate() + (6 - ((date.getDay() + 6) % 7))); + else if (key === "Home") + date.setDate(date.getDate() - ((date.getDay() + 6) % 7)); + else if (key === "End") + date.setDate(date.getDate() + (6 - ((date.getDay() + 6) % 7))); const today = localDateKey(data!.updatedAt); const target = localDateKey(date.getTime()); if (target > today || date.getFullYear() < 100) return; @@ -516,13 +564,17 @@ root.addEventListener("click", (event) => { return; } if (button.dataset.calendarNav) { - shiftCalendarMonth(Number(button.dataset.calendarNav), button.dataset.focus ?? ""); + shiftCalendarMonth( + Number(button.dataset.calendarNav), + button.dataset.focus ?? "", + ); return; } if (button.dataset.calendarBoundary) { activeBoundary = button.dataset.calendarBoundary as "start" | "end"; if (activeBoundary === "start") draftMode = "custom"; - calendarFocusDate = activeBoundary === "start" ? draftStartDate : draftEndDate; + calendarFocusDate = + activeBoundary === "start" ? draftStartDate : draftEndDate; calendarMonth = `${calendarFocusDate.slice(0, 7)}-01`; render(); root @@ -545,7 +597,9 @@ root.addEventListener("click", (event) => { if (draftMode === "custom") { if (draftEndDate < draftStartDate) draftStartDate = chosen; } else { - draftStartDate = localDateKey(startOfRange(days, periodEnd(data!.updatedAt, chosen)!)); + draftStartDate = localDateKey( + startOfRange(days, periodEnd(data!.updatedAt, chosen)!), + ); } } render(); @@ -690,7 +744,9 @@ root.addEventListener("keydown", (event) => { const [year, month, day] = calendarFocusDate.split("-").map(Number); const step = direction * (event.shiftKey ? 12 : 1); const last = new Date(year!, month! + step, 0).getDate(); - const next = localDateKey(new Date(year!, month! - 1 + step, Math.min(day!, last)).getTime()); + const next = localDateKey( + new Date(year!, month! - 1 + step, Math.min(day!, last)).getTime(), + ); if (next <= localDateKey(data!.updatedAt)) { calendarFocusDate = next; calendarMonth = `${next.slice(0, 7)}-01`; @@ -699,7 +755,11 @@ root.addEventListener("keydown", (event) => { } return; } - if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { + if ( + ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes( + event.key, + ) + ) { event.preventDefault(); moveCalendarFocus(event.key); } @@ -707,7 +767,9 @@ root.addEventListener("keydown", (event) => { document.addEventListener("click", (event) => { if ( calendarOpen && - !(event.target as Element).closest(".date-nav, [data-calendar-action='custom'], [data-calendar-action='open']") + !(event.target as Element).closest( + ".date-nav, [data-calendar-action='custom'], [data-calendar-action='open']", + ) ) { calendarOpen = false; render(); @@ -721,7 +783,10 @@ root.addEventListener("focusout", (event) => { ) return; queueMicrotask(() => { - if (calendarOpen && !root.querySelector(".date-nav")?.contains(document.activeElement)) { + if ( + calendarOpen && + !root.querySelector(".date-nav")?.contains(document.activeElement) + ) { calendarOpen = false; render(); } diff --git a/test/shared-usage-sync.test.ts b/test/shared-usage-sync.test.ts new file mode 100644 index 0000000..e89faed --- /dev/null +++ b/test/shared-usage-sync.test.ts @@ -0,0 +1,95 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SharedUsageSync } from "../src/core/shared-usage-sync"; +import type { UsageCall } from "../src/core/types"; + +const projectA = "a".repeat(24); +const projectB = "b".repeat(24); +const call = (id: string, projectId: string): UsageCall => ({ + id, + projectId, + timestamp: 1_750_000_000_000, + model: "test-model", + input: 12, + output: 8, + failed: false, +}); + +test("exchanges sanitized usage between hosts without duplicate imports", async () => { + const root = await mkdtemp(join(tmpdir(), "hoosage-shared-usage-")); + try { + const hostA = new SharedUsageSync( + root, + "same checkout", + "a".repeat(24), + "window-a", + ); + const hostB = new SharedUsageSync( + root, + "same checkout", + "b".repeat(24), + "window-b", + ); + const a = call("span-a", projectA); + const b = call("span-b", projectB); + + assert.deepEqual(await hostA.poll([a], projectA), []); + assert.deepEqual(await hostB.poll([b], projectB), [ + { ...a, projectId: projectB }, + ]); + assert.deepEqual(await hostA.poll([a], projectA), [ + { ...b, projectId: projectA }, + ]); + assert.deepEqual(await hostB.poll([b], projectB), [ + { ...a, projectId: projectB }, + ]); + + const groupDirs = await readdir(root); + const files = await readdir(join(root, groupDirs[0]!)); + assert.equal(files.length, 2); + const payload = await readFile( + join(root, groupDirs[0]!, files[0]!), + "utf8", + ); + assert.ok(!payload.includes("same checkout")); + assert.ok(!payload.includes("repo")); + assert.equal(payload.trim().split("\n").length, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("keeps different user-selected groups isolated", async () => { + const root = await mkdtemp(join(tmpdir(), "hoosage-shared-groups-")); + try { + const writer = new SharedUsageSync(root, "first", "a".repeat(24), "one"); + const reader = new SharedUsageSync(root, "second", "b".repeat(24), "two"); + await writer.poll([call("span", projectA)], projectA); + assert.deepEqual(await reader.poll([], projectB), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejects tampered shared usage entries", async () => { + const root = await mkdtemp(join(tmpdir(), "hoosage-shared-invalid-")); + try { + const writer = new SharedUsageSync(root, "shared", "a".repeat(24), "one"); + const reader = new SharedUsageSync(root, "shared", "b".repeat(24), "two"); + await writer.poll([call("span", projectA)], projectA); + const group = (await readdir(root))[0]!; + const file = (await readdir(join(root, group)))[0]!; + const path = join(root, group, file); + const content = await readFile(path, "utf8"); + await writeFile( + path, + content.replace('"id":"span"', '"id":"span","prompt":"must not sync"'), + ); + await assert.rejects(reader.poll([], projectB), /invalid entry/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From b7878a85bc4b42078dd5d8b5fe4c9c01ae811512 Mon Sep 17 00:00:00 2001 From: Jan Heinsohn Date: Mon, 28 Sep 2026 12:15:08 +0200 Subject: [PATCH 2/2] fix(hoosage): avoid shared file races Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/shared-usage-sync.ts | 110 ++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 44 deletions(-) diff --git a/src/core/shared-usage-sync.ts b/src/core/shared-usage-sync.ts index bdfd2f9..4a0c443 100644 --- a/src/core/shared-usage-sync.ts +++ b/src/core/shared-usage-sync.ts @@ -1,12 +1,6 @@ import { createHash } from "node:crypto"; -import { - appendFile, - mkdir, - open, - readdir, - readFile, - stat, -} from "node:fs/promises"; +import { constants } from "node:fs"; +import { mkdir, open, readdir, type FileHandle } from "node:fs/promises"; import { join } from "node:path"; import type { UsageCall } from "./types"; @@ -152,15 +146,26 @@ export class SharedUsageSync { pending.push({ version: 1, hostId: this.hostId, call }); pendingKeys.push(key); } - if (pending.length) - await appendFile( + if (pending.length) { + const handle = await open( this.ownFile, - pending.map((entry) => JSON.stringify(entry)).join("\n") + "\n", - { - encoding: "utf8", - mode: 0o600, - }, + constants.O_WRONLY | + constants.O_APPEND | + constants.O_CREAT | + (constants.O_NOFOLLOW ?? 0), + 0o600, ); + try { + if (!(await handle.stat()).isFile()) + throw new Error("A shared usage file is not a regular file."); + await handle.writeFile( + pending.map((entry) => JSON.stringify(entry)).join("\n") + "\n", + { encoding: "utf8" }, + ); + } finally { + await handle.close(); + } + } for (const key of pendingKeys) this.written.add(key); const entries = await readdir(this.directory, { withFileTypes: true }); @@ -175,40 +180,47 @@ export class SharedUsageSync { if (sourceHostId === this.hostId) continue; activeFiles.add(file.name); const path = join(this.directory, file.name); - const fileStat = await stat(path); - if (fileStat.size > MAX_FILE_BYTES) - throw new Error("A shared usage file exceeds the 64 MiB limit."); - let peer = this.peerFiles.get(file.name); - if (!peer || fileStat.size < peer.offset) { - peer = { offset: 0, calls: new Map() }; - this.peerFiles.set(file.name, peer); - } - const length = fileStat.size - peer.offset; - if (length) { - const handle = await open(path, "r"); - let buffer: Buffer; - try { - buffer = Buffer.alloc(length); + const handle = await open( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + try { + const fileStat = await handle.stat(); + if (!fileStat.isFile()) + throw new Error("A shared usage file is not a regular file."); + if (fileStat.size > MAX_FILE_BYTES) + throw new Error("A shared usage file exceeds the 64 MiB limit."); + let peer = this.peerFiles.get(file.name); + if (!peer || fileStat.size < peer.offset) { + peer = { offset: 0, calls: new Map() }; + this.peerFiles.set(file.name, peer); + } + const length = fileStat.size - peer.offset; + if (length) { + const buffer = Buffer.alloc(length); const { bytesRead } = await handle.read( buffer, 0, length, peer.offset, ); - buffer = buffer.subarray(0, bytesRead); - } finally { - await handle.close(); - } - const newline = buffer.lastIndexOf(10); - if (newline < 0) continue; - const contents = buffer.subarray(0, newline).toString("utf8"); - for (const line of contents.split("\n")) { - if (!line) continue; - const entry = parseEntry(line, sourceHostId); - const key = `${entry.hostId}:${entry.call.projectId}:${entry.call.id}`; - peer.calls.set(key, entry.call); + const contentsBuffer = buffer.subarray(0, bytesRead); + const newline = contentsBuffer.lastIndexOf(10); + if (newline >= 0) { + const contents = contentsBuffer + .subarray(0, newline) + .toString("utf8"); + for (const line of contents.split("\n")) { + if (!line) continue; + const entry = parseEntry(line, sourceHostId); + const key = `${entry.hostId}:${entry.call.projectId}:${entry.call.id}`; + peer.calls.set(key, entry.call); + } + peer.offset += newline + 1; + } } - peer.offset += newline + 1; + } finally { + await handle.close(); } } for (const name of this.peerFiles.keys()) @@ -235,10 +247,18 @@ export class SharedUsageSync { } private async loadWritten() { + let handle: FileHandle | undefined; try { - if ((await stat(this.ownFile)).size > MAX_FILE_BYTES) + handle = await open( + this.ownFile, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + const fileStat = await handle.stat(); + if (!fileStat.isFile()) + throw new Error("A shared usage file is not a regular file."); + if (fileStat.size > MAX_FILE_BYTES) throw new Error("A shared usage file exceeds the 64 MiB limit."); - const contents = await readFile(this.ownFile, "utf8"); + const contents = await handle.readFile({ encoding: "utf8" }); for (const line of contents.split("\n")) { if (!line) continue; const entry = parseEntry(line, this.hostId); @@ -247,6 +267,8 @@ export class SharedUsageSync { } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; + } finally { + await handle?.close(); } } }