Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hash>/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/<workspace-hash>/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.

Expand All @@ -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/<workspace-hash>/`; 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/<workspace-hash>/`; 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.

Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
274 changes: 274 additions & 0 deletions src/core/shared-usage-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import { createHash } from "node:crypto";
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";

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<string, UsageCall>;
}

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<string, unknown>;
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<string, unknown>).version !== 1 ||
typeof (value as Record<string, unknown>).hostId !== "string" ||
!/^[a-f0-9]{24}$/.test(
(value as Record<string, unknown>).hostId as string,
) ||
(expectedHostId !== undefined &&
(value as Record<string, unknown>).hostId !== expectedHostId) ||
!validCall((value as Record<string, unknown>).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<string>();
private readonly peerFiles = new Map<string, PeerFile>();
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<UsageCall[]> {
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) {
const handle = await open(
this.ownFile,
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 });
const activeFiles = new Set<string>();
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 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,
);
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;
}
}
} finally {
await handle.close();
}
}
for (const name of this.peerFiles.keys())
if (!activeFiles.has(name)) this.peerFiles.delete(name);
const seen = new Set<string>();
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() {
let handle: FileHandle | undefined;
try {
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 handle.readFile({ encoding: "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;
} finally {
await handle?.close();
}
}
}
Loading
Loading