UNPKG

@phren/cli

Version:

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

100 lines (99 loc) • 4.58 kB
import { readFile } from "node:fs/promises"; import path from "node:path"; import { createHash } from "node:crypto"; import { atomicInPrivateDir, bridgeRoot, object, objects } from "./protocol.js"; const digest = (text) => createHash("sha256").update(text).digest("hex"); export const tabActivityKey = (workspace, tab) => JSON.stringify([workspace, tab]); /** A clock for Herdr's clockless state counters. Only successful snapshots * prune tabs; each activity pass also prunes missing servers. Writes are ordered * and atomic, and an unchanged poll never rewrites the file. */ export class TabActivityStore { file; now; entries = new Map(); loaded = false; dirty = false; pending = Promise.resolve(); constructor(file = path.join(bridgeRoot(), "tab-activity.json"), now = () => new Date()) { this.file = file; this.now = now; } observe(server, snapshot) { const at = this.now().toISOString(); const operation = this.pending.catch(() => { }).then(async () => { await this.load(); const prefix = JSON.stringify(server) + ":"; const seen = new Set(), times = new Map(); let changed = false; for (const tab of objects(snapshot.tabs)) { const tabKey = tabActivityKey(tab.workspace_id, tab.tab_id), key = prefix + tabKey; seen.add(key); // Include every pane: a lower counter changing must count even when // another pane still has the tab's maximum state_change_seq. const panes = objects(snapshot.panes).filter(p => p.workspace_id === tab.workspace_id && p.tab_id === tab.tab_id) .map(p => [p.pane_id, p.agent, p.agent_status, p.state_change_seq, p.title || p.terminal_title_stripped]) .sort((a, b) => String(a[0]).localeCompare(String(b[0]))); const signature = digest(JSON.stringify([tab.agent_status, tab.state_change_seq, tab.label, tab.title, panes])); let stamp = this.entries.get(key); if (stamp?.signature !== signature) { stamp = { signature, lastChangedAt: at }; this.entries.set(key, stamp); changed = true; } times.set(tabKey, stamp.lastChangedAt); } for (const key of this.entries.keys()) { if (key.startsWith(prefix) && !seen.has(key)) { this.entries.delete(key); changed = true; } } this.dirty ||= changed; await this.persist(); return times; }); this.pending = operation; return operation; } async load() { if (!this.loaded) { const saved = await readFile(this.file, "utf8").then(JSON.parse).catch(() => ({})); for (const [key, value] of Object.entries(object(object(saved).entries))) { const stamp = object(value); if (typeof stamp.signature === "string" && typeof stamp.lastChangedAt === "string" && Number.isFinite(Date.parse(stamp.lastChangedAt))) { const hashed = /^[a-f0-9]{64}$/.test(stamp.signature); this.entries.set(key, { signature: hashed ? stamp.signature : digest(stamp.signature), lastChangedAt: stamp.lastChangedAt }); this.dirty ||= !hashed; } } this.loaded = true; } } async persist() { if (this.dirty) { try { await atomicInPrivateDir(this.file, JSON.stringify({ version: 1, entries: Object.fromEntries(this.entries) })); this.dirty = false; } catch { // A full/unwritable disk must not hide live sessions. Keep the clock // in memory and retry saving on the next successful snapshot. } } } pruneServers(servers) { const operation = this.pending.catch(() => { }).then(async () => { await this.load(); const prefixes = servers.map(server => JSON.stringify(server) + ":"); for (const key of this.entries.keys()) if (!prefixes.some(prefix => key.startsWith(prefix))) { this.entries.delete(key); this.dirty = true; } await this.persist(); }); this.pending = operation; return operation; } }