import { mkdir, writeFile } from "fs/promises"; import { randomBytes } from "node:crypto"; import path from "path"; import { logger } from "./logger.ts"; import { JsonlStore } from "./JsonlStore.ts"; import { agentYesHome } from "./agentYesHome.ts"; import type { AgentPermissions } from "./agentPermissions.ts"; import { appendGlobalPid, maybeCompactGlobalPids, pruneOldLogs, updateGlobalPidStatus, } from "./globalPidIndex.ts"; export interface PidRecord { _id?: string; pid: number; cli: string; args: string; prompt?: string; cwd: string; logFile: string; fifoFile: string; status: "idle" | "active" | "exited"; exitReason: string; exitCode?: number; startedAt: number; // Stable id minted at registration; mirrored to the global index as `agent_id`. agentId?: string; /** Permission posture at spawn time; mirrored to the global index. */ permissions?: AgentPermissions; /** The child CLI's latest terminal title (OSC 0/2); mirrored as `title`. */ title?: string; } export class PidStore { private storeDir: string; private store: JsonlStore; constructor(workingDir: string) { this.storeDir = path.resolve(workingDir, ".agent-yes"); this.store = new JsonlStore(path.join(this.storeDir, "pid-records.jsonl")); } async init(): Promise { try { await this.ensureGitignore(); await this.store.load(); await this.cleanStaleRecords(); // Best-effort, fire-and-forget: reclaim raw/rendered logs of long-dead // sessions across all projects. Index-driven so scattered pwd logs are // still swept. Never block startup on it. pruneOldLogs().catch(() => null); } catch (error) { logger.warn("[pidStore] Failed to initialize:", error); } } async registerProcess({ pid, cli, args, prompt, cwd, wrapperPid, parentPid, permissions, }: { pid: number; cli: string; args: string[]; prompt?: string; cwd: string; wrapperPid?: number; parentPid?: number; permissions?: AgentPermissions; }): Promise { const now = Date.now(); const argsJson = JSON.stringify(args); // The index points at the file readers should tail. During the run that is // the raw byte log (`ay logs`/`attach` stream it live); on clean exit it is // repointed to the rendered log via `markRendered`. const logFile = this.getRawLogPath(pid); const fifoFile = this.getFifoPath(pid); // Upsert by pid. Reuse an existing record's agent id so re-registration // (e.g. status churn) keeps the id stable; else adopt a caller-injected // AGENT_YES_AGENT_ID (so `ay serve`'s /api/spawn can hand back an id that // addresses this exact agent — index.ts strips it from the wrapped CLI's env // so subagents don't inherit it); else mint a fresh 12-hex id. const injected = process.env.AGENT_YES_AGENT_ID; const existing = this.store.findOne((doc) => doc.pid === pid); const agentId = existing?.agentId ?? (injected && /^[0-9a-f]{6,32}$/i.test(injected) ? injected : randomBytes(6).toString("hex")); const record: Omit = { pid, cli, args: argsJson, permissions, prompt, cwd, logFile, fifoFile, status: "active", exitReason: "", startedAt: now, agentId, }; if (existing) { await this.store.updateById(existing._id!, record); } else { await this.store.append(record as PidRecord); } const result = this.store.findOne((doc) => doc.pid === pid); if (!result) { const allRecords = this.store.getAll(); logger.error(`[pidStore] Failed to find record for PID ${pid}. All records:`, allRecords); throw new Error(`Failed to register process ${pid}`); } logger.debug(`[pidStore] Registered process ${pid}`); // Mirror to the cross-runtime global index (~/.agent-yes/pids.jsonl). // Fire-and-forget — failures must not block agent startup. appendGlobalPid({ pid, cli, prompt: prompt ?? null, cwd, log_file: logFile, fifo_file: fifoFile, status: "active", exit_code: null, exit_reason: null, started_at: now, wrapper_pid: wrapperPid ?? null, parent_pid: parentPid ?? null, agent_id: agentId, permissions: permissions ?? null, }) .then(() => maybeCompactGlobalPids()) .catch(() => null); return result; } async updateStatus( pid: number, status: PidRecord["status"], extra?: { exitReason?: string; exitCode?: number }, ): Promise { const existing = this.store.findOne((doc) => doc.pid === pid); if (!existing) return; const patch: Partial = { status }; if (extra?.exitReason !== undefined) patch.exitReason = extra.exitReason; if (extra?.exitCode !== undefined) patch.exitCode = extra.exitCode; await this.store.updateById(existing._id!, patch); logger.debug(`[pidStore] Updated process ${pid} status=${status}`); // Mirror to global index. Same fire-and-forget policy. updateGlobalPidStatus(pid, { status, exit_code: extra?.exitCode ?? null, exit_reason: extra?.exitReason ?? null, }).catch(() => null); } /** Record the child CLI's latest terminal title (see ts/titleScanner.ts — * callers are change-gated + rate-limited, so each call may write). */ async updateTitle(pid: number, title: string): Promise { const existing = this.store.findOne((doc) => doc.pid === pid); if (existing && existing.title !== title) { await this.store.updateById(existing._id!, { title }); } // Mirror to global index. Same fire-and-forget policy as updateStatus. updateGlobalPidStatus(pid, { title }).catch(() => null); } getAllRecords(): PidRecord[] { return this.store.getAll(); } /** Project-local store dir: `/.agent-yes`. Durable logs live here. */ getStoreDir() { return this.storeDir; } getLogDir() { return path.resolve(this.storeDir, "logs"); } /** Raw PTY byte log (runtime), `/.agent-yes/.raw.log`. */ getRawLogPath(pid: number) { return path.resolve(this.storeDir, `${pid}.raw.log`); } /** Rendered plain-text log (final), `/.agent-yes/.log`. */ getRenderedLogPath(pid: number) { return path.resolve(this.storeDir, `${pid}.log`); } getFifoPath(pid: number) { if (process.platform === "win32") { return `\\\\.\\pipe\\agent-yes-${pid}`; } else { // Ephemeral IPC lives under the global home root, not the project dir: // keeps the FIFO on a local filesystem (reliable mkfifo) and stable even // if the project dir is moved/removed while the agent runs. return path.resolve(agentYesHome(), "fifo", `${pid}.stdin`); } } /** * Repoint a session's log from the raw byte stream to its rendered text log * (called on clean exit once the rendered log is durably written and the raw * log has been reclaimed). Updates both the local record and global index. */ async markRendered(pid: number, renderedPath: string): Promise { const existing = this.store.findOne((doc) => doc.pid === pid); if (existing) { await this.store.updateById(existing._id!, { logFile: renderedPath }); } updateGlobalPidStatus(pid, { log_file: renderedPath }).catch(() => null); } async cleanStaleRecords(): Promise { const activeRecords = this.store.find((r) => r.status !== "exited"); for (const record of activeRecords) { if (!this.isProcessAlive(record.pid)) { await this.store.updateById(record._id!, { status: "exited", exitReason: "stale-cleanup", }); logger.debug(`[pidStore] Cleaned stale record for PID ${record.pid}`); } } } async close(): Promise { try { await this.store.compact(); } catch (error) { logger.debug("[pidStore] Compact on close failed:", error); } logger.debug("[pidStore] Database compacted and closed"); } private isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } private async ensureGitignore(): Promise { const gitignorePath = path.join(this.storeDir, ".gitignore"); const gitignoreContent = `# Auto-generated .gitignore for agent-yes # Ignore all log files and runtime data logs/ fifo/ pid-db/ *.jsonl *.jsonl~ *.jsonl.lock *.sqlite *.sqlite-* *.log *.raw.log *.lines.log *.debug.log # Ignore .gitignore itself .gitignore `; try { await mkdir(this.storeDir, { recursive: true }); await writeFile(gitignorePath, gitignoreContent, { flag: "wx" }); logger.debug(`[pidStore] Created .gitignore in ${this.storeDir}`); } catch (error: any) { if (error.code !== "EEXIST") { logger.warn(`[pidStore] Failed to create .gitignore:`, error); } } } static async findActiveFifo(workingDir: string): Promise { try { const store = new PidStore(workingDir); await store.init(); const records = store.store .find((r) => r.status !== "exited") .sort((a, b) => b.startedAt - a.startedAt); await store.close(); return records[0]?.fifoFile ?? null; } catch (error) { logger.warn("[pidStore] findActiveFifo failed:", error); return null; } } }