/** * Live-instance registry. * * Each VS Code window that enables AI Agent access writes **its own** file * under `instances/`. Two properties follow from that: * * - **No locking and no merging.** Writers never touch each other's files, so * there is no read-modify-write race to guard. An earlier design that * shared a single JSON file would have needed a lock file (itself another * thing a crash can leave behind) plus a merge step. * - **Crash recovery does not wait for the same workspace.** Any instance can * prune entries whose recorded PID is dead, so a crashed window is cleaned * up the next time *any* VS Code window with the extension activates. * * A merged, read-only `index.json` is derived from the instance files purely * for discovery (agents/humans looking at the directory), so the machine * coordination files and the human-readable view stay separate. * * Pure TypeScript: no VS Code dependency. */ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { isProcessAlive, type AgentEndpoint } from "./endpoint"; import { defaultAgentHome, snapshotBaseName } from "./snapshot"; /** One live extension-host instance. */ export interface AgentInstance extends AgentEndpoint { /** Unique per window; also the file name stem. */ instanceId: string; } /** Discovery manifest derived from all live instances. */ export interface AgentIndexManifest { schemaVersion: number; generatedAt: string; instances: Array<{ instanceId: string; processId?: number; url: string; /** Tokens are intentionally omitted: the manifest is for discovery. */ projects: string[]; }>; projects: string[]; } export const INSTANCE_SCHEMA_VERSION = 1; /** Directory holding one file per live extension host. */ export function instancesDir(agentHome = defaultAgentHome()): string { return join(agentHome, "instances"); } /** Path to the merged discovery manifest. */ export function manifestPath(agentHome = defaultAgentHome()): string { return join(agentHome, "index.json"); } /** File name for one instance. */ export function instanceFileName(instanceId: string): string { return `vscode-${instanceId}.json`; } /** * Builds a process-unique instance id. Combining the PID with a random suffix * keeps two windows of the same process id from colliding across restarts. */ export function makeInstanceId(pid = process.pid): string { const rand = Math.random().toString(36).slice(2, 8); return `${pid}-${rand}`; } /** Writes this instance's own file. */ export async function writeInstance( instance: AgentInstance, agentHome = defaultAgentHome(), ): Promise { const file = join(instancesDir(agentHome), instanceFileName(instance.instanceId)); await mkdir(dirname(file), { recursive: true }); await writeFile(file, `${JSON.stringify(instance, null, 2)}\n`, "utf8"); return file; } /** Reads every instance file, skipping malformed ones. */ export async function readInstances( agentHome = defaultAgentHome(), ): Promise { const dir = instancesDir(agentHome); let names: string[]; try { names = await readdir(dir); } catch { return []; } const out: AgentInstance[] = []; for (const name of names) { if (!name.endsWith(".json")) continue; try { const parsed = JSON.parse( await readFile(join(dir, name), "utf8"), ) as AgentInstance; if (parsed?.url && parsed?.token) { // Fall back to the file name when an older file lacks the field. parsed.instanceId ??= name.replace(/^vscode-/, "").replace(/\.json$/, ""); out.push(parsed); } } catch { // Skip unreadable/corrupt entries. } } return out; } /** Removes this instance's own file. */ export async function clearInstance( instanceId: string, agentHome = defaultAgentHome(), ): Promise { await rm(join(instancesDir(agentHome), instanceFileName(instanceId)), { force: true, }); } export interface PruneResult { removed: string[]; kept: AgentInstance[]; } /** * Removes instance files whose recorded PID is no longer alive. * * This is how crashes are cleaned up without waiting for the same workspace to * be reopened. Only clearly-dead PIDs are pruned: `isProcessAlive` treats an * unknown or unparseable PID as alive, so an older file that predates the * `processId` field is never deleted by mistake. */ export async function pruneInstances( agentHome = defaultAgentHome(), ): Promise { const instances = await readInstances(agentHome); const removed: string[] = []; const kept: AgentInstance[] = []; for (const instance of instances) { if (isProcessAlive(instance.processId)) { kept.push(instance); } else { removed.push(instance.instanceId); await clearInstance(instance.instanceId, agentHome).catch(() => undefined); } } return { removed, kept }; } /** Collects every project root across live instances. */ export function projectsOf(instances: readonly AgentInstance[]): string[] { const seen = new Set(); const out: string[] = []; for (const instance of instances) { const candidates = [ ...(instance.projects ?? []), ...(instance.projectDir ? [instance.projectDir] : []), ]; for (const project of candidates) { const key = project.toLowerCase(); if (seen.has(key)) continue; seen.add(key); out.push(project); } } return out; } /** * Regenerates the merged read-only manifest from the live instances. * Best-effort: failures are swallowed because the manifest is a convenience, * not a correctness requirement (readers can always scan `instances/`). */ export async function writeManifest( instances: readonly AgentInstance[], agentHome = defaultAgentHome(), ): Promise { const manifest: AgentIndexManifest = { schemaVersion: INSTANCE_SCHEMA_VERSION, generatedAt: new Date().toISOString(), instances: instances.map((instance) => ({ instanceId: instance.instanceId, processId: instance.processId, url: instance.url, projects: [ ...(instance.projects ?? []), ...(instance.projectDir ? [instance.projectDir] : []), ], })), projects: projectsOf(instances), }; try { const file = manifestPath(agentHome); await mkdir(dirname(file), { recursive: true }); await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); } catch { // Discovery only: never fail the caller over this. } return manifest; } /** Reads the discovery manifest, or null when absent/malformed. */ export async function readManifest( agentHome = defaultAgentHome(), ): Promise { try { return JSON.parse( await readFile(manifestPath(agentHome), "utf8"), ) as AgentIndexManifest; } catch { return null; } } /** Snapshot path for a project, re-exported for discovery convenience. */ export function projectKey(projectDir: string): string { return snapshotBaseName(projectDir); }