ai agent
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user