* 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<void> {
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;
}
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<string> {
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<string[]> {
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<number> {
await this.#ensureInitialized();
return this.#nextId;
}
* Reset state (primarily for testing).
*/
reset(): void {
this.#nextId = 0;
this.#initialized = false;
}
}