This commit is contained in:
2026-09-10 19:18:15 +02:00
parent 3a3d70efeb
commit 5993da4ce6
21 changed files with 2916 additions and 340 deletions
+222
View File
@@ -0,0 +1,222 @@
/**
* 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<string> {
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<AgentInstance[]> {
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<void> {
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<PruneResult> {
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<string>();
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<AgentIndexManifest> {
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<AgentIndexManifest | null> {
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);
}