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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ A saved or multi-root workspace is one **Workspace group**. Copilot's telemetry

Copilot CLI sessions are read from `~/.copilot/session-state` (or `$COPILOT_HOME/session-state`) — no setup required. Sessions with reported usage automatically discover projects from their recorded working directory, including projects never opened in VS Code. Sessions launched in a Git subdirectory group under that Git root; sessions under an already registered workspace use its project group. Missing or ambiguous working directories stay in **Copilot CLI**. Sessions created by the GitHub Copilot plugin for JetBrains IDEs (`client_name: copilot-intellij` in `workspace.yaml`) are labelled JetBrains and follow the same discovery rule; unresolved sessions stay under **Copilot (JetBrains)**. Project names and local path hashes are kept on this host; raw paths and repository URLs are not exported.

On Windows, hoosage also reads `~/.copilot/session-state` of **running** WSL distributions through `\\wsl.localhost\<distro>` (each readable home under `/home`, plus `/root`). It checks which distributions are running with `wsl.exe --list --running --quiet` right before reading (normally every 15 seconds) and never starts a stopped one. Usage read earlier stays visible while a distribution is stopped; after a VS Code restart it reappears once the distribution runs. Linux working directories are mapped to their Windows form (`/home/you/repo` → `\\wsl.localhost\<distro>\home\you\repo`, `/mnt/c/repo` → `C:\repo`), so WSL CLI sessions join the project of a WSL folder opened in Windows VS Code. `$COPILOT_HOME` set inside WSL is not honoured. Set `hoosage.readWslCliSessions` to `false` to turn this off. If you also sync a project with hoosage running in a WSL window, entries read from both sides are counted once.

## Local data and settings

On explicit setup, hoosage changes these **user-level** Copilot settings:
Expand All @@ -139,7 +141,7 @@ VS Code 1.138 restricts these Copilot settings to application scope. Setup there

### WSL and Dev Containers

VS Code can run extensions on the local UI host or the [remote workspace host](https://code.visualstudio.com/api/advanced-topics/remote-extensions). In a WSL or Dev Container window, install hoosage **in that remote environment** and reload. If only the local copy is present, hoosage displays an actionable blocked state and **Diagnose Tracking** explains which host is running. A reachable collector on a remote host alone does not prove that Copilot Chat sends spans to that host: make one real Chat request and check whether the saved Chat-entry count increases. If it does not, remote Chat delivery is not yet supported for that host arrangement; use a local workspace for verified tracking. Hoosage never copies usage between local, WSL and container storage automatically.
VS Code can run extensions on the local UI host or the [remote workspace host](https://code.visualstudio.com/api/advanced-topics/remote-extensions). In a WSL or Dev Container window, install hoosage **in that remote environment** and reload. If only the local copy is present, hoosage displays an actionable blocked state and **Diagnose Tracking** explains which host is running. A reachable collector on a remote host alone does not prove that Copilot Chat sends spans to that host: make one real Chat request and check whether the saved Chat-entry count increases. If it does not, remote Chat delivery is not yet supported for that host arrangement; use a local workspace for verified tracking. Hoosage never copies usage between local, WSL and container storage automatically. (On Windows it only reads Copilot CLI session-state of running WSL distributions; see [Project attribution](#project-attribution).)

**Stop tracking before uninstalling.** Run **hoosage: Stop Tracking** and reload all open windows to restore the previous user values. This stops collection for all projects in this VS Code configuration. Settings changed by you after setup are preserved. Stopping keeps your history. To delete history, stop tracking, reload, and delete the corresponding project directory from hoosage global storage. No automatic retention/rotation is performed in this first release; the sanitized JSONL file grows with usage.

Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@
"type": "boolean",
"default": true,
"description": "Show today's USD usage value and observed Copilot tokens for the current project in the status bar."
},
"hoosage.readWslCliSessions": {
"type": "boolean",
"default": true,
"scope": "application",
"description": "On Windows, also read Copilot CLI and JetBrains session-state from running WSL distributions (never starts a stopped distribution)."
}
}
}
Expand Down
171 changes: 171 additions & 0 deletions src/core/wsl-sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { execFile } from "node:child_process";
import { readdir, stat } from "node:fs/promises";
import { join, win32 } from "node:path";
import { CliUsageScanner } from "./cli";
import type { UsageCall } from "./types";

