/** * Session-scoped manager for agent output IDs. * * Keeps every subagent output id unique within a session without polluting the * common case with bookkeeping. A requested name is used verbatim the first * time it appears; only a *repeated* name gets a numeric suffix to disambiguate * it (e.g. "Anna", "Anna-2", "Anna-3"). When a parent prefix is configured, ids * are nested under it (e.g. "Anna.Bob") so hierarchical outputs stay grouped. * * This enables reliable agent:// URL resolution and prevents artifact * collisions across repeated or nested task invocations. */ import * as fs from "node:fs/promises"; /** * Manages agent output ID allocation to ensure uniqueness. * * The first allocation of a given name keeps the name as-is; subsequent * allocations of the same name get a `-2`, `-3`, … suffix. On resume, scans * existing output files so previously written outputs are never overwritten. */ export class AgentOutputManager { #initialized = false; /** Final ids already handed out, relative to this manager's scope. */ readonly #taken = new Set(); readonly #getArtifactsDir: () => string | null; readonly #parentPrefix: string | undefined; constructor(getArtifactsDir: () => string | null, options?: { parentPrefix?: string }) { this.#getArtifactsDir = getArtifactsDir; this.#parentPrefix = options?.parentPrefix; } /** * Seed the taken-id set from output files already on disk so a resumed * session never reuses a name that would clobber a prior subagent's output. */ async #ensureInitialized(): Promise { if (this.#initialized) return; this.#initialized = true; const dir = this.#getArtifactsDir(); if (!dir) return; let files: string[]; try { files = await fs.readdir(dir); } catch { return; // Directory doesn't exist yet } const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : ""; for (const file of files) { if (!file.endsWith(".md")) continue; let rest = file.slice(0, -3); // drop ".md" if (prefix) { if (!rest.startsWith(prefix)) continue; rest = rest.slice(prefix.length); } // Requested ids never contain "."; a dot marks a nested child, so this // manager only owns the first segment of whatever remains. const dot = rest.indexOf("."); const segment = dot === -1 ? rest : rest.slice(0, dot); if (segment) this.#taken.add(segment); } } /** Pick the first free name (base, then `base-2`, `base-3`, …) and reserve it. */ #allocateUnique(id: string): string { let candidate = id; for (let n = 2; this.#taken.has(candidate); n++) { candidate = `${id}-${n}`; } this.#taken.add(candidate); return this.#parentPrefix ? `${this.#parentPrefix}.${candidate}` : candidate; } /** * Allocate a unique ID. * * @param id Requested ID (e.g., "Anna") * @returns Unique ID ("Anna" first, then "Anna-2", "Anna-3", …) */ async allocate(id: string): Promise { await this.#ensureInitialized(); return this.#allocateUnique(id); } /** * Allocate unique IDs for a batch of tasks. * * @param ids Array of requested IDs * @returns Array of unique IDs in same order */ async allocateBatch(ids: string[]): Promise { await this.#ensureInitialized(); return ids.map(id => this.#allocateUnique(id)); } }