diff --git a/README.md b/README.md index caef078..b0ebe0a 100644 --- a/README.md +++ b/README.md @@ -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\` (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\\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: @@ -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. diff --git a/package.json b/package.json index e745aac..e372a76 100644 --- a/package.json +++ b/package.json @@ -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)." } } } diff --git a/src/core/wsl-sessions.ts b/src/core/wsl-sessions.ts new file mode 100644 index 0000000..c72ca30 --- /dev/null +++ b/src/core/wsl-sessions.ts @@ -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\ 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 { + 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\\home\\.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(); + private readonly discoveredAt = new Map(); + private running: string[] = []; + private checkedAt = -Infinity; + private scannedAt = -Infinity; + private busy = false; + + constructor( + private readonly listDistros: () => Promise = 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 { + 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 { + 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), + }); + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 66d0007..e50bf1d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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", @@ -161,6 +162,13 @@ export async function activate(context: vscode.ExtensionContext) { const tailers = new Map(); const discovering = new Set(); const cliScanner = new CliUsageScanner(); + const wslSessions = + process.platform === "win32" ? new WslCliSessions() : undefined; + const readWsl = () => + wslSessions !== undefined && + vscode.workspace + .getConfiguration("hoosage") + .get("readWslCliSessions", true); const views = new Set(); const diagnostics = vscode.window.createOutputChannel("hoosage tracking"); context.subscriptions.push(diagnostics); @@ -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); @@ -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), ), @@ -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( @@ -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, @@ -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, }; @@ -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.`, diff --git a/test/wsl-sessions.test.ts b/test/wsl-sessions.test.ts new file mode 100644 index 0000000..84402ad --- /dev/null +++ b/test/wsl-sessions.test.ts @@ -0,0 +1,142 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + parseDistroList, + WslCliSessions, + wslPathToWindows, +} from "../src/core/wsl-sessions"; + +test("WSL paths map to the wsl.localhost share or DrvFs drives", () => { + assert.equal( + wslPathToWindows("VRKB", "/home/me/src/repo"), + "\\\\wsl.localhost\\VRKB\\home\\me\\src\\repo", + ); + assert.equal( + wslPathToWindows("Ubuntu-24.04", "/mnt/c/Users/me/repo/"), + "C:\\Users\\me\\repo\\", + ); + assert.equal(wslPathToWindows("Debian", "/mnt/d"), "D:\\"); + assert.equal( + wslPathToWindows("Debian", "/home/me/../other"), + "\\\\wsl.localhost\\Debian\\home\\other", + ); + assert.equal(wslPathToWindows("Debian", "relative/path"), undefined); + assert.equal(wslPathToWindows("bad\\name", "/home/me"), undefined); + assert.equal(wslPathToWindows("Debian", "/home/\0me"), undefined); +}); + +test("distribution list parsing handles UTF-16LE wsl.exe output", () => { + const utf16 = Buffer.from("\uFEFFVRKB\r\nUbuntu-24.04\r\n\r\n", "utf16le"); + assert.deepEqual(parseDistroList(utf16), ["VRKB", "Ubuntu-24.04"]); + assert.deepEqual(parseDistroList(Buffer.from("Debian\nDebian\n")), [ + "Debian", + ]); + assert.deepEqual( + parseDistroList(Buffer.from("bad name\n..\\x\nok\n", "utf8")), + ["ok"], + ); +}); + +const event = (value: Record) => JSON.stringify(value) + "\n"; + +test("running distributions are read and cwd is resolved in Windows form", async () => { + const share = await mkdtemp(join(tmpdir(), "hoosage-wsl-")); + try { + const sessions = join( + share, + "VRKB", + "home", + "me", + ".copilot", + "session-state", + ); + await mkdir(join(sessions, "s1"), { recursive: true }); + await writeFile( + join(sessions, "s1", "events.jsonl"), + event({ + type: "session.start", + timestamp: "2026-09-28T08:00:00Z", + data: { context: { cwd: "/home/me/src/vrkb" } }, + }) + + event({ + type: "session.usage_checkpoint", + id: "c1", + timestamp: "2026-09-28T08:10:00Z", + data: { + totalNanoAiu: 500_000_000_000, + modelCacheState: [{ modelId: "gpt-5.6-sol" }], + }, + }), + ); + await mkdir( + join(share, "Stopped", "home", "me", ".copilot", "session-state", "s2"), + { + recursive: true, + }, + ); + let running = ["VRKB"]; + let listed = 0; + const wsl = new WslCliSessions(async () => { + listed++; + return running; + }, share); + const seen: string[] = []; + const resolve = (cwd: string) => { + seen.push(cwd); + return cwd === "\\\\wsl.localhost\\VRKB\\home\\me\\src\\vrkb" + ? "project-vrkb" + : undefined; + }; + await wsl.poll(resolve, 0); + assert.equal(wsl.folderCount, 1); + assert.equal(wsl.caughtUp, true); + assert.deepEqual( + wsl.calls.map((call) => [call.projectId, call.nanoAiu, call.model]), + [["project-vrkb", 500_000_000_000, "gpt-5.6-sol"]], + ); + assert.ok(seen.every((cwd) => cwd.startsWith("\\\\wsl.localhost\\VRKB"))); + + // Reads are throttled while caught up, the running list is refreshed + // before a read, and stopped distributions keep their earlier usage. + await wsl.poll(resolve, 1_000); + assert.equal(listed, 1); + running = []; + await wsl.poll(resolve, 16_000); + assert.equal(listed, 2); + await appendFile( + join(sessions, "s1", "events.jsonl"), + event({ + type: "session.usage_checkpoint", + id: "c2", + timestamp: "2026-09-28T08:20:00Z", + data: { totalNanoAiu: 700_000_000_000 }, + }), + ); + await wsl.poll(resolve, 32_000); + assert.equal(listed, 3); + assert.equal(wsl.calls.length, 1); + running = ["VRKB"]; + await wsl.poll(resolve, 48_000); + assert.equal(listed, 4); + assert.equal(wsl.calls.length, 2); + assert.equal(wsl.calls[1]!.nanoAiu, 200_000_000_000); + } finally { + await rm(share, { recursive: true, force: true }); + } +}); + +test("no running distribution means nothing is read", async () => { + const share = await mkdtemp(join(tmpdir(), "hoosage-wsl-")); + try { + const wsl = new WslCliSessions(async () => [], share); + await wsl.poll(() => "p", 0); + assert.equal(wsl.folderCount, 0); + assert.deepEqual(wsl.calls, []); + assert.equal(wsl.caughtUp, true); + } finally { + await rm(share, { recursive: true, force: true }); + } +});