// Reading \\wsl.localhost\<distro> starts a stopped distribution, so the
// running list is refreshed right before a read (at most every LIST_MAX_AGE_MS
// while catching up).
const LIST_MAX_AGE_MS = 5_000;
const SCAN_INTERVAL_MS = 15_000;
const DISCOVER_INTERVAL_MS = 60_000;
const DISTRO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;

/** Windows path for a Linux path inside a WSL distribution: DrvFs mounts
* (/mnt/c/...) map back to the drive, everything else to the
* \\wsl.localhost share that VS Code uses for opened WSL folders. */
export function wslPathToWindows(
distro: string,
path: string,
): string | undefined {
if (!DISTRO_NAME.test(distro) || !path.startsWith("/")) return undefined;
if (path.includes("\0")) return undefined;
const drive = /^\/mnt\/([a-zA-Z])(?:\/(.*))?$/.exec(path);
if (drive)
return win32.normalize(
`${drive[1]!.toUpperCase()}:\\${(drive[2] ?? "").replace(/\//g, "\\")}`,
);
return win32.normalize(
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, "\\")}`,
);
}

/** Parses `wsl.exe --list --quiet` output, which is UTF-16LE on Windows. */
export function parseDistroList(output: Buffer): string[] {
const text = output.includes(0)
? output.toString("utf16le")
: output.toString("utf8");
return [
...new Set(
text
.replace(/^\uFEFF/, "")
.split(/\r?\n/)
.map((line) => line.replace(/\0/g, "").trim())
.filter((name) => DISTRO_NAME.test(name)),
),
];
}

/** Only running distributions are listed, so hoosage never starts a WSL VM. */
export function runningWslDistros(): Promise<string[]> {
return new Promise((resolve) => {
execFile(
"wsl.exe",
["--list", "--running", "--quiet"],
{ encoding: "buffer", windowsHide: true, timeout: 10_000 },
(error, stdout) => resolve(error ? [] : parseDistroList(stdout)),
);
});
}

type Resolve = (cwd: string) => string | undefined;

interface DistroScanner {
distro: string;
scanner: CliUsageScanner;
}

/** Reads Copilot CLI/JetBrains session-state of running WSL distributions from
* Windows (`\\wsl.localhost\<distro>\home\<user>\.copilot\session-state`).
* Linux working directories are translated to their Windows form before
* project matching. Usage read earlier stays available while a distribution
* is stopped; it is not re-read until the distribution runs again. */
export class WslCliSessions {
private readonly scanners = new Map<string, DistroScanner>();
private readonly discoveredAt = new Map<string, number>();
private running: string[] = [];
private checkedAt = -Infinity;
private scannedAt = -Infinity;
private busy = false;

constructor(
private readonly listDistros: () => Promise<string[]> = runningWslDistros,
private readonly shareRoot = "\\\\wsl.localhost",
) {}

get calls(): UsageCall[] {
return [...this.scanners.values()].flatMap(({ scanner }) => [
...scanner.calls.values(),
]);
}

/** Stopped distributions are not read, so they cannot hold up indexing. */
get caughtUp(): boolean {
return [...this.scanners.values()].every(
({ distro, scanner }) =>
scanner.caughtUp || !this.running.includes(distro),
);
}

get skippedLines(): number {
return [...this.scanners.values()].reduce(
(n, { scanner }) => n + scanner.skippedLines,
0,
);
}

/** Session-state folders found in WSL; no paths are exposed. */
get folderCount(): number {
return this.scanners.size;
}

async poll(resolve: Resolve, now = Date.now()): Promise<void> {
if (this.busy) return;
if (this.caughtUp && now - this.scannedAt < SCAN_INTERVAL_MS) return;
this.busy = true;
try {
this.scannedAt = now;
if (now - this.checkedAt >= LIST_MAX_AGE_MS) {
this.checkedAt = now;
this.running = await this.listDistros();
}
for (const distro of this.running) {
const seen = this.discoveredAt.get(distro);
if (seen !== undefined && now - seen < DISCOVER_INTERVAL_MS) continue;
this.discoveredAt.set(distro, now);
await this.discover(distro);
}
for (const distro of this.discoveredAt.keys())
if (!this.running.includes(distro)) this.discoveredAt.delete(distro);
const failures: unknown[] = [];
for (const [, { distro, scanner }] of this.scanners) {
if (!this.running.includes(distro)) continue;
try {
await scanner.poll((cwd) => {
const path = wslPathToWindows(distro, cwd);
return path === undefined ? undefined : resolve(path);
});
} catch (error) {
failures.push(error);
}
}
if (failures.length) throw failures[0];
} finally {
this.busy = false;
}
}

private async discover(distro: string): Promise<void> {
const base = join(this.shareRoot, distro);
const homes: string[] = [join(base, "root")];
try {
for (const entry of await readdir(join(base, "home"), {
withFileTypes: true,
}))
if (entry.isDirectory()) homes.push(join(base, "home", entry.name));
} catch {
/* No readable /home in this distribution. */
}
for (const home of homes) {
const root = join(home, ".copilot", "session-state");
if (this.scanners.has(root)) continue;
const info = await stat(root).catch(() => undefined);
if (info?.isDirectory())
this.scanners.set(root, {
distro,
scanner: new CliUsageScanner(root),
});
}
}
}
41 changes: 36 additions & 5 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ 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";
import { WslCliSessions } from "./core/wsl-sessions";

const KEYS = [
"enabled",
Expand Down Expand Up @@ -161,6 +162,13 @@ export async function activate(context: vscode.ExtensionContext) {
const tailers = new Map<string, UsageTailer>();
const discovering = new Set<string>();
const cliScanner = new CliUsageScanner();
const wslSessions =
process.platform === "win32" ? new WslCliSessions() : undefined;
const readWsl = () =>
wslSessions !== undefined &&
vscode.workspace
.getConfiguration("hoosage")
.get<boolean>("readWslCliSessions", true);
const views = new Set<vscode.Webview>();
const diagnostics = vscode.window.createOutputChannel("hoosage tracking");
context.subscriptions.push(diagnostics);
Expand Down Expand Up @@ -502,9 +510,21 @@ export async function activate(context: vscode.ExtensionContext) {
"Could not read Copilot CLI session data. Check storage permissions and refresh.",
);
}
if (readWsl())
try {
await wslSessions!.poll((cwd) => projectIndex.resolve(cwd));
} catch {
errors.push(
"Could not read Copilot CLI session data from WSL. Check that the distribution is running and refresh.",
);
}
const sessionCalls = [
...cliScanner.calls.values(),
...(readWsl() ? wslSessions!.calls : []),
];
const calls = [
...[...tailers.values()].flatMap((t) => [...t.calls.values()]),
...cliScanner.calls.values(),
...sessionCalls,
];
for (const [id, imported] of chatHistory) {
const live = tailers.get(id);
Expand All @@ -520,7 +540,7 @@ export async function activate(context: vscode.ExtensionContext) {
}
projects.push(
...projectIndex
.projects([...cliScanner.calls.values()])
.projects(sessionCalls)
.filter(
(project) => !projects.some((known) => known.id === project.id),
),
Expand Down Expand Up @@ -550,7 +570,10 @@ export async function activate(context: vscode.ExtensionContext) {
current.id,
);
sharedUsageCount = sharedCalls.length;
calls.push(...sharedCalls);
// A peer host may share session usage this host already reads
// directly (e.g. WSL CLI sessions); entry ids identify the same usage.
const localIds = new Set(calls.map((call) => call.id));
calls.push(...sharedCalls.filter((call) => !localIds.has(call.id)));
} catch (error) {
sharedUsageCount = 0;
errors.push(
Expand Down Expand Up @@ -594,7 +617,9 @@ 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 ||
(readWsl() && !wslSessions!.caughtUp);
return {
projects,
calls,
Expand All @@ -605,7 +630,8 @@ export async function activate(context: vscode.ExtensionContext) {
updatedAt: Date.now(),
skippedLines:
[...tailers.values()].reduce((n, t) => n + t.skippedLines, 0) +
cliScanner.skippedLines,
cliScanner.skippedLines +
(readWsl() ? wslSessions!.skippedLines : 0),
errors,
indexing,
};
Expand Down Expand Up @@ -722,6 +748,11 @@ export async function activate(context: vscode.ExtensionContext) {
`Indexing saved usage: ${state.indexing ? "yes" : "no"}`,
`Chat history scan complete here: ${history?.caughtUp ? "yes" : "no"}`,
`Local CLI session scan complete here: ${cliScanner.caughtUp ? "yes" : "no"}`,
...(wslSessions
? [
`WSL CLI session folders read here: ${readWsl() ? wslSessions.folderCount : "disabled"}`,
]
: []),
...(remoteUiFallback
? [
`Next step: install Hoosage inside the ${remoteLabel} workspace, then reload.`,
Expand Down
Loading
Loading