UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

74 lines (73 loc) • 3.57 kB
import { createHash } from "node:crypto"; import { open } from "node:fs/promises"; import { targetTranscriptPath } from "./transcripts.js"; import { terminalProvider } from "./terminal.js"; import { BACKGROUND_STALE_MS } from "./session-activity.js"; export function stallThreshold() { const value = Number(process.env.PHREN_STALL_MS ?? 300_000); return Number.isFinite(value) && value >= 0 ? value : 300_000; } async function transcriptStamp(target) { const file = await open(await targetTranscriptPath(target), "r"); try { const info = await file.stat(), start = Math.max(0, info.size - 8192), buffer = Buffer.alloc(info.size - start); await file.read(buffer, 0, buffer.length, start); return `${info.size}:${createHash("sha256").update(buffer).digest("hex")}`; } finally { await file.close(); } } /** Requires successful observations of both sources. Identity, terminal, work * status and either source changing all reset the clock. Missing data is unknown. * `live` reads the awaited work the pane's turn still waits on (`liveWork`: * awaited background shells and monitors, running children). A worker that * ended its turn on purpose while a build runs sits still on screen and in its * transcript, so once the clock runs out `live` is asked, and any live work * starts the clock over instead of reporting a stall, for at most * BACKGROUND_STALE_MS: work that never ends (a build deadlocked on a lock, a * child that died without its completion record) does not hide a stall forever. */ export class StallDetector { readers; entries = new Map(); constructor(readers = { screen: target => terminalProvider().readScreen(target.server, target.pane, { scope: "pane", source: "visible", lines: 120, stripAnsi: true, timeoutMs: 1000 }), transcript: transcriptStamp, now: Date.now, threshold: stallThreshold, }) { this.readers = readers; } async observe(target, pane, live) { const key = JSON.stringify([target.server, target.pane]); if (pane.agent_status !== "working" || this.readers.threshold() === 0) { this.entries.delete(key); return undefined; } let screen, transcript; try { [screen, transcript] = await Promise.all([this.readers.screen(target), this.readers.transcript(target)]); } catch { this.entries.delete(key); return undefined; } const signature = createHash("sha256").update(JSON.stringify([target, pane.terminal_id, screen, transcript])).digest("hex"); const now = this.readers.now(), prior = this.entries.get(key); if (!prior || prior.signature !== signature) { this.entries.delete(key); this.entries.set(key, { signature, since: now }); } while (this.entries.size > 1024) this.entries.delete(this.entries.keys().next().value); const entry = this.entries.get(key), since = entry.since; if (now - since < this.readers.threshold()) return undefined; const held = entry.liveSince === undefined || now - entry.liveSince < BACKGROUND_STALE_MS; if (live && held && await live().catch(() => undefined)) { entry.since = now; entry.liveSince ??= since; return undefined; } return { stalled: true, stalledSince: new Date(since).toISOString(), stallFor: Math.floor((now - since) / 1000) }; } } export const sessionStalls = new StallDetector();