/** * Session-scoped manager for agent output IDs. * * Ensures unique output IDs across task tool invocations within a session. * Prefixes each ID with a sequential number (e.g., "0-AuthProvider", "1-AuthApi"). * If a parent prefix is provided, IDs are nested (e.g., "0-Auth.1-Subtask"). * * This enables reliable agent:// URL resolution and prevents artifact collisions. */ import * as fs from "node:fs/promises"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Manages agent output ID allocation to ensure uniqueness. * * Each allocated ID gets a numeric prefix based on allocation order. * If configured with a parent prefix, the numeric prefix is appended after * the parent (e.g., "0-Parent.0-Child"). * On resume, scans existing files to find the next available index. */ export class AgentOutputManager { #nextId = 0; #initialized = false; readonly #getArtifactsDir: () => string | null; readonly #parentPrefix: string | undefined; constructor(getArtifactsDir: () => string | null, options?: { parentPrefix?: string }) { this.#getArtifactsDir = getArtifactsDir; this.#parentPrefix = options?.parentPrefix; } /** * Scan existing agent output files to find the next available ID. * This ensures we don't overwrite outputs when resuming a session. */ 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 pattern = this.#parentPrefix ? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.md$`) : /^(\d+)-.*\.md$/; let maxId = -1; for (const file of files) { const match = file.match(pattern); if (match) { const id = Number.parseInt(match[1], 10); if (id > maxId) maxId = id; } } this.#nextId = maxId + 1; } /** * Allocate a unique ID with numeric prefix. * * @param id Requested ID (e.g., "AuthProvider") * @returns Unique ID with prefix (e.g., "0-AuthProvider") */ async allocate(id: string): Promise { await this.#ensureInitialized(); const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : ""; return `${prefix}${this.#nextId++}-${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(); const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : ""; return ids.map(id => `${prefix}${this.#nextId++}-${id}`); } /** * Get the next ID that would be allocated (without allocating). */ async peekNextIndex(): Promise { await this.#ensureInitialized(); return this.#nextId; } /** * Reset state (primarily for testing). */ reset(): void { this.#nextId = 0; this.#initialized = false; } }