/** * Session-scoped artifact storage for truncated tool outputs. * * Artifacts are stored in a directory alongside the session file, * accessible via artifact:// URLs. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; /** * Manages artifact storage for a session. * * Artifacts are stored with sequential IDs in the session's artifact directory. * The directory is created lazily on first write. * * Subagents do not own their own `ArtifactManager`. The parent's instance is * adopted via `SessionManager.adoptArtifactManager`, so the whole parent + * subagent tree shares one ID space and one directory. */ export class ArtifactManager { #nextId = 0; readonly #dir: string; #dirCreated = false; #initialized = false; /** * @param dir Directory that will hold artifact files. Created lazily on first save. */ constructor(dir: string) { this.#dir = dir; } /** * Artifact directory path. * Directory may not exist until first artifact is saved. */ get dir(): string { return this.#dir; } async #ensureDir(): Promise { if (!this.#dirCreated) { await fs.mkdir(this.#dir, { recursive: true }); this.#dirCreated = true; } if (!this.#initialized) { await this.#scanExistingIds(); this.#initialized = true; } } /** * Scan existing artifact files to find the next available ID. * This ensures we don't overwrite artifacts when resuming a session. */ async #scanExistingIds(): Promise { const files = await this.listFiles(); let maxId = -1; for (const file of files) { // Files are named: {id}.{toolType}.log const match = file.match(/^(\d+)\..*\.log$/); if (match) { const id = parseInt(match[1], 10); if (id > maxId) maxId = id; } } this.#nextId = maxId + 1; } /** * Atomically allocate next artifact ID. * IDs are sequential within the session. */ allocateId(): number { return this.#nextId++; } /** * Allocate a new artifact path and ID without writing content. * * @param toolType Tool name for file extension (e.g., "bash", "read") */ async allocatePath(toolType: string): Promise<{ id: string; path: string }> { await this.#ensureDir(); const id = String(this.allocateId()); const filename = `${id}.${toolType}.log`; return { id, path: path.join(this.#dir, filename) }; } /** * Save content as an artifact and return the artifact ID. * * @param content Full content to save * @param toolType Tool name for file extension (e.g., "bash", "read") * @returns Artifact ID (numeric string) */ async save(content: string, toolType: string): Promise { const { id, path } = await this.allocatePath(toolType); await Bun.write(path, content); return id; } /** * Check if an artifact exists. * @param id Artifact ID (numeric string) */ async exists(id: string): Promise { const files = await this.listFiles(); return files.some(f => f.startsWith(`${id}.`)); } /** * List all artifact files in the directory. * Returns empty array if directory doesn't exist. */ async listFiles(): Promise { try { return await fs.readdir(this.#dir); } catch { return []; } } /** * Get the full path to an artifact file. * Returns null if artifact doesn't exist. * * @param id Artifact ID (numeric string) */ async getPath(id: string): Promise { const files = await this.listFiles(); const match = files.find(f => f.startsWith(`${id}.`)); return match ? path.join(this.#dir, match) : null; } }