Files
oh-my-pi/packages/coding-agent/src/session/artifacts.ts
T
can1357 9ed81977f7 feat(coding-agent): shared artifact manager and flat output directory across subagent sessions
- Added parent-to-subagent artifact manager adoption so subagents reuse the parent `ArtifactManager` and write artifacts into a shared directory with shared IDs.
- Passed the shared artifact manager through tool/session context into subagent executor startup and exposed it via `SessionManager` and `ToolSession` for lookup.
- Updated kernel environment and artifact-resolution paths to prefer `PI_ARTIFACTS_DIR`, falling back to existing session-file-based behavior when absent.
2026-05-12 05:17:53 +02:00

136 lines
3.5 KiB
TypeScript

/**
* 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<void> {
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<void> {
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<string> {
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<boolean> {
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<string[]> {
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<string | null> {
const files = await this.listFiles();
const match = files.find(f => f.startsWith(`${id}.`));
return match ? path.join(this.#dir, match) : null;
}
}