Files
oh-my-pi/packages/coding-agent/src/core/session-storage.ts
T
can1357 02cae69bb5 feat: added session storage abstraction and configurable bash interceptor rules
- Added in-memory session storage abstraction with SessionStorage interface and MemorySessionStorage implementation.
- Added configurable bash interceptor rules with pattern-based command interception settings.
- Added AbortSignal support to 80+ web scrapers for request cancellation.
- Changed task tool to require explicit task `id` field and single `agent` parameter at top level.
- Removed `submit_review` tool - reviewers now use `complete` tool with structured output.
- Replaced Date.now() and crypto.randomUUID() with nanoid() for unique ID generation throughout codebase.
2026-01-07 23:17:09 +01:00

380 lines
9.5 KiB
TypeScript

import {
closeSync,
existsSync,
fsyncSync,
mkdirSync,
openSync,
readFileSync,
readSync,
statSync,
writeFileSync,
writeSync,
} from "node:fs";
import { rename as renameAsync } from "node:fs/promises";
import { dirname, join } from "node:path";
export interface SessionStorageStat {
size: number;
mtimeMs: number;
mtime: Date;
}
export interface SessionStorageWriter {
writeLine(line: string): Promise<void>;
flush(): Promise<void>;
fsync(): Promise<void>;
close(): Promise<void>;
getError(): Error | undefined;
}
export interface SessionStorage {
ensureDirSync(dir: string): void;
existsSync(path: string): boolean;
readTextSync(path: string): string;
readTextPrefixSync(path: string, buf: Buffer): number;
writeTextSync(path: string, content: string): void;
statSync(path: string): SessionStorageStat;
listFilesSync(dir: string, pattern: string): string[];
exists(path: string): Promise<boolean>;
readText(path: string): Promise<string>;
writeText(path: string, content: string): Promise<void>;
rename(path: string, nextPath: string): Promise<void>;
unlink(path: string): Promise<void>;
fsyncDirSync(dir: string): void;
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter;
}
function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
// FinalizationRegistry to clean up leaked file descriptors
const writerRegistry = new FinalizationRegistry<number>((fd) => {
try {
closeSync(fd);
} catch {
// Ignore - fd may already be closed or invalid
}
});
class FileSessionStorageWriter implements SessionStorageWriter {
private fd: number;
private closed = false;
private error: Error | undefined;
private onError: ((err: Error) => void) | undefined;
constructor(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
this.onError = options?.onError;
const flags = options?.flags ?? "a";
// Ensure parent directory exists
const dir = dirname(path);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Open file once, keep fd for lifetime
this.fd = openSync(path, flags === "w" ? "w" : "a");
// Register for cleanup if abandoned without close()
writerRegistry.register(this, this.fd, this);
}
private recordError(err: unknown): Error {
const error = toError(err);
if (!this.error) this.error = error;
this.onError?.(error);
return error;
}
async writeLine(line: string): Promise<void> {
if (this.closed) throw new Error("Writer closed");
if (this.error) throw this.error;
try {
const buf = Buffer.from(line, "utf-8");
let offset = 0;
while (offset < buf.length) {
const written = writeSync(this.fd, buf, offset, buf.length - offset);
if (written === 0) {
throw new Error("Short write");
}
offset += written;
}
} catch (err) {
throw this.recordError(err);
}
}
async flush(): Promise<void> {
if (this.error) throw this.error;
// OS buffers are flushed on fsync, nothing to do here
}
async fsync(): Promise<void> {
if (this.closed) throw new Error("Writer closed");
if (this.error) throw this.error;
try {
fsyncSync(this.fd);
} catch (err) {
throw this.recordError(err);
}
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
// Unregister from finalization - we're closing properly
writerRegistry.unregister(this);
try {
closeSync(this.fd);
} catch {
// Ignore close errors
}
}
getError(): Error | undefined {
return this.error;
}
}
export class FileSessionStorage implements SessionStorage {
ensureDirSync(dir: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
existsSync(path: string): boolean {
return existsSync(path);
}
readTextSync(path: string): string {
return readFileSync(path, "utf-8");
}
readTextPrefixSync(path: string, buf: Buffer): number {
const fd = openSync(path, "r");
try {
const bytesRead = readSync(fd, buf, 0, buf.length, 0);
return bytesRead;
} finally {
closeSync(fd);
}
}
writeTextSync(path: string, content: string): void {
this.ensureDirSync(dirname(path));
writeFileSync(path, content);
}
statSync(path: string): SessionStorageStat {
const stats = statSync(path);
return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
}
listFilesSync(dir: string, pattern: string): string[] {
try {
return Array.from(new Bun.Glob(pattern).scanSync(dir)).map((name) => join(dir, name));
} catch {
return [];
}
}
exists(path: string): Promise<boolean> {
return Bun.file(path).exists();
}
readText(path: string): Promise<string> {
return Bun.file(path).text();
}
async writeText(path: string, content: string): Promise<void> {
await Bun.write(path, content, { createPath: true });
}
async rename(path: string, nextPath: string): Promise<void> {
try {
await renameAsync(path, nextPath);
} catch (err) {
throw toError(err);
}
}
unlink(path: string): Promise<void> {
return Bun.file(path).unlink();
}
fsyncDirSync(dir: string): void {
try {
const fd = openSync(dir, "r");
try {
fsyncSync(fd);
} finally {
closeSync(fd);
}
} catch {
// Best-effort: some platforms/filesystems don't support fsync on directories.
}
}
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
return new FileSessionStorageWriter(path, options);
}
}
function matchesPattern(name: string, pattern: string): boolean {
if (pattern === "*") return true;
if (pattern.startsWith("*.")) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
}
class MemorySessionStorageWriter implements SessionStorageWriter {
private storage: MemorySessionStorage;
private path: string;
private closed = false;
private error: Error | undefined;
private onError: ((err: Error) => void) | undefined;
private ready: Promise<void>;
constructor(
storage: MemorySessionStorage,
path: string,
options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
) {
this.storage = storage;
this.path = path;
this.onError = options?.onError;
this.ready = this.initialize(options?.flags ?? "a");
}
private async initialize(flags: "a" | "w"): Promise<void> {
if (flags === "w") {
await this.storage.writeText(this.path, "");
}
}
private recordError(err: unknown): Error {
const error = toError(err);
if (!this.error) this.error = error;
this.onError?.(error);
return error;
}
async writeLine(line: string): Promise<void> {
if (this.closed) throw new Error("Writer closed");
await this.ready;
if (this.error) throw this.error;
try {
const existing = this.storage.existsSync(this.path) ? this.storage.readTextSync(this.path) : "";
await this.storage.writeText(this.path, `${existing}${line}`);
} catch (err) {
throw this.recordError(err);
}
}
async flush(): Promise<void> {
await this.ready;
if (this.error) throw this.error;
}
async fsync(): Promise<void> {
// No-op for in-memory storage
await this.ready;
if (this.error) throw this.error;
}
async close(): Promise<void> {
if (this.closed) return;
await this.ready;
this.closed = true;
}
getError(): Error | undefined {
return this.error;
}
}
export class MemorySessionStorage implements SessionStorage {
private files = new Map<string, { content: string; mtimeMs: number }>();
ensureDirSync(_dir: string): void {
// No-op for in-memory storage.
}
existsSync(path: string): boolean {
return this.files.has(path);
}
readTextSync(path: string): string {
const entry = this.files.get(path);
if (!entry) throw new Error(`File not found: ${path}`);
return entry.content;
}
readTextPrefixSync(path: string, buf: Buffer): number {
const content = this.readTextSync(path);
return buf.write(content, 0, buf.length, "utf-8");
}
writeTextSync(path: string, content: string): void {
this.files.set(path, { content, mtimeMs: Date.now() });
}
statSync(path: string): SessionStorageStat {
const entry = this.files.get(path);
if (!entry) throw new Error(`File not found: ${path}`);
return {
size: entry.content.length,
mtimeMs: entry.mtimeMs,
mtime: new Date(entry.mtimeMs),
};
}
listFilesSync(dir: string, pattern: string): string[] {
const prefix = dir.endsWith("/") ? dir : `${dir}/`;
const files: string[] = [];
for (const path of this.files.keys()) {
if (!path.startsWith(prefix)) continue;
const name = path.slice(prefix.length);
if (name.includes("/") || name.includes("\\")) continue;
if (!matchesPattern(name, pattern)) continue;
files.push(path);
}
return files;
}
exists(path: string): Promise<boolean> {
return Promise.resolve(this.existsSync(path));
}
readText(path: string): Promise<string> {
return Promise.resolve(this.readTextSync(path));
}
writeText(path: string, content: string): Promise<void> {
this.writeTextSync(path, content);
return Promise.resolve();
}
rename(path: string, nextPath: string): Promise<void> {
const entry = this.files.get(path);
if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
this.files.set(nextPath, entry);
this.files.delete(path);
return Promise.resolve();
}
unlink(path: string): Promise<void> {
this.files.delete(path);
return Promise.resolve();
}
fsyncDirSync(_dir: string): void {
// No-op for in-memory storage.
}
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
return new MemorySessionStorageWriter(this, path, options);
}
}