import { mkdir, readFile, rename, rmdir, stat } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { type Context, type JsonlSessionMetadata, JsonlSessionRepo, type Session } from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/harness/env/nodejs"; export interface OpenedSession { session: Session; repo: JsonlSessionRepo; path: string; created: boolean; } const SAFE_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; export function sessionPathFor(sessionDir: string, runId: string): string { if (!SAFE_RUN_ID.test(runId) || runId.includes("..")) throw new Error(`runId 不能用作文件名: ${runId}`); return join(resolve(sessionDir), `${runId}.jsonl`); } /** * 打开/创建 sessionDir/.jsonl。 * JsonlSessionRepo 默认会把文件放到 `/--cwd--/<时间戳>_.jsonl`;这里创建后把空会话文件 * (只有 header)移到固定路径,再按元数据打开,之后所有写入都追加到固定路径。 * NodeExecutionEnv 只当作会话目录的 FileSystem 使用,它的 exec/shell 从不暴露给模型。 */ export async function openRunSession(sessionDir: string, runId: string, ctx: Context): Promise { const root = resolve(sessionDir); await mkdir(root, { recursive: true }); const target = sessionPathFor(root, runId); const fileSystem = new NodeExecutionEnv({ cwd: root }); const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot: root }); const existing = await stat(target).catch(() => undefined); if (existing) { const firstLine = (await readFile(target, "utf8")).split("\n", 1)[0] ?? ""; const header = JSON.parse(firstLine) as { id: string; createdAt: number; storageVersion: number; cwd: string }; if (header.id !== runId) throw new Error(`会话文件 ${target} 属于 ${header.id},不是 ${runId}`); const metadata: JsonlSessionMetadata = { id: header.id, createdAt: header.createdAt, storageVersion: header.storageVersion, cwd: header.cwd, path: target, modifiedAt: existing.mtimeMs, }; const session = await repo.open(metadata, ctx); return { session, repo, path: target, created: false }; } const draft = await repo.create({ id: runId, cwd: root }, ctx); const draftPath = draft.metadata.path; await draft.close(ctx); await rename(draftPath, target); await rmdir(dirname(draftPath)).catch(() => undefined); const info = await stat(target); const session = await repo.open({ ...draft.metadata, path: target, modifiedAt: info.mtimeMs }, ctx); return { session, repo, path: target, created: true }; }